From 1ea63516e9676d9a8f42745830013ba3b84de1da Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 03:11:38 +0800 Subject: [PATCH 01/18] feat(action-list): support imperative rectangle anchors --- .../actions/ActionList/ActionList.stories.tsx | 28 ++- .../ActionList/imperativeShowUtils.test.ts | 130 +++++++++++++ .../actions/ActionList/imperativeShowUtils.ts | 127 +++++++++++++ .../src/actions/ActionList/index.native.tsx | 176 +++++++++-------- .../src/actions/ActionList/index.tsx | 177 ++++++++++-------- .../AccountEdit/AccountEditButton.tsx | 50 +++-- 6 files changed, 515 insertions(+), 173 deletions(-) create mode 100644 packages/components/src/actions/ActionList/imperativeShowUtils.test.ts create mode 100644 packages/components/src/actions/ActionList/imperativeShowUtils.ts 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..43cebd39ccd4 --- /dev/null +++ b/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts @@ -0,0 +1,130 @@ +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('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..8a401e05b8e6 --- /dev/null +++ b/packages/components/src/actions/ActionList/imperativeShowUtils.ts @@ -0,0 +1,127 @@ +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; + + const handleOpenChange = (isOpen: boolean) => { + if (isClosed) { + return; + } + if (isOpen) { + onOpenChange?.(true); + return; + } + + isClosed = true; + onOpenChange?.(false); + if (onClose) { + schedule(onClose, 0); + } + schedule(destroy, CLOSE_ANIMATION_DURATION); + }; + + return { + handleOpenChange, + close: () => handleOpenChange(false), + }; +} + +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..bbb190bb453f 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 => { 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; @@ -586,12 +610,7 @@ const showActionList = ( , ); - return { - close: () => { - handleOpenChange(false); - ref.destroy(); - }, - }; + return { close: lifecycle.close }; }; function ActionListFrame(props: IActionListProps) { const isProcessing = useRef(false); @@ -632,9 +651,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) => { +const show = (props: IShowActionListParams): IActionListShowHandle => { imperativeActionList?.close(); 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..899729dfb451 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 => { 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; @@ -575,12 +600,7 @@ const showActionList = ( , ); - return { - close: () => { - handleOpenChange(false); - ref.destroy(); - }, - }; + return { close: lifecycle.close }; }; function ActionListFrame(props: IActionListProps) { const isProcessing = useRef(false); @@ -621,9 +641,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) => { +const show = (props: IShowActionListParams): IActionListShowHandle => { imperativeActionList?.close(); imperativeActionList = showActionList(props, undefined); + return imperativeActionList; }; export const ActionList = withStaticProperties(ActionListFrame, { 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} /> ); } From 64bd436ae34f9848c4fbd17e0a6222ec0790a893 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 14:29:26 +0800 Subject: [PATCH 02/18] feat: migrate account and network selectors to native list --- apps/mobile/dev-vendor.config.js | 1 + .../OneKeyWallet.xcodeproj/project.pbxproj | 2 + apps/mobile/ios/Podfile.lock | 182 +- apps/mobile/package.json | 75 +- docs/native-list-selector-performance.md | 119 + package.json | 4 +- packages/components/package.json | 6 +- packages/kit/package.json | 1 + .../AccountSelectorStackV2.tsx | 85 + .../WalletDetails/AccountEditActionListV2.tsx | 342 + .../WalletDetails/AccountSelectorActionV2.tsx | 186 + .../WalletDetails/WalletDetailsV2.tsx | 813 ++ .../accountSelectorAccountRowsV2.ts | 437 + .../accountSelectorValueV2.test.ts | 121 + .../WalletDetails/accountSelectorValueV2.ts | 155 + .../AccountSelectorWalletListSideBarV2.tsx | 632 ++ .../accountSelectorNativeListV2.ts | 260 + .../AccountManagerStacks/router/index.tsx | 2 +- .../NetworkContentV2.tsx | 194 + .../NetworkSectionListV2.tsx | 416 + .../NetworksSectionListV2.test.tsx | 183 + .../NetworksSectionListV2.tsx | 373 + .../PortfolioContentV2.tsx | 115 + .../UnifiedNetworkSelectorV2.tsx | 873 ++ .../UnifiedNetworkSelectorV2/index.tsx | 1 + .../useNetworkListPresentationV2.test.tsx | 90 + .../useNetworkListPresentationV2.ts | 209 + .../useNetworkTooltipV2.tsx | 156 + .../src/views/ChainSelector/router/index.ts | 2 +- ...@onekeyfe+react-native-image+3.0.105.patch | 1625 ++++ ...yfe+react-native-native-list+3.0.105.patch | 7474 +++++++++++++++++ yarn.lock | 394 +- 32 files changed, 15217 insertions(+), 311 deletions(-) create mode 100644 docs/native-list-selector-performance.md create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountEditActionListV2.tsx create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/AccountSelectorActionV2.tsx create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletList/AccountSelectorWalletListSideBarV2.tsx create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkContentV2.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworkSectionListV2.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/PortfolioContentV2.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/index.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkTooltipV2.tsx create mode 100644 patches/@onekeyfe+react-native-image+3.0.105.patch create mode 100644 patches/@onekeyfe+react-native-native-list+3.0.105.patch 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..bc4e2eec6f5f 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.105): - 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.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -51,7 +51,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.104): + - AutoSizeInput (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -74,7 +74,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.104): + - BackgroundThread (3.0.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -121,7 +121,7 @@ PODS: - ExpoModulesCore - SPAlert (~> 4.2) - SPIndicator (~> 1.6) - - ChartWebview (3.0.104): + - ChartWebview (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -145,7 +145,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.104): + - CloudFs (3.0.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -166,7 +166,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.104): + - CloudKitModule (3.0.105): - 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.105): - 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.105): - 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.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -525,7 +525,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.104): + - OneKeyImage (3.0.105): - 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.105) - Yoga - - OneKeyTextInput (3.0.104): + - OneKeyTextInput (3.0.105): - React-Core - OpenSSL-Universal (3.6.2000) - - Pbkdf2 (3.0.104): + - Pbkdf2 (3.0.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -576,7 +576,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.104): + - PerpDepthBar (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -599,7 +599,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.104): + - Ping (3.0.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2116,6 +2116,30 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-native-list (3.0.105): + - hermes-engine + - NitroModules + - OneKeyImage (= 3.0.105) + - 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.105): - 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.105): - 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.105) - 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.105): - 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.105): - hermes-engine - NitroModules - RCTRequired @@ -2788,7 +2812,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.104): + - ReactNativeBundleCrypto (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -2812,7 +2836,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.104): + - ReactNativeBundleUpdate (3.0.105): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2861,7 +2885,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.104): + - ReactNativeCheckBiometricAuthChanged (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -2886,7 +2910,7 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.104): + - ReactNativeDeviceUtils (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -2931,7 +2955,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeGetRandomValues (3.0.104): + - ReactNativeGetRandomValues (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -2955,7 +2979,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.104): + - ReactNativeLiteCard (3.0.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2977,7 +3001,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.104): + - ReactNativeNativeLogger (3.0.105): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -3001,7 +3025,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeNetworkThrottle (3.0.104): + - ReactNativeNetworkThrottle (3.0.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3024,7 +3048,7 @@ PODS: - Yoga - ReactNativePasskeys (0.3.3): - ExpoModulesCore - - ReactNativePerfMemory (3.0.104): + - ReactNativePerfMemory (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -3048,7 +3072,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.104): + - ReactNativePerfStats (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -3072,7 +3096,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.104): + - ReactNativeRangeDownloader (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -3097,7 +3121,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.104): + - ReactNativeSplashScreen (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -3143,7 +3167,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeZipArchive (3.0.104): + - ReactNativeZipArchive (3.0.105): - hermes-engine - NitroModules - RCTRequired @@ -3626,7 +3650,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.104): + - ScrollGuard (3.0.105): - 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.105): - 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.105): - hermes-engine - NitroModules - RCTRequired @@ -3706,7 +3730,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.104): + - SniConnect (3.0.105): - 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.105): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3754,7 +3778,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.104): + - TcpSocket (3.0.105): - 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: 96f93cf21b9936c545a596390ea62c14260cbe86 AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - AsyncStorage: eeae8c45c36c8a0d187dcafed1e1009613449b23 - AutoSizeInput: 52d873e1fe4f3dc0820c74d6c751c71760da5790 - BackgroundThread: d480c62e977e7f8f74eeb0e8bc8e78f6f3e2a073 + AsyncStorage: bfcce781d84cd2ab7759a86c9ef43fa5a62e479b + AutoSizeInput: ea141bb75f04cca90b633d728fddbe08498d21ca + BackgroundThread: fd3db8963f4f5b28b44b88c33f3c116d0b0270c0 BleUtils: 00f63c6bf8115301f8f47774b6e978f7174b4bc1 Burnt: e3a3397e26172fca31a59bb27421475a58068836 - ChartWebview: 31909ab00816555a318386cdaf74dc2c5e3ec0ac - CloudFs: 784a094064b4d2ec7c6261008dd5fd82dca3d88d - CloudKitModule: d974bdaeea42ef365d07d04e0178df2e40159813 + ChartWebview: c2bfef7a1f4ba735fe2ffb7069767e3046c4f5bc + CloudFs: ec232bbfb3c3ac3cb73a6fb6ab5a94a56661c4c0 + CloudKitModule: 3599939a447731242a6e48e4cdd010b4afef52b0 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 63d2dd661ac43f0772979cf2e530c6dc2e7cc9b1 + DnsLookup: 2babc8764c4b35dc02a83d4538eeb0c6bb9d6ab3 EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f EXApplication: bbd517d50878ca1d121fb3843392beb210181e28 EXConstants: e283cc77f61bddf6537e8ec1d351985e2394168a @@ -4461,22 +4488,22 @@ SPEC CHECKSUMS: JCoreRN: d985de509185b381c177fde4bb6bfc686089fdbd JPushRN: 807bc962b2f25860e5c0caa5fcb7b4910572b890 JuiceboxSdk: b2222491ce92b263694c217ea202a54b66c5ec5f - KeychainModule: 6d0801e640d695d75918c33943099056afe1a480 + KeychainModule: 9aab964af35ed134ed1d0270413420ed37d1ca08 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3 lottie-react-native: 3234694e4dbd43853060e5eba15a35c323ed29ee MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa MultiplatformBleAdapter: b1fddd0d499b96b607e00f0faa8e60648343dc1d - NetworkInfo: 8643d019ef5bb96459329b86dc676017241db731 + NetworkInfo: 3a615c690b29bdb27f149d1d4219a260dc517c35 NitroMmkv: 437a1303946283cfefe556d74e989228874aa8c0 NitroModules: d5be9f4559fc5178388ccf3ba2e152230b766d67 - OneKeyImage: a6f11358c72fb21630afb7cc1cd474badc4f271c - OneKeyTextInput: 6b4048aabb8b048b4c095c2a532cf02e4435934f + OneKeyImage: 370fde6b94eb0f9889db93a9973bd96479e05052 + OneKeyTextInput: 2a260c9713ff205d80a3f644f917e81da479f258 OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e - Pbkdf2: d25c5f5eec87b5a7c5a20485e75fc813ea966ab2 - PerpDepthBar: c05bd8fde3da58d921dd2de78b9ec6b0a96fc0b6 - Ping: ada7d70a52ac4339a16014ce8950c032f38fded0 + Pbkdf2: f733bdc7b1ea667d48ddb816e5793884d2fdeb5a + PerpDepthBar: cba3101c3424abfdc7ce423d29751212d51fb1a3 + Ping: 6b78c45c9af12ed40dfc7512b3758c0b7bce9c47 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: 8122bf3096a81da3ab32ce910e43b1f082d95090 react-native-netinfo: ac0848a4773ef5bd015399444409b5ea9ed75fa3 - react-native-pager-view: 107ec5569921f52775625d2a4d95089193340ea1 + react-native-pager-view: 24a99af5bf689345b48ab96321140cc530dd1518 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: 0b585b213696b54da697b23b9fcfa828065115dd 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: 08c35b67c23d95f163ac40bdfd3016f99f1c439f + ReactNativeBundleCrypto: 1dacbe21497cf04eb03770874421878aba5254cf + ReactNativeBundleUpdate: 05daf070c6b1463c7147c79aa5b522d4b12b3eaf ReactNativeCameraKit: 21f6c85397cfcb494dfa453a6fb4c9933a7c1bef - ReactNativeCheckBiometricAuthChanged: c269b6b630d29b2aa802a0035ecb491cd46da735 + ReactNativeCheckBiometricAuthChanged: cc4a8d33c31ad17dc6e9634f8bbe94e6e131a3e8 ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: 0b534b3145bb4aade8ac5d8ad3f1014e9a547302 + ReactNativeDeviceUtils: 3d0f37636218246dbcf89f77964736bbff9837e7 ReactNativeFs: 4704ddcd290a4f26195f7236c7237611360df74b - ReactNativeGetRandomValues: 1833eb939a72bb10fb2e870d3441eb4cb1a79e3a - ReactNativeLiteCard: d4617859c001a62d1337d26f535d7cca3d3023b4 - ReactNativeNativeLogger: 5e772b28e5d3314af3aec163044ef50b2cb3d144 - ReactNativeNetworkThrottle: 6e9465bafcd37657fa1f516e01200b6b5e74e366 + ReactNativeGetRandomValues: 4c152576be63fa984767282431534253272c0f05 + ReactNativeLiteCard: 47a21163d8b38a09e6dacc7c32949439872ac58f + ReactNativeNativeLogger: c4f763f64985fec01cdbdbc30533964a1044e538 + ReactNativeNetworkThrottle: 9f4166dd273eee7ea5dbbd28d4bc07bdfc98e58e ReactNativePasskeys: 9e950e8cbf0e7d6aad9df4dcd21cee0efeb4e5cd - ReactNativePerfMemory: 27619522e70bdef4dbfe3f50935ccc13144b2cc2 - ReactNativePerfStats: f48b47fde27330c27ac8a72beb4478a9e9b8fa93 - ReactNativeRangeDownloader: c65289d3623901429a2bc6437f33d0e423859c39 - ReactNativeSplashScreen: 5a19f247822f5a5ef32879770abedd2cce913c9c + ReactNativePerfMemory: 5589376aad5526ca4d333b958b4788d45154cf78 + ReactNativePerfStats: d2ff1603689916404510523dacc8d8976719e806 + ReactNativeRangeDownloader: 871ee72e73c87f2234d467877ad16e93b91acfa3 + ReactNativeSplashScreen: 6ef71692a8821299654d59efc23c8c7a6ad642a1 ReactNativeVideo: 36938964abd84cb1355c5844d89feb501f3fbf93 - ReactNativeZipArchive: 1746dd03ef62feaad4d8cb5a414e11a2f48ad884 + ReactNativeZipArchive: 329dc5d5a9a9d79faef5e716f873b7d220ca7f7a RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1 RevenueCat: 72d1e14966339bf38c41d9b2841ef64c8fd79646 RNCMaskedView: a543b7a36c195a519d1bd7ef0291f1cbc6a84a0d @@ -4600,19 +4628,19 @@ SPEC CHECKSUMS: RNSentry: f9219292e372d83cccb99b7f5fa7a9d06c204adf RNSVG: 26c9fd280121dbb1639fae26d476a54c8016ed81 RNWorklets: 4405c9ce44ccd5af4e389229bbdc5e0314f2eee1 - ScrollGuard: 6648e905b1cc3fc7d499c3f53b411c8c3c0b8e09 + ScrollGuard: ad990e799199769320bef2a388702c091e0fd509 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 718e5c7fde0e150af5695e176e99172a61e85185 + SegmentSlider: 12eadfe0873789868c3616af532d0ca0a2e942ad Sentry: 7475eb7bf6a41d7505f46341706015ad2d1766b9 - Skeleton: ca5afb6ba8c19ac02a453b462ffead84c26eabd0 - SniConnect: 0bcec9cb796f06d036d3681af14c96dffe3397b9 + Skeleton: fcaa7565b56eb8448f2f5f9d21ff4714219421d2 + SniConnect: 54c48aa876fc810899704f0b5a0fc9fc0bcf3e45 SPAlert: 735da1f16a887e294719217572ce1f936d8c8782 SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f - SplitBundleLoader: c35ea818165997c0f77735910a5faba6206d4215 + SplitBundleLoader: dd00e84d9ba93268331bbad26472920b99163ae2 SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: e549c8c3aa925666282c9b3f011beb5c73d24441 + TcpSocket: 324f84fd12979f3e6bb77a4c09c8b9d3edcc19e4 TOCropViewController: 5fa42dd0ac8c32790c06fc6057831d17b3b16857 Yoga: 0b38f02674a32b9a15de1f41f8680ffa06eaf8ef ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7dda79090621..7e1700168e83 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.105", + "@onekeyfe/react-native-auto-size-input": "3.0.105", + "@onekeyfe/react-native-background-thread": "3.0.105", "@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.105", + "@onekeyfe/react-native-bundle-update": "3.0.105", + "@onekeyfe/react-native-chart-webview": "3.0.105", + "@onekeyfe/react-native-check-biometric-auth-changed": "3.0.105", + "@onekeyfe/react-native-cloud-kit-module": "3.0.105", + "@onekeyfe/react-native-device-utils": "3.0.105", + "@onekeyfe/react-native-image": "3.0.105", + "@onekeyfe/react-native-keychain-module": "3.0.105", + "@onekeyfe/react-native-lite-card": "3.0.105", + "@onekeyfe/react-native-native-list": "3.0.105", + "@onekeyfe/react-native-native-logger": "3.0.105", + "@onekeyfe/react-native-network-throttle": "3.0.105", + "@onekeyfe/react-native-perf-memory": "3.0.105", + "@onekeyfe/react-native-perf-stats": "3.0.105", + "@onekeyfe/react-native-perp-depth-bar": "3.0.105", + "@onekeyfe/react-native-range-downloader": "3.0.105", + "@onekeyfe/react-native-scroll-guard": "3.0.105", + "@onekeyfe/react-native-segment-slider": "3.0.105", + "@onekeyfe/react-native-skeleton": "3.0.105", + "@onekeyfe/react-native-sni-connect": "3.0.105", + "@onekeyfe/react-native-splash-screen": "3.0.105", + "@onekeyfe/react-native-split-bundle-loader": "3.0.105", + "@onekeyfe/react-native-tab-view": "3.0.105", + "@onekeyfe/react-native-text-input": "3.0.105", "@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.105", "@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.105", "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.105", "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.105", + "react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.105", "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.105", "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.105", "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.105", "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.105", "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.105", "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.105", "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..03addfdbb020 --- /dev/null +++ b/docs/native-list-selector-performance.md @@ -0,0 +1,119 @@ +# 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 URI avatar/cache change passes the listed pixel and persistence checks. Steady scrolling is approximately 60 JS rAF FPS. **Cold opening and first-time avatar loading during extreme scrolling have not passed an all-scenarios full-frame requirement.** + +## 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, with at most 128 idle URLs retained; mounted image leases are protected from eviction. +- 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. + +## 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. + +Open acceptance items are cold-page initialization long tasks, first-visit avatar waiting at extreme scroll speeds, actual extension runtime coverage, and Release-device/high-refresh-rate measurement. diff --git a/package.json b/package.json index a3300609340f..7ba4f219fc6b 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.105", "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.105", "@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..7f621e5f4417 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.105", + "@onekeyfe/react-native-segment-slider": "3.0.105", + "@onekeyfe/react-native-tab-view": "3.0.105", "@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/kit/package.json b/packages/kit/package.json index 38d322d8af53..5e085ef71284 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.105", "@onekeyhq/components": "*", "@types/url-parse": "^1.4.8", "date-fns": "2.30.0", 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..f72e8c289905 --- /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..c2b62f0dff9b --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx @@ -0,0 +1,813 @@ +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, + buildAccountSelectorRowPatchesV2, + useAccountSelectorAccountRowsV2, +} from './accountSelectorAccountRowsV2'; +import { + AccountSelectorCreateAddressActionV2, + AccountSelectorMenuActionV2, +} from './AccountSelectorActionV2'; +import { EmptyView } from './EmptyView'; +import { useAddAccount } from './hooks/useAddAccount'; +import { useAccountSelectorValuesLoader } from './useAccountSelectorValuesLoader'; +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 + useAccountSelectorValuesLoader({ + 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, + ]); + // Keep the native snapshot prop stable while balance batches update row fields. + const [nativeSnapshot, setNativeSnapshot] = useState(snapshot); + const appliedSnapshotRef = useRef< + { base: NativeListSnapshot; latest: NativeListSnapshot } | undefined + >(undefined); + if ( + buildAccountSelectorRowPatchesV2(nativeSnapshot, snapshot) === undefined + ) { + setNativeSnapshot(snapshot); + } + useEffect(() => { + const list = listRef.current; + if (!list) { + appliedSnapshotRef.current = undefined; + return; + } + const previous = + appliedSnapshotRef.current?.base === nativeSnapshot + ? appliedSnapshotRef.current.latest + : nativeSnapshot; + const patches = buildAccountSelectorRowPatchesV2(previous, snapshot); + if (patches?.length) list.applyPatches(patches); + appliedSnapshotRef.current = { base: nativeSnapshot, latest: snapshot }; + }, [nativeSnapshot, snapshot, 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.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts new file mode 100644 index 000000000000..416f8364165a --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts @@ -0,0 +1,437 @@ +import { useMemo } 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 { + IAccountSelectorDeFiItem, + IAccountSelectorValueItem, +} 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 { formatAccountSelectorValueV2 } from './accountSelectorValueV2'; + +import type { + IdentityRow, + 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, + ], + ); + // Each cache belongs to the formatting context; weak keys retain only live rows. + const getValueRow = useMemo(() => { + const cache = new WeakMap< + IdentityRow, + { + accountValue?: IAccountSelectorValueItem; + overview?: IAccountSelectorDeFiItem; + row: IdentityRow; + } + >(); + return ( + row: IdentityRow, + record: IAccountSelectorRowRecordV2, + accountValue?: IAccountSelectorValueItem, + overview?: IAccountSelectorDeFiItem, + ): IdentityRow => { + if ( + platformEnv.isWebDappMode || + platformEnv.isE2E || + record.shouldShowCreateAddressButton + ) { + return row; + } + const cached = cache.get(row); + if ( + cached && + isEqual(cached.accountValue, accountValue) && + isEqual(cached.overview, overview) + ) { + return cached.row; + } + const value = formatAccountSelectorValueV2({ + accountValue, + activeAccountValue, + overview, + walletId: wallet?.id ?? '', + linkedAccountId: + record.indexedAccount?.associateAccount?.id ?? record.item.id, + linkedNetworkId: record.avatarNetworkId ?? network?.id, + mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + currencyMap, + targetCurrency: currencyInfo.id, + hideValue: !!settingsValue.hideValue, + }); + const subtitleSegments = [value, ...(row.subtitleSegments ?? [])]; + const valueRow: IdentityRow = { + ...row, + subtitleSegments, + accessibilityLabel: [ + row.title, + ...subtitleSegments.map((segment) => segment.text), + ].join(', '), + }; + cache.set(row, { accountValue, overview, row: valueRow }); + return valueRow; + }; + }, [ + activeAccountValue, + wallet?.id, + network?.id, + mergeDeriveAssetsEnabled, + enabledNetworksCompatibleWithWalletId, + networkInfoMap, + currencyMap, + currencyInfo.id, + settingsValue.hideValue, + ]); + // Balance batches must not rebuild avatars or other static row presentation. + const rows = useMemo( + () => + staticRows.map((row, index) => + getValueRow( + row, + records[index], + valuesMap[num]?.[row.key], + deFiMap[num]?.[row.key], + ), + ), + [staticRows, records, getValueRow, valuesMap, num, deFiMap], + ); + return { records, rows }; +} + +const accountRowPatchFieldsV2 = new Set([ + 'title', + 'leading', + 'selected', + 'subtitleSegments', + 'pressDisabled', + 'trailing', + 'accessibilityLabel', +]); + +export function buildAccountSelectorRowPatchesV2( + previous: NativeListSnapshot, + next: NativeListSnapshot, +): RowPatch[] | undefined { + const { rows: previousRows, ...previousMetadata } = previous; + const { rows: nextRows, ...nextMetadata } = next; + if ( + previousRows.length !== nextRows.length || + !isEqual(previousMetadata, nextMetadata) + ) { + return undefined; + } + const patches: RowPatch[] = []; + for (let index = 0; index < nextRows.length; index += 1) { + const row = nextRows[index]; + const previousRow = previousRows[index]; + 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 if (row !== previousRow) { + 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; +} 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..2e3b9daf4adf --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts @@ -0,0 +1,121 @@ +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('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..b96b2d81dabd --- /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/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.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts new file mode 100644 index 000000000000..7df3add307dd --- /dev/null +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts @@ -0,0 +1,260 @@ +import { useMemo } from 'react'; + +import { EFirmwareType } from '@onekeyfe/hd-shared'; +import { Image } 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 { 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; + return Image.resolveAssetSource(source)?.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..24e47a4c7e0e 100644 --- a/packages/kit/src/views/AccountManagerStacks/router/index.tsx +++ b/packages/kit/src/views/AccountManagerStacks/router/index.tsx @@ -4,7 +4,7 @@ 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'), ); 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..7ecd5a145709 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx @@ -0,0 +1,183 @@ +/** + * @jest-environment jsdom + */ +import type { ComponentProps, ReactNode } from 'react'; +import { 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')]; +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: () => ({ + formatMessage: ({ id }: { id: string }) => id, + }), +})); +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: () => ({ + enabledNetworksWithoutAccount: [{ networkId: 'a' }], + run: mockRun, + }), +})); +jest.mock('../../hooks/usePureChainSelectorSections', () => ({ + usePureChainSelectorSections: () => ({ + sections: [{ data: mockSearch ? [mockNetworks[1]] : mockNetworks }], + }), +})); +jest.mock('./useNetworkListPresentationV2', () => ({ + getNetworkValueV2: () => '0', + getNetworkTitleMatchV2: () => undefined, + useNetworkListPresentationV2: () => ({ + nativeTheme: { rowBackground: '#ffffff' }, + formatCurrencyValue: (value: string) => ({ text: value }), + getNetworkLeading: () => ({ kind: 'network', fallbackText: 'N' }), + }), +})); +jest.mock('./useNetworkTooltipV2', () => ({ + useNetworkTooltipV2: () => ({}), +})); + +type IContextValueV2 = ComponentProps< + typeof AllNetworksManagerContext.Provider +>['value']; + +function HarnessV2() { + const [state, setState] = useState(mockState); + mockState = state; + const value = useMemo( + () => ({ + walletId: 'wallet', + accountId: undefined, + indexedAccountId: undefined, + networks: { mainNetworks: mockNetworks, frequentlyUsedNetworks: [] }, + networksState: state, + setNetworksState: setState, + enabledNetworks: mockNetworks.filter( + (network) => state.enabledNetworks[network.id], + ), + searchKey: mockSearch, + setSearchKey: jest.fn(), + isCreatingEnabledAddresses: false, + setIsCreatingEnabledAddresses: jest.fn(), + isCreatingMissingAddresses: false, + setIsCreatingMissingAddresses: jest.fn(), + missingAddressCount: 0, + setMissingAddressCount: mockMissingCount, + accountNetworkValues: {}, + accountDeFiOverview: {}, + }), + [state], + ); + 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 = ''; + 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 the original missing-address computation connected to the footer', () => { + render(); + expect(mockMissingCount).toHaveBeenCalledWith(1); + expect(mockRun).toHaveBeenCalled(); + expect(getNativePropsV2().snapshot.layout.stickyHeaders).toBe(false); + expect( + getNativePropsV2().snapshot.rows.filter((row) => row.type === 'identity'), + ).toHaveLength(3); + }); +}); 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..a8075ac9ebcd --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx @@ -0,0 +1,373 @@ +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, + 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, run } = + useEnabledNetworksCompatibleWithWalletIdInAllNetworks({ + walletId: walletId ?? '', + indexedAccountId, + filterNetworksWithoutAccount: true, + enabledNetworks, + }); + useEffect(() => { + setMissingAddressCount(enabledNetworksWithoutAccount.length); + }, [enabledNetworksWithoutAccount.length, setMissingAddressCount]); + const enabledNetworkIds = useMemo( + () => enabledNetworks.map((network) => network.id).join(','), + [enabledNetworks], + ); + useEffect(() => { + void run(); + }, [enabledNetworkIds, run]); + + 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], + ); + + const rows = useMemo(() => { + const result: RowModel[] = []; + if (!sections.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', + }); + } + sections.forEach((section, index) => { + const sectionKey = section.totalValue + ? 'portfolio-assets' + : `portfolio-section-${section.title ?? 'search'}`; + if (index) { + result.push({ + type: 'system', + variant: 'spacer', + key: `${sectionKey}-spacer`, + sectionKey, + height: 20, + heightRounding: 'nearest', + }); + } + if (section.title) { + if (section.totalValue) { + const formattedValue = formatCurrencyValue(section.totalValue); + 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', + }); + } + } + section.data.forEach((network) => { + 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) }); + } + trailing.push({ + kind: 'checkbox', + state: selectedIds.has(network.id) ? 'checked' : 'unchecked', + target: { scope: 'row' }, + }); + result.push({ + 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, + }); + }); + }); + return result; + }, [ + accountDeFiOverview, + accountNetworkValues, + enabledNetworks.length, + formatCurrencyValue, + getNetworkLeading, + intl, + searchKey, + sectionBackground, + sections, + 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.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx new file mode 100644 index 000000000000..089a7dca5321 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx @@ -0,0 +1,873 @@ +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 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 [originalEnabledNetworks, setOriginalEnabledNetworks] = useState< + IServerNetworkMatch[] + >([]); + const [enabledNetworks, setEnabledNetworks] = useState( + [], + ); + + 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], + ); + + // Keep networksState in sync with revalidation. The seed above handles + // first paint; this effect picks up later updates from the SWR fetch. + useEffect(() => { + if (!networkMeta) return; + setNetworksState(networkMeta.allNetworksState); + }, [networkMeta]); + + // Derive the enabled subset from networks + state. Lives after the + // `networks` useMemo to keep declaration order clean. + useEffect(() => { + const result = networks.mainNetworks.filter((network) => + isEnabledNetworksInAllNetworks({ + networkId: network.id, + enabledNetworks: networksState.enabledNetworks, + disabledNetworks: networksState.disabledNetworks, + isTestnet: network.isTestnet, + }), + ); + setEnabledNetworks(result); + if (!enabledNetworksInit.current && networks.allNetworks.length > 0) { + setOriginalEnabledNetworks(result); + enabledNetworksInit.current = true; + } + }, [networksState, networks.mainNetworks, networks.allNetworks]); + + 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 first to avoid race condition: refreshNetworkMeta + // (triggered by AddedCustomNetwork event) fetches backend state + // and overwrites local state. By persisting before the event, + // the backend already includes the enabled state. + const newEnabledNetworks = { + ...networksState.enabledNetworks, + [network.id]: true, + }; + const newDisabledNetworks = { + ...networksState.disabledNetworks, + [network.id]: false, + }; + setNetworksState({ + 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, 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..2e9c50bfd792 --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx @@ -0,0 +1,90 @@ +/** + * @jest-environment jsdom + */ +import { renderHook } from '@testing-library/react'; + +import { numberFormatAsRenderText } from '@onekeyhq/shared/src/utils/numberUtils'; + +import { useNetworkListPresentationV2 } from './useNetworkListPresentationV2'; + +const mockTheme = new Proxy({}, { get: () => ({ val: '#000000' }) }); +let mockHideValue = false; +let mockCurrency = 'usd'; +let mockCurrencyMap: Record = {}; + +jest.mock('@onekeyhq/components', () => ({ + useTheme: () => mockTheme, +})); +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(() => { + mockHideValue = false; + mockCurrency = 'usd'; + mockCurrencyMap = { + usd: { unit: '$', value: 1 }, + btc: { unit: '₿', value: 0.000_001 }, + eur: { unit: '€', value: 0.8 }, + }; + }); + + 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..9ac60b4523db --- /dev/null +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts @@ -0,0 +1,209 @@ +import { useCallback, useMemo } from 'react'; + +import BigNumber from 'bignumber.js'; + +import { 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 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: 'none', + }, + 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..10037cc7ed3f 100644 --- a/packages/kit/src/views/ChainSelector/router/index.ts +++ b/packages/kit/src/views/ChainSelector/router/index.ts @@ -27,7 +27,7 @@ const MultiNetworkSelector = LazyLoadPage( const ChainListSearch = LazyLoadPage(() => import('../pages/ChainListSearch')); const UnifiedNetworkSelector = LazyLoadPage( - () => import('../components/UnifiedNetworkSelector'), + () => import('../components/UnifiedNetworkSelectorV2'), ); export const ChainSelectorRouter: IModalFlowNavigatorConfig< diff --git a/patches/@onekeyfe+react-native-image+3.0.105.patch b/patches/@onekeyfe+react-native-image+3.0.105.patch new file mode 100644 index 000000000000..8a78a8195ceb --- /dev/null +++ b/patches/@onekeyfe+react-native-image+3.0.105.patch @@ -0,0 +1,1625 @@ +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt +new file mode 100644 +index 0000000..88f0152 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt +@@ -0,0 +1,264 @@ ++// OneKey patch: Render the versioned local avatar URI without a JS PNG payload. ++// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64 ++// Permission is hereby granted, free of charge, to any person obtaining a copy ++// of this software and associated documentation files (the "Software"), to deal ++// in the Software without restriction, including without limitation the rights ++// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ++// copies of the Software, and to permit persons to whom the Software is ++// furnished to do so, subject to the following conditions: ++// The above copyright notice and this permission notice shall be included in ++// all copies or substantial portions of the Software. ++// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ++// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ++// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ++// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ++// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ++// THE SOFTWARE. ++ ++package com.margelo.nitro.onekeyimage ++ ++import java.io.ByteArrayOutputStream ++import java.io.DataOutputStream ++import java.nio.ByteBuffer ++import java.nio.charset.CodingErrorAction ++import java.util.concurrent.CancellationException ++import java.util.concurrent.FutureTask ++import java.util.concurrent.atomic.AtomicBoolean ++import java.util.zip.CRC32 ++import java.util.zip.Deflater ++import java.util.zip.DeflaterOutputStream ++import java.util.zip.DataFormatException ++import java.util.zip.Inflater ++import kotlin.math.floor ++ ++internal data class OneKeyBlockieAvatarModel(val uri: String) ++ ++internal object OneKeyBlockieAvatar { ++ const val URI_PREFIX = "onekey-avatar://blockie/v1/" ++ const val SIZE = 128 ++ const val MAX_PNG_BYTES = SIZE * (SIZE / 4 + 1) + 128 ++ ++ fun isAvatarUri(uri: String): Boolean = uri.startsWith("onekey-avatar:") ++ ++ fun decodeSeed(uri: String, isCancelled: () -> Boolean = { false }): String { ++ require(uri.startsWith(URI_PREFIX)) { "Unsupported avatar URI version" } ++ val encoded = uri.substring(URI_PREFIX.length) ++ require(encoded.isNotEmpty()) { "Avatar seed is empty" } ++ val bytes = ByteArrayOutputStream(encoded.length) ++ var index = 0 ++ while (index < encoded.length) { ++ if (index % 256 == 0 && isCancelled()) throw CancellationException("Avatar generation cancelled") ++ val char = encoded[index] ++ if (char == '%') { ++ require(index + 2 < encoded.length) { "Invalid avatar percent encoding" } ++ val high = encoded[index + 1].digitToIntOrNull(16) ++ val low = encoded[index + 2].digitToIntOrNull(16) ++ require(high != null && low != null) { "Invalid avatar percent encoding" } ++ bytes.write((high shl 4) or low) ++ index += 3 ++ } else { ++ require(char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char in "-_.!~*'()") { ++ "Invalid avatar URI component" ++ } ++ bytes.write(char.code) ++ index += 1 ++ } ++ } ++ // The app already applies JavaScript lowercase; JVM Unicode casing differs. ++ return Charsets.UTF_8.newDecoder() ++ .onMalformedInput(CodingErrorAction.REPORT) ++ .onUnmappableCharacter(CodingErrorAction.REPORT) ++ .decode(ByteBuffer.wrap(bytes.toByteArray())) ++ .toString() ++ } ++ ++ // Only our fixed v1 encoder format is accepted, never arbitrary user PNG data. ++ fun isValidPng(bytes: ByteArray, isCancelled: () -> Boolean = { false }): Boolean { ++ if (isCancelled()) throw CancellationException("Avatar cache read cancelled") ++ if (bytes.size !in 93..MAX_PNG_BYTES) return false ++ val input = ByteBuffer.wrap(bytes) ++ if (input.long != 0x89504e470d0a1a0aUL.toLong()) return false ++ fun chunk(expectedType: String, expectedLength: Int? = null): ByteArray? { ++ if (input.remaining() < 12) return null ++ val length = input.int ++ if (length < 0 || length > input.remaining() - 8) return null ++ if (expectedLength != null && length != expectedLength) return null ++ val type = ByteArray(4).also(input::get) ++ if (!type.contentEquals(expectedType.toByteArray(Charsets.US_ASCII))) return null ++ val data = ByteArray(length).also(input::get) ++ val crc = CRC32().apply { update(type); update(data) } ++ if (input.int != crc.value.toInt()) return null ++ return data ++ } ++ val header = chunk("IHDR", 13) ?: return false ++ if (!header.contentEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128, 2, 3, 0, 0, 0))) return false ++ chunk("PLTE", 9) ?: return false ++ if (chunk("tRNS", 3)?.contentEquals(byteArrayOf(-1, -1, -1)) != true) return false ++ val compressed = chunk("IDAT") ?: return false ++ chunk("IEND", 0) ?: return false ++ if (input.hasRemaining()) return false ++ ++ val expectedBytes = SIZE * (SIZE / 4 + 1) ++ val pixels = ByteArray(expectedBytes + 1) ++ val inflater = Inflater() ++ var count = 0 ++ try { ++ inflater.setInput(compressed) ++ while (!inflater.finished() && count <= expectedBytes) { ++ if (isCancelled()) throw CancellationException("Avatar cache read cancelled") ++ val decoded = inflater.inflate(pixels, count, pixels.size - count) ++ if (decoded == 0) return false ++ count += decoded ++ } ++ if (!inflater.finished() || inflater.remaining != 0 || count != expectedBytes) return false ++ } catch (_: DataFormatException) { ++ return false ++ } finally { ++ inflater.end() ++ } ++ repeat(SIZE) { row -> ++ if (isCancelled()) throw CancellationException("Avatar cache read cancelled") ++ val offset = row * 33 ++ if (pixels[offset] != 0.toByte()) return false ++ for (index in 1..32) { ++ val value = pixels[offset + index].toInt() and 0xff ++ if ( ++ (value and 3) == 3 || ((value shr 2) and 3) == 3 || ++ ((value shr 4) and 3) == 3 || ((value shr 6) and 3) == 3 ++ ) return false ++ } ++ } ++ return true ++ } ++ ++ fun png(uri: String, isCancelled: () -> Boolean = { false }): ByteArray { ++ fun checkCancelled() { ++ if (isCancelled()) throw CancellationException("Avatar generation cancelled") ++ } ++ checkCancelled() ++ val seed = decodeSeed(uri, isCancelled) ++ val state = IntArray(4) ++ seed.forEachIndexed { index, char -> ++ if (index % 256 == 0) checkCancelled() ++ val slot = index % 4 ++ // Kotlin Int overflow and signed shr preserve JavaScript's bitwise PRNG. ++ state[slot] = (state[slot] shl 5) - state[slot] + char.code ++ } ++ fun random(): Double { ++ val t = state[0] xor (state[0] shl 11) ++ state[0] = state[1] ++ state[1] = state[2] ++ state[2] = state[3] ++ state[3] = state[3] xor (state[3] shr 19) xor t xor (t shr 8) ++ return (state[3].toLong() and 0xffffffffL).toDouble() / 2147483648.0 ++ } ++ fun hue(p: Double, q: Double, value: Double): Double { ++ var t = value ++ if (t < 0) t += 1 ++ if (t > 1) t -= 1 ++ return when { ++ t < 1.0 / 6.0 -> p + (q - p) * 6 * t ++ t < 1.0 / 2.0 -> q ++ t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6 ++ else -> p ++ } ++ } ++ fun color(): ByteArray { ++ val h = floor(random() * 360) / 360 ++ val saturation = (random() * 60 + 40) / 100 ++ val lightness = ((random() + random() + random() + random()) * 25) / 100 ++ val q = if (lightness < 0.5) lightness * (1 + saturation) ++ else lightness + saturation - lightness * saturation ++ val p = 2 * lightness - q ++ return doubleArrayOf( ++ hue(p, q, h + 1.0 / 3.0), hue(p, q, h), hue(p, q, h - 1.0 / 3.0), ++ ).map { floor(it * 255 + 0.5).toInt().toByte() }.toByteArray() ++ } ++ val foreground = color() ++ val background = color() ++ val spot = color() ++ val pixels = ByteArray(SIZE * 33) ++ repeat(8) { row -> ++ checkCancelled() ++ val line = ByteArray(33) ++ repeat(4) { column -> ++ val value = floor(random() * 2.3).toInt() ++ val paletteIndex = if (value == 0) 0 else if (value == 1) 1 else 2 ++ val packed = (paletteIndex * 0x55).toByte() ++ line.fill(packed, 1 + column * 4, 1 + (column + 1) * 4) ++ line.fill(packed, 1 + (7 - column) * 4, 1 + (8 - column) * 4) ++ } ++ repeat(16) { line.copyInto(pixels, (row * 16 + it) * 33) } ++ } ++ val compressed = ByteArrayOutputStream() ++ val deflater = Deflater(Deflater.BEST_SPEED) ++ try { ++ DeflaterOutputStream(compressed, deflater).use { it.write(pixels) } ++ } finally { ++ deflater.end() ++ } ++ checkCancelled() ++ val result = ByteArrayOutputStream() ++ DataOutputStream(result).use { output -> ++ output.write(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10)) ++ fun chunk(type: String, data: ByteArray) { ++ val typeBytes = type.toByteArray(Charsets.US_ASCII) ++ output.writeInt(data.size) ++ output.write(typeBytes) ++ output.write(data) ++ val crc = CRC32().apply { update(typeBytes); update(data) } ++ output.writeInt(crc.value.toInt()) ++ } ++ chunk("IHDR", byteArrayOf(0, 0, 0, 128.toByte(), 0, 0, 0, 128.toByte(), 2, 3, 0, 0, 0)) ++ chunk("PLTE", background + foreground + spot) ++ chunk("tRNS", byteArrayOf(-1, -1, -1)) ++ chunk("IDAT", compressed.toByteArray()) ++ chunk("IEND", byteArrayOf()) ++ } ++ return result.toByteArray() ++ } ++} ++ ++/** Only overlapping source fetches are retained; Glide owns all lasting caches. */ ++internal class OneKeyAvatarInFlight( ++ private val generate: (String, () -> Boolean) -> ByteArray, ++) { ++ internal class Work(uri: String, generate: (String, () -> Boolean) -> ByteArray) { ++ val cancelled = AtomicBoolean(false) ++ val task = FutureTask { generate(uri, cancelled::get) } ++ var references = 0 ++ } ++ ++ private val pending = mutableMapOf() ++ ++ internal inner class Lease(private val uri: String, internal val work: Work) { ++ private val released = AtomicBoolean(false) ++ ++ fun bytes(): ByteArray { ++ work.task.run() ++ return work.task.get() ++ } ++ ++ fun release() { ++ if (!released.compareAndSet(false, true)) return ++ synchronized(pending) { ++ work.references -= 1 ++ if (work.references == 0) { ++ if (pending[uri] === work) pending.remove(uri) ++ if (!work.task.isDone) { ++ work.cancelled.set(true) ++ work.task.cancel(false) ++ } ++ } ++ } ++ } ++ } ++ ++ fun acquire(uri: String): Lease = synchronized(pending) { ++ val work = pending.getOrPut(uri) { Work(uri, generate) } ++ work.references += 1 ++ Lease(uri, work) ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt +new file mode 100644 +index 0000000..9c8e11f +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt +@@ -0,0 +1,173 @@ ++package com.margelo.nitro.onekeyimage ++ ++import com.bumptech.glide.Priority ++import com.bumptech.glide.load.DataSource ++import com.bumptech.glide.load.Option ++import com.bumptech.glide.load.Options ++import com.bumptech.glide.load.data.DataFetcher ++import com.bumptech.glide.load.engine.DiskCacheStrategy ++import com.bumptech.glide.load.model.ModelLoader ++import com.bumptech.glide.load.model.ModelLoaderFactory ++import com.bumptech.glide.load.model.MultiModelLoaderFactory ++import com.bumptech.glide.signature.ObjectKey ++import com.bumptech.glide.request.RequestOptions ++import java.io.ByteArrayInputStream ++import java.io.ByteArrayOutputStream ++import java.io.File ++import java.io.IOException ++import java.nio.ByteBuffer ++import java.util.concurrent.ExecutionException ++import java.util.concurrent.CancellationException ++ ++// Only source PNGs are persisted; transformed resources can have other dimensions. ++internal fun oneKeyImageMemoryDiskStrategy(uri: String): DiskCacheStrategy = ++ if (OneKeyBlockieAvatar.isAvatarUri(uri)) DiskCacheStrategy.DATA else DiskCacheStrategy.AUTOMATIC ++ ++internal val oneKeyAvatarCacheFileOption: Option = ++ Option.memory("onekey-image.blockie-source-cache-v1", false) ++ ++internal fun RequestOptions.withOneKeyAvatarCache(uri: String): RequestOptions = ++ if (OneKeyBlockieAvatar.isAvatarUri(uri)) set(oneKeyAvatarCacheFileOption, true) else this ++ ++internal class OneKeyAvatarCacheFileLoaderFactory : ModelLoaderFactory { ++ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = ++ OneKeyAvatarCacheFileLoader() ++ ++ override fun teardown() = Unit ++} ++ ++private class OneKeyAvatarCacheFileLoader : ModelLoader { ++ override fun handles(model: File): Boolean = true ++ ++ override fun buildLoadData( ++ model: File, ++ width: Int, ++ height: Int, ++ options: Options, ++ ): ModelLoader.LoadData? = ++ if (options.get(oneKeyAvatarCacheFileOption) == true) { ++ ModelLoader.LoadData(ObjectKey(model), OneKeyAvatarCacheFileFetcher(model)) ++ } else null ++} ++ ++// Serialize validation/removal so two failed reads cannot delete a newly repaired file. ++private val avatarCacheFileLock = Any() ++ ++private class OneKeyAvatarCacheFileFetcher(private val file: File) : DataFetcher { ++ @Volatile ++ private var cancelled = false ++ ++ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { ++ val bytes = try { ++ synchronized(avatarCacheFileLock) { ++ checkCancelled() ++ val bytes = readBounded() ++ if (bytes == null || !OneKeyBlockieAvatar.isValidPng(bytes) { cancelled }) { ++ checkCancelled() ++ if (file.exists() && !file.delete()) throw IOException("Cannot remove invalid avatar cache entry") ++ // Glide's journal sees the missing clean file; SOURCE can write it again. ++ throw IOException("Invalid avatar cache entry removed") ++ } ++ bytes ++ } ++ } catch (error: Exception) { ++ if (!cancelled) callback.onLoadFailed(error) ++ return ++ } ++ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(bytes)) ++ } ++ ++ private fun readBounded(): ByteArray? { ++ if (file.length() > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null ++ return file.inputStream().use { input -> ++ val output = ByteArrayOutputStream() ++ val buffer = ByteArray(1024) ++ while (true) { ++ checkCancelled() ++ val count = input.read(buffer) ++ if (count < 0) break ++ if (output.size() + count > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null ++ output.write(buffer, 0, count) ++ } ++ output.toByteArray() ++ } ++ } ++ ++ private fun checkCancelled() { ++ if (cancelled) throw CancellationException("Avatar cache read cancelled") ++ } ++ ++ override fun cancel() { cancelled = true } ++ override fun cleanup() = cancel() ++ override fun getDataClass(): Class = ByteBuffer::class.java ++ override fun getDataSource(): DataSource = DataSource.LOCAL ++} ++ ++internal class OneKeyBlockieAvatarLoaderFactory : ModelLoaderFactory { ++ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = ++ OneKeyBlockieAvatarLoader() ++ ++ override fun teardown() = Unit ++} ++ ++private val avatarRequests = OneKeyAvatarInFlight { uri, isCancelled -> ++ OneKeyImageSafety.requireEncodedLength(uri.length.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES) ++ val png = OneKeyBlockieAvatar.png(uri, isCancelled) ++ OneKeyImageSafety.requireEncodedLength(png.size.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES) ++ OneKeyEncodedImageInspector.inspect(ByteArrayInputStream(png)) ++ png ++} ++ ++private class OneKeyBlockieAvatarLoader : ModelLoader { ++ override fun handles(model: OneKeyBlockieAvatarModel): Boolean = true ++ ++ override fun buildLoadData( ++ model: OneKeyBlockieAvatarModel, ++ width: Int, ++ height: Int, ++ options: Options, ++ ): ModelLoader.LoadData = ModelLoader.LoadData( ++ OneKeyImageSafetyVersionedKey(ObjectKey(model)), ++ OneKeyBlockieAvatarFetcher(model.uri), ++ ) ++} ++ ++internal class OneKeyBlockieAvatarFetcher( ++ private val uri: String, ++ private val requests: OneKeyAvatarInFlight = avatarRequests, ++) : DataFetcher { ++ @Volatile ++ private var cancelled = false ++ private var lease: OneKeyAvatarInFlight.Lease? = null ++ ++ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { ++ val request = synchronized(this) { ++ if (cancelled) return ++ requests.acquire(uri).also { lease = it } ++ } ++ // Glide invokes loadData on its source executor, never on the UI thread. ++ val png = try { ++ request.bytes() ++ } catch (error: Exception) { ++ val cause = if (error is ExecutionException) error.cause else error ++ if (!cancelled) callback.onLoadFailed(cause as? Exception ?: IOException("Avatar generation failed")) ++ return ++ } ++ // Glide may cancel while holding its EngineJob lock; do not call back under ours. ++ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(png)) ++ } ++ ++ override fun cancel() = release() ++ override fun cleanup() = release() ++ ++ private fun release() { ++ val request = synchronized(this) { ++ cancelled = true ++ lease.also { lease = null } ++ } ++ request?.release() ++ } ++ ++ override fun getDataClass(): Class = ByteBuffer::class.java ++ override fun getDataSource(): DataSource = DataSource.LOCAL ++} +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +index 5b9aacc..887ea50 100644 +--- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt +@@ -485,7 +485,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) : + requestManager() + .asDrawable() + .load(OneKeyImageModel.build(requestUrl, headersJson)) +- .apply(requestOptions(policy)) ++ .apply(requestOptions(policy, requestUrl)) + .override(decodeDimensions.width, decodeDimensions.height) + .listener(object : RequestListener { + override fun onLoadFailed( +@@ -513,12 +513,13 @@ class HybridOneKeyImage(private val context: ThemedReactContext) : + .into(target) + } + +- private fun requestOptions(policy: OneKeyImageCachePolicy): RequestOptions { ++ private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions { + val options = RequestOptions() ++ .withOneKeyAvatarCache(uri) + .dontTransform() + .downsample(OneKeyImageSafeDownsampleStrategy) + return when (policy) { +- OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) ++ OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri)) + OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE) + OneKeyImageCachePolicy.DISK -> options + .diskCacheStrategy(DiskCacheStrategy.DATA) +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +index 7b30df1..0b97164 100644 +--- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt +@@ -22,11 +22,12 @@ class HybridOneKeyImageCache : HybridOneKeyImageCacheSpec() { + return@forEach + } + val baseOptions = RequestOptions() ++ .withOneKeyAvatarCache(source.uri) + .dontTransform() + .downsample(OneKeyImageSafeDownsampleStrategy) + val options = when (source.cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK) { + OneKeyImageCachePolicy.MEMORY_DISK -> baseOptions +- .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) ++ .diskCacheStrategy(oneKeyImageMemoryDiskStrategy(source.uri)) + OneKeyImageCachePolicy.MEMORY -> baseOptions + .diskCacheStrategy(DiskCacheStrategy.NONE) + OneKeyImageCachePolicy.DISK -> baseOptions +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt +index eb76d33..f35264c 100644 +--- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt +@@ -28,6 +28,7 @@ import com.github.penfeizhou.animation.glide.StreamAnimationDecoder + import java.io.ByteArrayOutputStream + import java.io.ByteArrayInputStream + import java.io.IOException ++import java.io.File + import java.io.InputStream + import java.nio.ByteBuffer + +@@ -50,6 +51,16 @@ internal object OneKeyImageGlideRegistry { + val glide = Glide.get(appContext) + val registry = glide.registry + ++ registry.prepend( ++ File::class.java, ++ ByteBuffer::class.java, ++ OneKeyAvatarCacheFileLoaderFactory(), ++ ) ++ registry.prepend( ++ OneKeyBlockieAvatarModel::class.java, ++ ByteBuffer::class.java, ++ OneKeyBlockieAvatarLoaderFactory(), ++ ) + registry.prepend( + OneKeyImageDataUriModel::class.java, + ByteBuffer::class.java, +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt +index 026985d..3969761 100644 +--- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt +@@ -25,6 +25,7 @@ internal object OneKeyImageModel { + } + + fun build(uri: String, headersJson: String?): Any { ++ if (OneKeyBlockieAvatar.isAvatarUri(uri)) return OneKeyBlockieAvatarModel(uri) + if (uri.startsWith("data:")) return OneKeyImageDataUriModel(uri) + if (!uri.startsWith("http://") && !uri.startsWith("https://")) { + return OneKeyImageLocalModel(Uri.parse(uri)) +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +index cbad4c8..3120623 100644 +--- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt ++++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt +@@ -28,7 +28,12 @@ class OneKeyImageReusableView(context: ThemedReactContext) : FrameLayout(context + optimizeTos: Boolean, + overscan: Double, + loadingStrategy: String, ++ onLoad: (() -> Unit)? = null, ++ onError: (() -> Unit)? = null, + ) { ++ // OneKey patch: Let reusable cells display their own success and fallback visuals. ++ image.onLoad = { _, _, _ -> onLoad?.invoke() } ++ image.onError = { _ -> onError?.invoke() } + image.sourceHeadersJson = sourceHeadersJson + image.variant = when (variant) { + "token" -> OneKeyImageVariant.TOKEN +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt +new file mode 100644 +index 0000000..64d5c04 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt +@@ -0,0 +1,327 @@ ++package com.margelo.nitro.onekeyimage ++ ++import androidx.core.util.Pools ++import com.bumptech.glide.Priority ++import com.bumptech.glide.disklrucache.DiskLruCache ++import com.bumptech.glide.load.DataSource ++import com.bumptech.glide.load.EncodeStrategy ++import com.bumptech.glide.load.Options ++import com.bumptech.glide.load.data.DataFetcher ++import com.bumptech.glide.load.engine.DiskCacheStrategy ++import com.bumptech.glide.load.model.MultiModelLoaderFactory ++import org.junit.Assert.assertArrayEquals ++import org.junit.Assert.assertEquals ++import org.junit.Assert.assertFalse ++import org.junit.Assert.assertNotEquals ++import org.junit.Assert.assertNull ++import org.junit.Assert.assertTrue ++import org.junit.Test ++import java.io.IOException ++import java.io.File ++import java.nio.file.Files ++import java.nio.ByteBuffer ++import java.security.MessageDigest ++import java.util.concurrent.CountDownLatch ++import java.util.concurrent.Executors ++import java.util.concurrent.TimeUnit ++import java.util.concurrent.atomic.AtomicInteger ++ ++class OneKeyBlockieAvatarLoaderTest { ++ private val uri = "onekey-avatar://blockie/v1/0x1234" ++ ++ private class Callback : DataFetcher.DataCallback { ++ var data: ByteBuffer? = null ++ var error: Exception? = null ++ override fun onDataReady(data: ByteBuffer?) { this.data = data } ++ override fun onLoadFailed(error: Exception) { this.error = error } ++ } ++ ++ @Test ++ fun memoryDiskAvatarRequestsCacheTheOriginalLocalPngAcrossSizes() { ++ val strategy = oneKeyImageMemoryDiskStrategy(uri) ++ assertTrue(strategy.isDataCacheable(DataSource.LOCAL)) ++ assertTrue(strategy.decodeCachedData()) ++ assertFalse(strategy.decodeCachedResource()) ++ assertFalse(strategy.isResourceCacheable(false, DataSource.LOCAL, EncodeStrategy.TRANSFORMED)) ++ assertFalse(strategy.isDataCacheable(DataSource.DATA_DISK_CACHE)) ++ assertFalse(DiskCacheStrategy.ALL.isDataCacheable(DataSource.LOCAL)) ++ assertEquals(DiskCacheStrategy.AUTOMATIC, oneKeyImageMemoryDiskStrategy("https://example.com/image.png")) ++ assertEquals(DiskCacheStrategy.AUTOMATIC, oneKeyImageMemoryDiskStrategy("data:image/png;base64,AA==")) ++ assertFalse(DiskCacheStrategy.AUTOMATIC.isDataCacheable(DataSource.LOCAL)) ++ } ++ ++ @Test ++ fun originalPngCacheKeyIsStableAcrossRequestedDecodeDimensions() { ++ val factory = MultiModelLoaderFactory(Pools.SynchronizedPool>(1)) ++ val loader = OneKeyBlockieAvatarLoaderFactory().build(factory) ++ val first = loader.buildLoadData(OneKeyBlockieAvatarModel(uri), 96, 96, Options())!! ++ val second = loader.buildLoadData(OneKeyBlockieAvatarModel(uri), 128, 128, Options())!! ++ val other = loader.buildLoadData(OneKeyBlockieAvatarModel(uri + "0"), 96, 96, Options())!! ++ assertEquals(first.sourceKey, second.sourceKey) ++ assertNotEquals(first.sourceKey, other.sourceKey) ++ val firstDigest = MessageDigest.getInstance("SHA-256").also(first.sourceKey::updateDiskCacheKey).digest() ++ val secondDigest = MessageDigest.getInstance("SHA-256").also(second.sourceKey::updateDiskCacheKey).digest() ++ assertArrayEquals(firstDigest, secondDigest) ++ } ++ ++ private fun cacheFetcher(file: File): DataFetcher { ++ val factory = MultiModelLoaderFactory(Pools.SynchronizedPool>(1)) ++ val loader = OneKeyAvatarCacheFileLoaderFactory().build(factory) ++ return loader.buildLoadData(file, 96, 96, Options().set(oneKeyAvatarCacheFileOption, true))!!.fetcher ++ } ++ ++ @Test ++ fun cacheValidationIsNotRegisteredForOrdinaryImageRequests() { ++ val factory = MultiModelLoaderFactory(Pools.SynchronizedPool>(1)) ++ val loader = OneKeyAvatarCacheFileLoaderFactory().build(factory) ++ assertNull(loader.buildLoadData(File("ordinary-network-image"), 96, 96, Options())) ++ } ++ ++ @Test ++ fun validCachedSourceIsReturnedWithoutRemovingOrRewritingIt() { ++ val directory = Files.createTempDirectory("avatar-cache-valid").toFile() ++ val file = File(directory, "avatar.0") ++ val bytes = OneKeyBlockieAvatar.png(uri) ++ try { ++ file.writeBytes(bytes) ++ val modified = file.lastModified() ++ val fetcher = cacheFetcher(file) ++ val callback = Callback() ++ fetcher.loadData(Priority.NORMAL, callback) ++ fetcher.cleanup() ++ assertNull(callback.error) ++ assertArrayEquals(bytes, callback.data!!.array()) ++ assertEquals(modified, file.lastModified()) ++ assertArrayEquals(bytes, file.readBytes()) ++ } finally { ++ directory.deleteRecursively() ++ } ++ } ++ ++ @Test ++ fun corruptEntryRemovalAllowsRealGlideJournalRewriteAndRestartReuse() { ++ val directory = Files.createTempDirectory("avatar-cache-journal").toFile() ++ val bytes = OneKeyBlockieAvatar.png(uri) ++ try { ++ DiskLruCache.open(directory, 1, 1, 1024L * 1024).use { disk -> ++ disk.edit("avatar").also { editor -> ++ editor.getFile(0).writeBytes(bytes.copyOf(bytes.size - 1)) ++ editor.commit() ++ } ++ val file = disk.get("avatar")!!.getFile(0) ++ val fetcher = cacheFetcher(file) ++ val callback = Callback() ++ fetcher.loadData(Priority.NORMAL, callback) ++ fetcher.cleanup() ++ assertTrue(callback.error is IOException) ++ assertNull(callback.data) ++ assertFalse(file.exists()) ++ // DiskLruCacheWrapper.put uses this same journal lookup before deciding to skip a write. ++ assertNull(disk.get("avatar")) ++ disk.edit("avatar").also { editor -> ++ editor.getFile(0).writeBytes(bytes) ++ editor.commit() ++ } ++ assertArrayEquals(bytes, disk.get("avatar")!!.getFile(0).readBytes()) ++ } ++ DiskLruCache.open(directory, 1, 1, 1024L * 1024).use { reopened -> ++ val fetcher = cacheFetcher(reopened.get("avatar")!!.getFile(0)) ++ val callback = Callback() ++ fetcher.loadData(Priority.NORMAL, callback) ++ fetcher.cleanup() ++ assertNull(callback.error) ++ assertArrayEquals(bytes, callback.data!!.array()) ++ } ++ } finally { ++ directory.deleteRecursively() ++ } ++ } ++ ++ @Test ++ fun oversizedCacheIsRejectedAndRemovedBeforeUnboundedRead() { ++ val directory = Files.createTempDirectory("avatar-cache-large").toFile() ++ val file = File(directory, "avatar.0") ++ try { ++ file.writeBytes(ByteArray(OneKeyBlockieAvatar.MAX_PNG_BYTES + 1)) ++ val fetcher = cacheFetcher(file) ++ val callback = Callback() ++ fetcher.loadData(Priority.NORMAL, callback) ++ fetcher.cleanup() ++ assertTrue(callback.error is IOException) ++ assertFalse(file.exists()) ++ } finally { ++ directory.deleteRecursively() ++ } ++ } ++ ++ @Test ++ fun cancelledCacheReadDoesNotDeleteTheFileOrCallBack() { ++ val directory = Files.createTempDirectory("avatar-cache-cancel").toFile() ++ val file = File(directory, "avatar.0") ++ try { ++ file.writeBytes(byteArrayOf(1, 2, 3)) ++ val fetcher = cacheFetcher(file) ++ val callback = Callback() ++ fetcher.cancel() ++ fetcher.loadData(Priority.NORMAL, callback) ++ fetcher.cleanup() ++ assertNull(callback.data) ++ assertNull(callback.error) ++ assertTrue(file.exists()) ++ } finally { ++ directory.deleteRecursively() ++ } ++ } ++ ++ @Test ++ fun modelDispatchDoesNotDecodeTheUriOrParseHeadersOnMain() { ++ val model = OneKeyImageModel.build("onekey-avatar://blockie/v1/%INVALID", "invalid JSON") ++ assertEquals(OneKeyBlockieAvatarModel("onekey-avatar://blockie/v1/%INVALID"), model) ++ } ++ ++ @Test ++ fun defaultFetcherGeneratesAValidatedPngAndReportsLocalData() { ++ val fetcher = OneKeyBlockieAvatarFetcher(uri) ++ val callback = Callback() ++ try { ++ fetcher.loadData(Priority.NORMAL, callback) ++ assertNull(callback.error) ++ assertEquals(DataSource.LOCAL, fetcher.dataSource) ++ assertArrayEquals(OneKeyBlockieAvatar.png(uri), callback.data!!.array()) ++ } finally { ++ fetcher.cleanup() ++ } ++ } ++ ++ @Test ++ fun malformedUriReportsFailureWithoutReturningImageData() { ++ val fetcher = OneKeyBlockieAvatarFetcher("onekey-avatar://blockie/v2/seed") ++ val callback = Callback() ++ try { ++ fetcher.loadData(Priority.NORMAL, callback) ++ assertNull(callback.data) ++ assertTrue(callback.error is IllegalArgumentException) ++ } finally { ++ fetcher.cleanup() ++ } ++ } ++ ++ @Test ++ fun cancelledBeforeLoadingDoesNotGenerateOrCallBack() { ++ val generated = AtomicInteger() ++ val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> ++ generated.incrementAndGet() ++ byteArrayOf(1) ++ }) ++ val callback = Callback() ++ fetcher.cancel() ++ fetcher.loadData(Priority.NORMAL, callback) ++ fetcher.cleanup() ++ assertEquals(0, generated.get()) ++ assertNull(callback.data) ++ assertNull(callback.error) ++ } ++ ++ @Test ++ fun cancellationSuppressesLateCallbacksAndReleasesTheWorkForAFreshRequest() { ++ val started = CountDownLatch(1) ++ val complete = CountDownLatch(1) ++ val generated = AtomicInteger() ++ val requests = OneKeyAvatarInFlight { _, cancelled -> ++ if (generated.incrementAndGet() == 1) { ++ started.countDown() ++ check(complete.await(3, TimeUnit.SECONDS)) ++ assertTrue(cancelled()) ++ } ++ byteArrayOf(1) ++ } ++ val fetcher = OneKeyBlockieAvatarFetcher(uri, requests) ++ val callback = Callback() ++ val executor = Executors.newSingleThreadExecutor() ++ try { ++ val loaded = executor.submit { fetcher.loadData(Priority.NORMAL, callback) } ++ assertTrue(started.await(3, TimeUnit.SECONDS)) ++ fetcher.cancel() ++ fetcher.cleanup() ++ complete.countDown() ++ loaded.get(3, TimeUnit.SECONDS) ++ assertNull(callback.data) ++ assertNull(callback.error) ++ val fresh = OneKeyBlockieAvatarFetcher(uri, requests) ++ val next = Callback() ++ fresh.loadData(Priority.NORMAL, next) ++ fresh.cleanup() ++ assertArrayEquals(byteArrayOf(1), next.data!!.array()) ++ assertEquals(2, generated.get()) ++ } finally { ++ fetcher.cleanup() ++ complete.countDown() ++ executor.shutdownNow() ++ } ++ } ++ ++ @Test ++ fun cancellationNeverWaitsForAGlideCallbackHoldingItsOwnLock() { ++ val callbackStarted = CountDownLatch(1) ++ val callbackComplete = CountDownLatch(1) ++ val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> byteArrayOf(1) }) ++ val executor = Executors.newFixedThreadPool(2) ++ val callback = object : DataFetcher.DataCallback { ++ override fun onDataReady(data: ByteBuffer?) { ++ callbackStarted.countDown() ++ check(callbackComplete.await(3, TimeUnit.SECONDS)) ++ } ++ override fun onLoadFailed(error: Exception) { throw error } ++ } ++ try { ++ val loaded = executor.submit { fetcher.loadData(Priority.NORMAL, callback) } ++ assertTrue(callbackStarted.await(3, TimeUnit.SECONDS)) ++ executor.submit { fetcher.cancel() }.get(1, TimeUnit.SECONDS) ++ callbackComplete.countDown() ++ loaded.get(3, TimeUnit.SECONDS) ++ } finally { ++ callbackComplete.countDown() ++ fetcher.cleanup() ++ executor.shutdownNow() ++ } ++ } ++ ++ @Test ++ fun glideCallbackFailureIsNotReportedAsASecondGenerationFailure() { ++ val failures = AtomicInteger() ++ val expected = IllegalStateException("Synthetic callback failure") ++ val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> byteArrayOf(1) }) ++ val callback = object : DataFetcher.DataCallback { ++ override fun onDataReady(data: ByteBuffer?) { throw expected } ++ override fun onLoadFailed(error: Exception) { failures.incrementAndGet() } ++ } ++ try { ++ fetcher.loadData(Priority.NORMAL, callback) ++ org.junit.Assert.fail("Callback exception must propagate") ++ } catch (error: IllegalStateException) { ++ assertEquals(expected, error) ++ assertEquals(0, failures.get()) ++ } finally { ++ fetcher.cleanup() ++ } ++ } ++ ++ @Test ++ fun failedGenerationIsReleasedSoRetryCanSucceed() { ++ val generated = AtomicInteger() ++ val requests = OneKeyAvatarInFlight { _, _ -> ++ if (generated.incrementAndGet() == 1) throw IOException("Synthetic generation failure") ++ byteArrayOf(1) ++ } ++ val failed = OneKeyBlockieAvatarFetcher(uri, requests) ++ val first = Callback() ++ failed.loadData(Priority.NORMAL, first) ++ failed.cleanup() ++ assertTrue(first.error is IOException) ++ val retried = OneKeyBlockieAvatarFetcher(uri, requests) ++ val second = Callback() ++ retried.loadData(Priority.NORMAL, second) ++ retried.cleanup() ++ assertArrayEquals(byteArrayOf(1), second.data!!.array()) ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt +new file mode 100644 +index 0000000..7b81e86 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt +@@ -0,0 +1,214 @@ ++package com.margelo.nitro.onekeyimage ++ ++import org.junit.Assert.assertArrayEquals ++import org.junit.Assert.assertEquals ++import org.junit.Assert.assertFalse ++import org.junit.Assert.assertTrue ++import org.junit.Assert.fail ++import org.junit.Test ++import java.io.ByteArrayOutputStream ++import java.io.DataOutputStream ++import java.nio.ByteBuffer ++import java.util.zip.CRC32 ++import java.util.zip.DeflaterOutputStream ++import java.util.concurrent.CancellationException ++import java.util.concurrent.CountDownLatch ++import java.util.concurrent.Executors ++import java.util.concurrent.TimeUnit ++import java.util.concurrent.atomic.AtomicInteger ++ ++class OneKeyBlockieAvatarTest { ++ @Test ++ fun percentDecodePreservesJavaScriptNormalizedUtf16WithoutRecasing() { ++ assertEquals("i\u0307中🙂/#+", OneKeyBlockieAvatar.decodeSeed( ++ "onekey-avatar://blockie/v1/i%CC%87%E4%B8%AD%F0%9F%99%82%2F%23%2B", ++ )) ++ assertEquals("İ", OneKeyBlockieAvatar.decodeSeed("onekey-avatar://blockie/v1/%C4%B0")) ++ assertEquals("\u0000", OneKeyBlockieAvatar.decodeSeed("onekey-avatar://blockie/v1/%00")) ++ } ++ ++ @Test ++ fun invalidProtocolPercentEncodingAndUtf8AreRejected() { ++ listOf( ++ "onekey-avatar://blockie/v2/seed", "onekey-avatar://blockie/v1/", ++ "onekey-avatar://blockie/v1/%", "onekey-avatar://blockie/v1/%GG", ++ "onekey-avatar://blockie/v1/%C0%AF", "onekey-avatar://blockie/v1/%ED%A0%80", ++ "onekey-avatar://blockie/v1/seed?query", "onekey-avatar://blockie/v1/a/b", ++ "onekey-avatar://blockie/v1/a+b", "onekey-avatar://blockie/v1/中", ++ ).forEach { uri -> ++ try { ++ OneKeyBlockieAvatar.decodeSeed(uri) ++ fail("Invalid avatar URI was accepted") ++ } catch (_: Exception) { } ++ } ++ } ++ ++ @Test ++ fun generationIsDeterministicAndEmits128pxIndexedPng() { ++ val uri = "onekey-avatar://blockie/v1/0x1234" ++ val first = OneKeyBlockieAvatar.png(uri) ++ assertArrayEquals(first, OneKeyBlockieAvatar.png(uri)) ++ assertArrayEquals(byteArrayOf(-119, 80, 78, 71, 13, 10, 26, 10), first.copyOfRange(0, 8)) ++ assertArrayEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128), first.copyOfRange(16, 24)) ++ assertTrue(first.size < 1024) ++ } ++ ++ @Test(expected = CancellationException::class) ++ fun cancellationStopsBeforeGeneration() { ++ OneKeyBlockieAvatar.png("onekey-avatar://blockie/v1/seed") { true } ++ } ++ ++ @Test(expected = CancellationException::class) ++ fun cancellationAlsoStopsLargeSeedDecoding() { ++ val checks = AtomicInteger() ++ OneKeyBlockieAvatar.png("onekey-avatar://blockie/v1/" + "a".repeat(8192)) { ++ checks.incrementAndGet() > 3 ++ } ++ } ++ ++ private fun replaceChunk(png: ByteArray, type: String, data: ByteArray): ByteArray { ++ val input = ByteBuffer.wrap(png) ++ input.position(8) ++ while (input.hasRemaining()) { ++ val start = input.position() ++ val length = input.int ++ val chunkType = ByteArray(4).also(input::get).toString(Charsets.US_ASCII) ++ val end = input.position() + length + 4 ++ if (chunkType == type) { ++ val chunk = ByteArrayOutputStream() ++ DataOutputStream(chunk).use { ++ val name = type.toByteArray(Charsets.US_ASCII) ++ it.writeInt(data.size) ++ it.write(name) ++ it.write(data) ++ it.writeInt(CRC32().apply { update(name); update(data) }.value.toInt()) ++ } ++ return png.copyOfRange(0, start) + chunk.toByteArray() + png.copyOfRange(end, png.size) ++ } ++ input.position(end) ++ } ++ throw IllegalArgumentException("Test PNG chunk missing") ++ } ++ ++ private fun compressedPixels(size: Int, invalidFilter: Boolean = false): ByteArray { ++ val result = ByteArrayOutputStream() ++ DeflaterOutputStream(result).use { output -> ++ repeat(size) { index -> output.write(if (invalidFilter && index == 0) 1 else 0) } ++ } ++ return result.toByteArray() ++ } ++ ++ @Test ++ fun completeFixedFormatPngPassesIntegrityValidation() { ++ listOf("seed", "0x1234", "%E4%B8%AD%F0%9F%99%82").forEach { ++ assertTrue(OneKeyBlockieAvatar.isValidPng(OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + it))) ++ } ++ } ++ ++ @Test ++ fun corruptedOrTruncatedChunksAndTrailingBytesAreRejected() { ++ val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed") ++ assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf(png.size - 1))) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(png + byteArrayOf(0))) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf().also { it[45] = (it[45].toInt() xor 1).toByte() })) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf().also { ByteBuffer.wrap(it).putInt(8, Int.MAX_VALUE) })) ++ } ++ ++ @Test ++ fun validCrcCannotHideWrongDimensionsOrMalformedCompressedData() { ++ val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed") ++ val header = png.copyOfRange(16, 29).also { it[3] = 96 } ++ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IHDR", header))) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", byteArrayOf(1, 2, 3)))) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", compressedPixels(128 * 33 - 1)))) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", compressedPixels(128 * 33, true)))) ++ } ++ ++ @Test ++ fun decompressionIsBoundedEvenWhenEveryChunkCrcIsValid() { ++ val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed") ++ val bomb = replaceChunk(png, "IDAT", compressedPixels(1024 * 1024)) ++ assertTrue(bomb.size < OneKeyBlockieAvatar.MAX_PNG_BYTES) ++ assertFalse(OneKeyBlockieAvatar.isValidPng(bomb)) ++ } ++ ++ @Test(expected = CancellationException::class) ++ fun cancellationStopsCachedPngValidation() { ++ OneKeyBlockieAvatar.isValidPng(OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed")) { true } ++ } ++ ++ @Test ++ fun overlappingSizeRequestsShareOneGenerationAndDoNotRetainCompletedImages() { ++ val generated = AtomicInteger() ++ val started = CountDownLatch(1) ++ val complete = CountDownLatch(1) ++ val requests = OneKeyAvatarInFlight { _, _ -> ++ generated.incrementAndGet() ++ started.countDown() ++ check(complete.await(3, TimeUnit.SECONDS)) ++ byteArrayOf(1, 2, 3) ++ } ++ val leases = List(8) { requests.acquire("uri") } ++ val executor = Executors.newFixedThreadPool(8) ++ try { ++ val values = leases.map { lease -> executor.submit { lease.bytes() } } ++ assertTrue(started.await(3, TimeUnit.SECONDS)) ++ complete.countDown() ++ values.forEach { assertArrayEquals(byteArrayOf(1, 2, 3), it.get(3, TimeUnit.SECONDS)) } ++ assertEquals(1, generated.get()) ++ leases.forEach { it.release() } ++ val fresh = requests.acquire("uri") ++ assertArrayEquals(byteArrayOf(1, 2, 3), fresh.bytes()) ++ fresh.release() ++ assertEquals(2, generated.get()) ++ } finally { ++ complete.countDown() ++ leases.forEach { it.release() } ++ executor.shutdownNow() ++ } ++ } ++ ++ @Test ++ fun cancellingOneConsumerKeepsTheOtherConsumerAlive() { ++ val requests = OneKeyAvatarInFlight { _, cancelled -> ++ assertFalse(cancelled()) ++ byteArrayOf(7) ++ } ++ val cancelled = requests.acquire("uri") ++ val active = requests.acquire("uri") ++ cancelled.release() ++ cancelled.release() ++ assertArrayEquals(byteArrayOf(7), active.bytes()) ++ active.release() ++ } ++ ++ @Test ++ fun cancellingEveryConsumerStopsWorkAndAllowsAFreshRequest() { ++ val started = CountDownLatch(1) ++ val stopped = CountDownLatch(1) ++ val generated = AtomicInteger() ++ val requests = OneKeyAvatarInFlight { _, cancelled -> ++ if (generated.incrementAndGet() == 1) { ++ started.countDown() ++ while (!cancelled()) Thread.yield() ++ stopped.countDown() ++ throw CancellationException("Cancelled") ++ } ++ byteArrayOf(9) ++ } ++ val lease = requests.acquire("uri") ++ val executor = Executors.newSingleThreadExecutor() ++ try { ++ executor.submit { try { lease.bytes() } catch (_: CancellationException) { } } ++ assertTrue(started.await(3, TimeUnit.SECONDS)) ++ lease.release() ++ assertTrue(stopped.await(3, TimeUnit.SECONDS)) ++ val fresh = requests.acquire("uri") ++ assertArrayEquals(byteArrayOf(9), fresh.bytes()) ++ fresh.release() ++ } finally { ++ lease.release() ++ executor.shutdownNow() ++ } ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift +new file mode 100644 +index 0000000..9b67732 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift +@@ -0,0 +1,217 @@ ++// OneKey patch: Share local avatar work and persistent SDWebImage caching across ++// render/preload requests, including managers isolated for HTTP headers. ++import Foundation ++import SDWebImage ++import UIKit ++ ++final class OneKeyAvatarImageLoader: NSObject, SDImageLoader { ++ static let shared = OneKeyAvatarImageLoader() ++ static let cache: SDImageCache = { ++ let config = SDImageCacheConfig() ++ config.maxDiskAge = 30 * 24 * 60 * 60 ++ config.maxDiskSize = 32 * 1024 * 1024 ++ config.maxMemoryCost = 8 * 1024 * 1024 ++ config.maxMemoryCount = 128 ++ return SDImageCache(namespace: "onekey-avatar-blockie-v1", diskCacheDirectory: nil, config: config) ++ }() ++ ++ private final class Subscription: NSObject, SDWebImageOperation { ++ let id = UUID() ++ let options: SDWebImageOptions ++ let context: [SDWebImageContextOption: Any]? ++ private let lock = NSLock() ++ private var completion: SDImageLoaderCompletedBlock? ++ private var cancellation: (() -> Void)? ++ private var terminal = false ++ ++ init(options: SDWebImageOptions, context: [SDWebImageContextOption: Any]?, ++ completion: SDImageLoaderCompletedBlock?) { ++ self.options = options; self.context = context; self.completion = completion ++ } ++ ++ var isTerminal: Bool { ++ lock.lock(); defer { lock.unlock() }; return terminal ++ } ++ ++ func onCancel(_ block: @escaping () -> Void) { ++ lock.lock() ++ let alreadyTerminal = terminal ++ if !alreadyTerminal { cancellation = block } ++ lock.unlock() ++ if alreadyTerminal { block() } ++ } ++ ++ func finish(image: UIImage?, data: Data?, error: Error?) { ++ lock.lock() ++ guard !terminal else { lock.unlock(); return } ++ terminal = true ++ let callback = completion ++ completion = nil; cancellation = nil ++ lock.unlock() ++ callback?(image, data, error, true) ++ } ++ ++ func cancel() { ++ lock.lock() ++ guard !terminal else { lock.unlock(); return } ++ terminal = true ++ let callback = completion, cancel = cancellation ++ completion = nil; cancellation = nil ++ lock.unlock() ++ cancel?() ++ // Preload's checked continuation must also terminate on cancellation. ++ DispatchQueue.global(qos: .userInitiated).async { ++ callback?(nil, nil, URLError(.cancelled), true) ++ } ++ } ++ } ++ ++ private final class Flight { ++ let id = UUID() ++ let descriptor: OneKeyBlockieDescriptor ++ let url: URL ++ var subscriptions: [UUID: Subscription] = [:] ++ var operation: BlockOperation? ++ init(descriptor: OneKeyBlockieDescriptor, url: URL) { ++ self.descriptor = descriptor; self.url = url ++ } ++ } ++ ++ private let state = DispatchQueue(label: "onekey.avatar.state") ++ private let workers: OperationQueue = { ++ let queue = OperationQueue() ++ queue.name = "onekey.avatar.generate" ++ queue.qualityOfService = .userInitiated ++ queue.maxConcurrentOperationCount = 2 ++ return queue ++ }() ++ private var flights: [String: Flight] = [:] ++ private var diskWrites = 0 ++ ++ func canRequestImage(for url: URL?) -> Bool { ++ // Own invalid local-avatar URLs too: fail locally instead of using HTTP. ++ url?.scheme == "onekey-avatar" ++ } ++ ++ func shouldBlockFailedURL(with url: URL, error: Error) -> Bool { false } ++ ++ func requestImage(with url: URL?, options: SDWebImageOptions, ++ context: [SDWebImageContextOption: Any]?, progress: SDImageLoaderProgressBlock?, ++ completed: SDImageLoaderCompletedBlock?) -> SDWebImageOperation? { ++ let subscriber = Subscription(options: options, context: context, completion: completed) ++ state.async { ++ guard !subscriber.isTerminal else { return } ++ guard let url, let descriptor = OneKeyBlockieDescriptor(url: url) else { ++ subscriber.finish(image: nil, data: nil, error: URLError(.badURL)) ++ return ++ } ++ let key = descriptor.cacheKey ++ let flight = self.flights[key] ?? Flight(descriptor: descriptor, url: url) ++ self.flights[key] = flight ++ flight.subscriptions[subscriber.id] = subscriber ++ subscriber.onCancel { [weak self, weak flight] in ++ guard let self, let flight else { return } ++ self.state.async { ++ guard self.flights[key] === flight else { return } ++ flight.subscriptions.removeValue(forKey: subscriber.id) ++ if flight.subscriptions.isEmpty { ++ self.flights.removeValue(forKey: key) ++ flight.operation?.cancel() ++ } ++ } ++ } ++ guard flight.operation == nil else { return } ++ let operation = BlockOperation { [weak self, weak flight] in ++ guard let self, let flight else { return } ++ self.load(flight) ++ } ++ flight.operation = operation ++ self.workers.addOperation(operation) ++ } ++ return subscriber ++ } ++ ++ private func load(_ flight: Flight) { ++ let key = flight.descriptor.cacheKey ++ let cancelled = { flight.operation?.isCancelled != false } ++ guard !cancelled() else { return } ++ let queryTypes = state.sync { cacheTypes(flight, field: .originalQueryCacheType) } ++ // Query again inside the merged worker to close the manager-cache-query vs ++ // completed previous flight race. These disk operations never run on UI. ++ var data: Data? ++ if queryTypes.memory, let image = Self.cache.imageFromMemoryCache(forKey: key) { ++ data = image.pngData() ++ } ++ if data == nil, queryTypes.disk { data = Self.cache.diskImageData(forKey: key) } ++ var original = data.flatMap { UIImage(data: $0) } ++ // A damaged persistent entry is a local cache miss, not a permanent avatar failure. ++ if original == nil { ++ data = OneKeyBlockie.png(seed: flight.descriptor.seed, isCancelled: cancelled) ++ original = data.flatMap { UIImage(data: $0) } ++ } ++ guard !cancelled() else { return } ++ guard let data, let original else { ++ finish(flight, data: nil, error: URLError(.cannotDecodeContentData)); return ++ } ++ // Synchronous store on this worker seals the cache-fill window before any ++ // subscriber (or a new isolated manager) can observe a completed flight. ++ var storedMemory = false, storedDisk = false ++ while !cancelled() { ++ var subscribers: [Subscription]? ++ let storeTypes = state.sync { () -> (memory: Bool, disk: Bool) in ++ guard flights[key] === flight else { subscribers = []; return (false, false) } ++ let required = cacheTypes(flight, field: .originalStoreCacheType) ++ if (!required.memory || storedMemory) && (!required.disk || storedDisk) { ++ flights.removeValue(forKey: key) ++ subscribers = Array(flight.subscriptions.values) ++ } ++ return required ++ } ++ if let subscribers { deliver(subscribers, flight: flight, data: data, error: nil); return } ++ if storeTypes.memory && !storedMemory { ++ Self.cache.storeImage(toMemory: original, forKey: key) ++ storedMemory = true ++ } ++ if storeTypes.disk && !storedDisk { ++ Self.cache.storeImageData(toDisk: data, forKey: key) ++ storedDisk = true ++ state.async { ++ self.diskWrites += 1 ++ // SDWebImage also cleans on background/termination; bound active-session ++ // growth without scanning the directory after every small PNG write. ++ if self.diskWrites % 128 == 0 { Self.cache.deleteOldFiles(completionBlock: nil) } ++ } ++ } ++ } ++ } ++ ++ private func cacheTypes(_ flight: Flight, field: SDWebImageContextOption) -> (memory: Bool, disk: Bool) { ++ var memory = false, disk = false ++ for subscriber in flight.subscriptions.values where !subscriber.isTerminal { ++ let raw = (subscriber.context?[field] as? NSNumber)?.intValue ?? SDImageCacheType.all.rawValue ++ memory = memory || raw == SDImageCacheType.memory.rawValue || raw == SDImageCacheType.all.rawValue ++ disk = disk || raw == SDImageCacheType.disk.rawValue || raw == SDImageCacheType.all.rawValue ++ } ++ return (memory, disk) ++ } ++ ++ private func finish(_ flight: Flight, data: Data?, error: Error?) { ++ let subscribers: [Subscription] = state.sync { ++ guard flights[flight.descriptor.cacheKey] === flight else { return [] } ++ flights.removeValue(forKey: flight.descriptor.cacheKey) ++ return Array(flight.subscriptions.values) ++ } ++ deliver(subscribers, flight: flight, data: data, error: error) ++ } ++ ++ private func deliver(_ subscribers: [Subscription], flight: Flight, data: Data?, error: Error?) { ++ for subscriber in subscribers where !subscriber.isTerminal { ++ let image: UIImage? = data.flatMap { ++ SDWebImage.SDImageLoaderDecodeImageData($0, flight.url, ++ .init(rawValue: subscriber.options.rawValue), subscriber.context) ++ } ++ subscriber.finish(image: image, data: data, ++ error: error ?? (image == nil ? URLError(.cannotDecodeContentData) : nil)) ++ } ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyBlockie.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyBlockie.swift +new file mode 100644 +index 0000000..3171368 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyBlockie.swift +@@ -0,0 +1,126 @@ ++// OneKey patch: Render the versioned local avatar URI without a JS PNG payload. ++// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64 ++// Permission is hereby granted, free of charge, to any person obtaining a copy ++// of this software and associated documentation files (the "Software"), to deal ++// in the Software without restriction, including without limitation the rights ++// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ++// copies of the Software, and to permit persons to whom the Software is ++// furnished to do so, subject to the following conditions: ++// The above copyright notice and this permission notice shall be included in ++// all copies or substantial portions of the Software. ++// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ++// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ++// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ++// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ++// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ++// THE SOFTWARE. ++ ++import CoreGraphics ++import Foundation ++import ImageIO ++ ++struct OneKeyBlockieDescriptor { ++ let seed: String ++ let cacheKey: String ++ ++ init?(url: URL) { ++ guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), ++ parts.scheme == "onekey-avatar", parts.host == "blockie", ++ parts.user == nil, parts.password == nil, parts.port == nil, ++ parts.query == nil, parts.fragment == nil, ++ parts.percentEncodedPath.hasPrefix("/v1/") ++ else { return nil } ++ let encoded = String(parts.percentEncodedPath.dropFirst(4)) ++ guard !encoded.contains("/"), let seed = encoded.removingPercentEncoding, ++ !seed.isEmpty ++ else { return nil } ++ // The caller already applied JS lowercase. ASCII keys preserve distinct ++ // UTF16 seeds that Swift String otherwise compares as canonically equal. ++ let allowed = CharacterSet(charactersIn: ++ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()") ++ guard let canonical = seed.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil } ++ self.seed = seed ++ cacheKey = "onekey-avatar://blockie/v1/\(canonical)" ++ } ++} ++ ++enum OneKeyBlockie { ++ static let pixelSize = 128 ++ ++ private struct Random { ++ var state = [Int32](repeating: 0, count: 4) ++ ++ init(seed: String) { ++ for (index, unit) in seed.utf16.enumerated() { ++ let slot = index % 4 ++ state[slot] = (state[slot] &<< 5) &- state[slot] &+ Int32(unit) ++ } ++ } ++ ++ mutating func next() -> Double { ++ let t = state[0] ^ (state[0] &<< 11) ++ state[0] = state[1]; state[1] = state[2]; state[2] = state[3] ++ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8) ++ return Double(UInt32(bitPattern: state[3])) / 2_147_483_648 ++ } ++ ++ mutating func color() -> [UInt8] { ++ let h = floor(next() * 360) / 360 ++ let s = (next() * 60 + 40) / 100 ++ let l = ((next() + next() + next() + next()) * 25) / 100 ++ let q = l < 0.5 ? l * (1 + s) : l + s - l * s ++ let p = 2 * l - q ++ func channel(_ value: Double) -> UInt8 { ++ var t = value ++ if t < 0 { t += 1 } ++ if t > 1 { t -= 1 } ++ let value: Double ++ if t < 1.0 / 6 { value = p + (q - p) * 6 * t } ++ else if t < 1.0 / 2 { value = q } ++ else if t < 2.0 / 3 { value = p + (q - p) * (2.0 / 3 - t) * 6 } ++ else { value = p } ++ return UInt8(truncatingIfNeeded: Int(floor(value * 255 + 0.5))) ++ } ++ return [channel(h + 1.0 / 3), channel(h), channel(h - 1.0 / 3), 255] ++ } ++ } ++ ++ static func rgba(seed: String, isCancelled: () -> Bool = { false }) -> Data? { ++ var random = Random(seed: seed) ++ let foreground = random.color(), background = random.color(), spot = random.color() ++ var pixels = [UInt8](repeating: 0, count: pixelSize * pixelSize * 4) ++ for row in 0..<8 { ++ guard !isCancelled() else { return nil } ++ let half = (0..<4).map { _ in Int(floor(random.next() * 2.3)) } ++ let cells = half + half.reversed() ++ for column in 0..<8 { ++ let color = cells[column] == 0 ? background : cells[column] == 1 ? foreground : spot ++ for y in (row * 16)..<((row + 1) * 16) { ++ for x in (column * 16)..<((column + 1) * 16) { ++ let offset = (y * pixelSize + x) * 4 ++ for channel in 0..<4 { pixels[offset + channel] = color[channel] } ++ } ++ } ++ } ++ } ++ return Data(pixels) ++ } ++ ++ static func png(seed: String, isCancelled: () -> Bool = { false }) -> Data? { ++ guard let data = rgba(seed: seed, isCancelled: isCancelled), !isCancelled(), ++ let provider = CGDataProvider(data: data as CFData), ++ let image = CGImage(width: pixelSize, height: pixelSize, bitsPerComponent: 8, ++ bitsPerPixel: 32, bytesPerRow: pixelSize * 4, space: CGColorSpaceCreateDeviceRGB(), ++ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), ++ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) ++ else { return nil } ++ let output = NSMutableData() ++ guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) ++ else { return nil } ++ CGImageDestinationAddImage(destination, image, nil) ++ guard CGImageDestinationFinalize(destination), !isCancelled() else { return nil } ++ return output as Data ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift +index dbbe6cc..6162f20 100644 +--- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift +@@ -313,7 +313,10 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView { + cachePolicy: cachePolicy ?? .memoryDisk, + thumbnailPixelSize: thumbnailPixelSize, + safetyTracker: safetyHandle.tracker, +- manager: safetyHandle.manager ++ // OneKey patch: Rendering and preload use the same local-avatar loader. ++ // manager: safetyHandle.manager ++ manager: safetyHandle.manager, ++ url: url + ) + hostView.sd_setImage( + with: url, +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift +index 8517d8a..00f0f20 100644 +--- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift +@@ -74,6 +74,8 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec { + func clearMemory() throws -> Promise { + Promise.async { + SDImageCache.shared.clearMemory() ++ // OneKey patch: Clear the dedicated local-avatar cache with public cache operations. ++ OneKeyAvatarImageLoader.cache.clearMemory() + } + } + +@@ -82,11 +84,16 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec { + await withCheckedContinuation { continuation in + SDImageCache.shared.clearDisk { continuation.resume() } + } ++ await withCheckedContinuation { continuation in ++ OneKeyAvatarImageLoader.cache.clearDisk { continuation.resume() } ++ } + } + } + + func clearAll() throws -> Promise { + SDImageCache.shared.clearMemory() ++ // OneKey patch: The avatar cache is shared by render and preload requests. ++ OneKeyAvatarImageLoader.cache.clearMemory() + return try clearDisk() + } + +@@ -154,7 +161,10 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec { + cachePolicy: source.cachePolicy ?? .memoryDisk, + thumbnailPixelSize: thumbnailPixelSize, + safetyTracker: safetyHandle.tracker, +- manager: safetyHandle.manager ++ // OneKey patch: Rendering and preload use the same local-avatar loader. ++ // manager: safetyHandle.manager ++ manager: safetyHandle.manager, ++ url: url + ) + return await load(url: url, context: context, safetyHandle: safetyHandle) + } +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift +index 393660a..2a29939 100644 +--- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift +@@ -697,7 +697,10 @@ enum OneKeyImageRequestContext { + cachePolicy: OneKeyImageCachePolicy, + thumbnailPixelSize: CGSize?, + safetyTracker: OneKeyImageSafetyTracker?, +- manager: SDWebImageManager ++ // OneKey patch: Scope local avatar routing to this request, preserving HTTP managers. ++ // manager: SDWebImageManager ++ manager: SDWebImageManager, ++ url: URL? = nil + ) -> [SDWebImageContextOption: Any] { + var context = baseContext + context[.customManager] = manager +@@ -728,6 +731,14 @@ enum OneKeyImageRequestContext { + context[.storeCacheType] = cacheType.rawValue + context[.originalQueryCacheType] = cacheType.rawValue + context[.originalStoreCacheType] = cacheType.rawValue ++ if url?.scheme == "onekey-avatar" { ++ context[.imageLoader] = OneKeyAvatarImageLoader.shared ++ context[.imageCache] = OneKeyAvatarImageLoader.cache ++ context[.originalImageCache] = OneKeyAvatarImageLoader.cache ++ context[.cacheKeyFilter] = SDWebImageCacheKeyFilter { url in ++ OneKeyBlockieDescriptor(url: url)?.cacheKey ?? url.absoluteString ++ } ++ } + return context + } + +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift +index a55ad89..f15571d 100644 +--- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift +@@ -27,8 +27,13 @@ public final class OneKeyImageReusableView: UIView { + recyclingKey: String, + optimizeTos: Bool, + overscan: Double, +- loadingStrategy: String ++ loadingStrategy: String, ++ onLoad: (() -> Void)? = nil, ++ onError: (() -> Void)? = nil + ) { ++ // OneKey patch: Let reusable cells display their own success and fallback visuals. ++ image.onLoad = { _, _, _ in onLoad?() } ++ image.onError = { _ in onError?() } + image.sourceHeadersJson = sourceHeadersJson + image.variant = OneKeyImageVariant(fromString: variant) ?? .generic + image.contentFit = OneKeyImageContentFit(fromString: contentFit) ?? .cover +diff --git a/node_modules/@onekeyfe/react-native-image/ios/tests/OneKeyAvatarImageTests.swift b/node_modules/@onekeyfe/react-native-image/ios/tests/OneKeyAvatarImageTests.swift +new file mode 100644 +index 0000000..9c93474 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/ios/tests/OneKeyAvatarImageTests.swift +@@ -0,0 +1,49 @@ ++// OneKey patch: Protect request-local avatar routing and exact UTF16 identities. ++import Foundation ++import SDWebImage ++import XCTest ++ ++@testable import OneKeyImage ++ ++final class OneKeyAvatarImageTests: XCTestCase { ++ func testAvatarCacheIsSharedWithoutReplacingIsolatedHTTPManagers() throws { ++ let url = try XCTUnwrap(URL(string: "onekey-avatar://blockie/v1/synthetic-avatar")) ++ let first = OneKeyImagePipeline.makeIsolatedManager() ++ let second = OneKeyImagePipeline.makeIsolatedManager() ++ func context(_ manager: SDWebImageManager, headers: String) -> [SDWebImageContextOption: Any] { ++ OneKeyImageRequestContext.make(headersJson: headers, cachePolicy: .memoryDisk, ++ thumbnailPixelSize: CGSize(width: 96, height: 96), safetyTracker: nil, ++ manager: manager, url: url) ++ } ++ let a = context(first, headers: "{\"X-Test\":\"a\"}") ++ let b = context(second, headers: "{\"X-Test\":\"b\"}") ++ XCTAssertTrue(a[.customManager] as? SDWebImageManager === first) ++ XCTAssertTrue(b[.customManager] as? SDWebImageManager === second) ++ XCTAssertTrue(a[.imageLoader] as? OneKeyAvatarImageLoader === OneKeyAvatarImageLoader.shared) ++ XCTAssertTrue(b[.imageLoader] as? OneKeyAvatarImageLoader === OneKeyAvatarImageLoader.shared) ++ XCTAssertTrue(a[.imageCache] as? SDImageCache === b[.imageCache] as? SDImageCache) ++ XCTAssertTrue(a[.originalImageCache] as? SDImageCache === OneKeyAvatarImageLoader.cache) ++ let firstFilter = try XCTUnwrap(a[.cacheKeyFilter] as? SDWebImageCacheKeyFilter) ++ let secondFilter = try XCTUnwrap(b[.cacheKeyFilter] as? SDWebImageCacheKeyFilter) ++ XCTAssertEqual(firstFilter.cacheKey(for: url), secondFilter.cacheKey(for: url)) ++ let remote = OneKeyImageRequestContext.make(headersJson: nil, cachePolicy: .memoryDisk, ++ thumbnailPixelSize: nil, safetyTracker: nil, manager: first, ++ url: URL(string: "https://example.com/image.png")) ++ XCTAssertNil(remote[.imageLoader]) ++ XCTAssertNil(remote[.imageCache]) ++ XCTAssertNil(remote[.originalImageCache]) ++ } ++ ++ func testURIDecodesExactlyOnceAndPreservesUTF16SeedIdentity() throws { ++ func descriptor(_ suffix: String) throws -> OneKeyBlockieDescriptor { ++ try XCTUnwrap(OneKeyBlockieDescriptor(url: ++ XCTUnwrap(URL(string: "onekey-avatar://blockie/v1/" + suffix)))) ++ } ++ XCTAssertEqual(try descriptor("%2561").seed, "%61") ++ XCTAssertEqual(try descriptor("%61").cacheKey, try descriptor("a").cacheKey) ++ XCTAssertNotEqual(try descriptor("%C3%A9").cacheKey, try descriptor("e%CC%81").cacheKey) ++ XCTAssertEqual(try descriptor("%C4%B0").seed.utf16.count, 1) ++ XCTAssertNotEqual(OneKeyBlockie.rgba(seed: "é"), OneKeyBlockie.rgba(seed: "e\u{301}")) ++ XCTAssertNil(OneKeyBlockie.png(seed: "synthetic", isCancelled: { true })) ++ } ++} diff --git a/patches/@onekeyfe+react-native-native-list+3.0.105.patch b/patches/@onekeyfe+react-native-native-list+3.0.105.patch new file mode 100644 index 000000000000..5133a6b116d1 --- /dev/null +++ b/patches/@onekeyfe+react-native-native-list+3.0.105.patch @@ -0,0 +1,7474 @@ +diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +index c7d7905..e338756 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt ++++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt +@@ -41,6 +41,7 @@ internal class NativeListAdapter( + private val createdRows = Collections.newSetFromMap( + WeakHashMap(), + ) ++ var usesSelectorSourceScale = false + var theme: JSONObject? = null + var layout: String = "linear" + var orientation: String = "vertical" +@@ -75,8 +76,9 @@ internal class NativeListAdapter( + layout, + orientation, + position, +- selectedKeys.contains(item.key), ++ item.json.optBoolean("selected", false) || selectedKeys.contains(item.key), + checkboxState, ++ useSourceScale = usesSelectorSourceScale, + ) + } + +@@ -85,14 +87,17 @@ internal class NativeListAdapter( + position: Int, + payloads: MutableList, + ) { +- if (payloads.contains(SELECTION_PAYLOAD)) { ++ // OneKey patch: an unknown payload must retain full binding; validated echoes keep images alive. ++ // if (payloads.contains(SELECTION_PAYLOAD)) { ++ if (payloads.isNotEmpty() && payloads.all { it == SELECTION_PAYLOAD || it == SELECTION_ECHO_PAYLOAD }) { + val item = itemAt(position) ?: return ++ if (payloads.contains(SELECTION_ECHO_PAYLOAD)) holder.rowView.bindStableSummary(item) + holder.rowView.bindSelection( + item, + theme, + layout, + position, +- selectedKeys.contains(item.key), ++ item.json.optBoolean("selected", false) || selectedKeys.contains(item.key), + checkboxState, + ) + return +@@ -158,8 +163,16 @@ internal class NativeListAdapter( + + override fun areContentsTheSame(oldItem: NativeListItem, newItem: NativeListItem): Boolean = + oldItem.revision == newItem.revision && oldItem.content == newItem.content ++ ++ // OneKey patch: a theme/layout update or a diff spanning another baseline stays a full bind. ++ override fun getChangePayload(oldItem: NativeListItem, newItem: NativeListItem): Any? = ++ if (oldItem.key == newItem.key && oldItem.type == newItem.type && ++ newItem.selectionUpdateFromContent == oldItem.content ++ ) SELECTION_ECHO_PAYLOAD else null + } + } + } + + internal const val SELECTION_PAYLOAD = "selection" ++// OneKey patch: internal payload, never exposed through the serialized row contract. ++internal const val SELECTION_ECHO_PAYLOAD = "selectionEcho" +diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +index d8300e2..a129837 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt ++++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt +@@ -11,6 +11,12 @@ internal data class NativeListItem( + val json: JSONObject, + ) { + val content: String = json.toString() ++ // OneKey patch: only a host-validated stable snapshot can request a lightweight diff payload. ++ var selectionUpdateFromContent: String? = null ++ ++ // OneKey patch: migrated selectors keep source dimensions on narrow Android screens. ++ val usesSelectorSourceScale: Boolean ++ get() = if (type == "walletGroup") json.optJSONObject("parent")?.has("height") == true else json.has("height") && json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") + + val isSelectable: Boolean + get() = !json.optBoolean("disabled", false) && type in SELECTABLE_TYPES +diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +index db47e5e..efed94f 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt ++++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +@@ -29,6 +29,12 @@ import android.widget.LinearLayout + import android.widget.ProgressBar + import android.widget.TextView + import com.facebook.react.uimanager.ThemedReactContext ++// OneKey patch: selector checkboxes share the source React Native border/background renderer. ++import com.facebook.react.uimanager.BackgroundStyleApplicator ++import com.facebook.react.uimanager.LengthPercentage ++import com.facebook.react.uimanager.LengthPercentageType ++import com.facebook.react.uimanager.style.BorderRadiusProp ++import com.facebook.react.uimanager.style.LogicalEdge + import com.margelo.nitro.onekeyimage.OneKeyImageReusableView + import androidx.core.graphics.PathParser + import androidx.core.widget.TextViewCompat +@@ -42,6 +48,7 @@ internal data class NativeListActionOrigin( + val bindingEpoch: Long, + val source: String, + val slot: Int? = null, ++ val anchorInsetPixels: Int = 0, + ) + + /** React Native color strings use CSS #RRGGBBAA ordering; Android expects #AARRGGBB. */ +@@ -96,7 +103,20 @@ internal object NativeListFonts { + + internal data class NativeSelectionTarget(val scope: String, val key: String?) + ++// OneKey patch: match React Native's CustomLineHeightSpan for first/last line bounds. ++private class SelectorLineHeightSpan(private val lineHeight: Int) : android.text.style.LineHeightSpan { ++ override fun chooseHeight(text: CharSequence, start: Int, end: Int, spanstartv: Int, v: Int, fm: Paint.FontMetricsInt) { ++ val leading = lineHeight - (fm.descent - fm.ascent) ++ fm.ascent -= kotlin.math.ceil(leading / 2.0).toInt() ++ fm.descent += kotlin.math.floor(leading / 2.0).toInt() ++ if (start == 0) fm.top = fm.ascent ++ if (end == text.length) fm.bottom = fm.descent ++ } ++} ++ + private class DottedUnderlineTextView(context: android.content.Context) : TextView(context) { ++ var useSourceScale = false ++ private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) + var showsDottedUnderline = false + var dottedUnderlineColor = Color.TRANSPARENT + private val dottedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } +@@ -105,11 +125,11 @@ private class DottedUnderlineTextView(context: android.content.Context) : TextVi + super.onDraw(canvas) + if (!showsDottedUnderline || text.isEmpty()) return + dottedPaint.color = dottedUnderlineColor +- val radius = NativeListScale.dp(resources, 0.75f) +- val spacing = NativeListScale.dp(resources, 4f) ++ val radius = scaledDp(0.75f) ++ val spacing = scaledDp(4f) + val lineWidth = paint.measureText(text.toString()).coerceAtMost(width.toFloat()) + val y = height - radius +- var x = NativeListScale.dp(resources, 1f) ++ var x = scaledDp(1f) + while (x <= lineWidth - radius) { + canvas.drawCircle(x, y, radius, dottedPaint) + x += spacing +@@ -160,6 +180,29 @@ private class PackedTitleLineLayout(context: android.content.Context) : LinearLa + } + } + ++// OneKey patch: fit subtitle segments at intrinsic width, shrinking text only when necessary. ++private class SelectorSubtitleLayout(context: android.content.Context) : LinearLayout(context) { ++ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { ++ val available = MeasureSpec.getSize(widthMeasureSpec) ++ val labels = mutableListOf>() ++ var fixedWidth = 0 ++ for (index in 0 until childCount) { ++ val child = getChildAt(index) ++ val params = child.layoutParams as LayoutParams ++ if (child is TextView) { ++ child.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), heightMeasureSpec) ++ labels.add(child to child.measuredWidth) ++ } else fixedWidth += params.width + params.leftMargin + params.rightMargin ++ } ++ val desired = labels.sumOf { it.second } ++ val textWidth = (available - fixedWidth).coerceAtLeast(0) ++ labels.forEach { (label, width) -> ++ (label.layoutParams as LayoutParams).width = if (desired <= textWidth) width else (width.toLong() * textWidth / desired.coerceAtLeast(1)).toInt() ++ } ++ super.onMeasure(widthMeasureSpec, heightMeasureSpec) ++ } ++} ++ + private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { + private val primaryLine = LinearLayout(context) + private val primary = TextView(context) +@@ -282,6 +325,21 @@ internal class NativeListRowView( + private val reactContext: ThemedReactContext, + ) : LinearLayout(reactContext) { + private val leadingFrame = FrameLayout(context) ++ // OneKey patch: reset selector fragments and corner decorations on every bind. ++ private val selectorViews = mutableListOf() ++ private var selectorUsesSourceScale = false ++ private val selectorAccessibilityDelegate = object : View.AccessibilityDelegate() { ++ override fun onInitializeAccessibilityNodeInfo(host: View, info: android.view.accessibility.AccessibilityNodeInfo) { ++ super.onInitializeAccessibilityNodeInfo(host, info) ++ info.viewIdResourceName = host.getTag(com.facebook.react.R.id.react_test_id) as? String ++ } ++ } ++ private val selectorOriginalFontFeatures = mutableMapOf() ++ private val selectorOriginalPaintFlags = mutableMapOf() ++ private val selectorLineHeights = mutableMapOf() ++ private val selectorFontSizes = mutableMapOf() ++ private val selectorImages = mutableListOf() ++ private var selectorHeight: Int? = null + private val leadingImages = List(3) { OneKeyImageReusableView(reactContext) } + private val leadingOverlayBackground = View(context) + private val leadingCornerIconFrame = FrameLayout(context) +@@ -334,6 +392,8 @@ internal class NativeListRowView( + private var walletGroupExpandAnimator: ValueAnimator? = null + private var isMediaTile = false + private var boundKey: String? = null ++ // OneKey patch: delayed retries cannot survive cell rebinding or recycling. ++ private val selectorImageRetries = mutableMapOf() + var bindingEpoch: Long = 0 + private set + private var boundCheckboxData: JSONObject? = null +@@ -344,6 +404,8 @@ internal class NativeListRowView( + private var checkboxCheckedColor = Color.rgb(32, 32, 32) + private var checkboxUncheckedColor = Color.rgb(252, 252, 252) + private var checkboxBorderColor = Color.rgb(206, 206, 206) ++ private var checkboxIconColor = Color.rgb(252, 252, 252) ++ private var checkboxUsesSelectorStyle = false + private var iconSubduedColor = Color.rgb(141, 141, 141) + private var visualBackdropColor = Color.WHITE + private val circleOutlineProvider = object : ViewOutlineProvider() { +@@ -462,7 +524,8 @@ internal class NativeListRowView( + } + setOnClickListener { view -> + (view.tag as? NativeListItem)?.let { item -> +- onRowPress?.invoke(item, actionOrigin(view, "row")) ++ // OneKey patch: allow create-address accessories when whole-row press is gated. ++ if (!item.json.optBoolean("pressDisabled", false)) onRowPress?.invoke(item, actionOrigin(view, "row")) + } + } + setWillNotDraw(false) +@@ -509,13 +572,64 @@ internal class NativeListRowView( + } + } + ++ // OneKey patch: RecyclerView may replace item delegates during a selection update. ++ override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) { ++ super.onInitializeAccessibilityNodeInfo(info) ++ info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String ++ } ++ + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (isMediaTile) { + val availableWidth = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) + .coerceAtLeast(0) + leadingFrame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, availableWidth) + } +- super.onMeasure(widthMeasureSpec, heightMeasureSpec) ++ // OneKey patch: RecyclerView must use the adapter's measured selector height. ++ super.onMeasure(widthMeasureSpec, selectorHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } ?: heightMeasureSpec) ++ val item = tag as? NativeListItem ?: return ++ if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && item.type in setOf("identity", "sectionHeader") && checkbox.visibility == VISIBLE && trailingColumn.parent === this) { ++ // OneKey patch: Yoga snaps the compound accessory before its children; their rounded edges can overflow it. ++ val density = resources.displayMetrics.density ++ val visibleValues = trailingViews.filter { it.visibility == VISIBLE } ++ val sourceTrailingWidth = visibleValues.sumOf { it.measuredWidth } + (20 + 12 * visibleValues.size) * density ++ val sourceTrailingLeft = (measuredWidth - 12 * density - sourceTrailingWidth).roundToInt() ++ val measuredTrailingLeft = measuredWidth - paddingRight - trailingColumn.measuredWidth ++ val mainWidth = (mainColumn.measuredWidth + sourceTrailingLeft - measuredTrailingLeft).coerceAtLeast(0) ++ mainColumn.measure(MeasureSpec.makeMeasureSpec(mainWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(mainColumn.measuredHeight, MeasureSpec.EXACTLY)) ++ } ++ } ++ ++ // OneKey patch: Yoga rounds a half-pixel text center upward; LinearLayout truncates it. ++ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { ++ super.onLayout(changed, left, top, right, bottom) ++ val item = tag as? NativeListItem ?: return ++ val accessory = item.json.optJSONArray("trailing")?.optJSONObject(0) ++ if (item.type == "identity" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && accessory?.optString("kind") == "icon" && accessory.optString("name") == "PlusSmallOutline") { ++ // OneKey patch: the borderless Plus retains the source's fixed top18/negative7 slot. ++ val icon = trailingIcons[0] ++ trailingColumn.offsetTopAndBottom(dp(18) - dp(7) - trailingColumn.top - icon.top) ++ } ++ if (item.type == "action" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && leadingIcon.visibility == VISIBLE) { ++ // OneKey patch: Yoga rounds the 4dp padding inside the 32dp Add account icon upward. ++ leadingIcon.offsetLeftAndRight((leadingFrame.width - leadingIcon.width + 1) / 2 - leadingIcon.left) ++ leadingIcon.offsetTopAndBottom((leadingFrame.height - leadingIcon.height + 1) / 2 - leadingIcon.top) ++ } ++ if (!item.json.has("height")) return ++ val isNetworkIdentity = item.type == "identity" && item.json.optString("presentation") == "networkSelector" ++ val isAccountAction = item.type == "action" && item.json.optString("presentation") == "accountSelector" ++ val isNetworkSummary = item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" && item.json.optString("variant") == "summary" ++ val centeredColumns = when { ++ isNetworkIdentity || isAccountAction -> listOf(mainColumn, trailingColumn) ++ isNetworkSummary -> listOf(trailingColumn) ++ else -> return ++ } ++ for (column in centeredColumns) { ++ if (column.parent !== this || column.visibility == GONE) continue ++ val margins = column.layoutParams as MarginLayoutParams ++ val available = height - paddingTop - paddingBottom - margins.topMargin - margins.bottomMargin ++ val desiredTop = paddingTop + margins.topMargin + (available - column.height + 1) / 2 ++ column.offsetTopAndBottom(desiredTop - column.top) ++ } + } + + fun bind( +@@ -526,12 +640,14 @@ internal class NativeListRowView( + itemIndex: Int?, + selected: Boolean, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, ++ useSourceScale: Boolean = false, + ) { + invalidateCurrentBinding() + bindingEpoch += 1 + boundKey = item.key + currentLayout = layout + tag = item ++ selectorUsesSourceScale = item.usesSelectorSourceScale || useSourceScale + reorderActive = false + leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) + secondaryImage.prepareForReuse() +@@ -544,12 +660,21 @@ internal class NativeListRowView( + val accent = color(theme, "accent", "#0D8200FC") + checkboxCheckedColor = primary + checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") ++ checkboxIconColor = checkboxUncheckedColor ++ checkboxUsesSelectorStyle = item.json.optString("presentation") == "networkSelector" + checkboxBorderColor = Color.argb( + 0x31, + 0, + 0, + 0, + ) ++ if (item.json.optString("presentation") == "networkSelector") { ++ checkboxCheckedColor = color(theme, "checkboxBackground", "#202020") ++ checkboxBorderColor = color(theme, "checkboxBorder", "#00000031") ++ checkboxIconColor = color(theme, "checkboxIcon", "#FFFFFF") ++ // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse. ++ checkboxUncheckedColor = checkboxIconColor ++ } + iconSubduedColor = color(theme, "iconSubdued", "#00000072") + visualBackdropColor = color(theme, "rowBackground", "#FFFFFF") + unreadDot.background = roundedFill( +@@ -592,8 +717,12 @@ internal class NativeListRowView( + invalidate() + trailingViews.forEach { it.setTextColor(primary) } + isEnabled = !item.json.optBoolean("disabled", false) +- alpha = if (isEnabled) 1f else 0.5f ++ // OneKey patch: deprecation dims the row without disabling menu controls. ++ // alpha = if (isEnabled) 1f else 0.5f ++ alpha = item.json.optDouble("opacity", 1.0).toFloat() * (if (isEnabled) 1f else 0.5f) + contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) ++ // OneKey patch: retain stable original selector test identifiers. ++ setTag(com.facebook.react.R.id.react_test_id, item.json.optString("testID").takeIf { it.isNotEmpty() }) + + when (item.type) { + "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) +@@ -609,7 +738,16 @@ internal class NativeListRowView( + "system" -> bindSystem(item, theme) + } + applySize(item) ++ if (item.type == "system" && item.json.optString("variant") == "warning") { ++ title.typeface = NativeListFonts.medium(context) ++ title.textSize = sp(14f) ++ subtitle.textSize = sp(14f) ++ TextViewCompat.setLineHeight(title, dp(20)) ++ TextViewCompat.setLineHeight(subtitle, dp(20)) ++ minimumHeight = 0 ++ } + applyListOrientation(item, listOrientation) ++ applySelectorTypography(item) + } + + fun recycle() { +@@ -636,6 +774,8 @@ internal class NativeListRowView( + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + ) { + if (boundKey != item.key) return ++ // OneKey patch: keep row callbacks and checkbox fallback state current without resetting images. ++ tag = item + if (item.type == "walletGroup") { + val members = buildList { + add(item.json.getJSONObject("parent")) +@@ -669,7 +809,18 @@ internal class NativeListRowView( + ), + ) + } +- boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } ++ // OneKey patch: the comparator permits state changes only on these existing checkbox slots. ++ // boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } ++ if (boundCheckboxData != null) { ++ val latestCheckbox = if (item.type == "identity") { ++ item.json.optJSONArray("trailing")?.let { trailing -> ++ (0 until trailing.length()).mapNotNull { trailing.optJSONObject(it) } ++ .lastOrNull { it.optString("kind") == "checkbox" } ++ } ++ } else item.json.optJSONObject("checkbox") ++ boundCheckboxData = latestCheckbox ?: boundCheckboxData ++ boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } ++ } + } + + fun bindStableSummary(item: NativeListItem) { +@@ -684,10 +835,14 @@ internal class NativeListRowView( + contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) + title.text = item.json.optString("title") + trailingViews[0].text = item.json.optString("value") ++ applySelectorTypography(item) + } + + fun dispose() { ++ invalidateCurrentBinding() + restoreRestingBackground() ++ selectorImages.forEach(OneKeyImageReusableView::dispose) ++ selectorImages.clear() + leadingImages.forEach(OneKeyImageReusableView::dispose) + secondaryImage.dispose() + mediaNetworkImage.dispose() +@@ -699,7 +854,8 @@ internal class NativeListRowView( + sourceView: View, + source: String, + slot: Int? = null, +- ) = NativeListActionOrigin(sourceView, this, bindingEpoch, source, slot) ++ ) = NativeListActionOrigin(sourceView, this, bindingEpoch, source, slot, ++ if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && sourceView in trailingIcons && (sourceView.layoutParams as MarginLayoutParams).marginStart < 0) dp(7) else 0) + + private fun emitAction( + item: NativeListItem, +@@ -712,13 +868,65 @@ internal class NativeListRowView( + onAction?.invoke(item, actionKey, target, actionOrigin(sourceView, source, slot)) + } + ++ // OneKey patch: match SizableText TABULAR_NUMS on dynamic labels without replacing their typeface. ++ private fun applySelectorTypography(item: NativeListItem) { ++ val usesSelectorTypography = item.json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") || item.type == "system" && item.json.optString("variant") == "warning" ++ fun visit(view: View) { ++ if (view is OneKeyIconView) view.useSourceScale = selectorUsesSourceScale ++ if (view is DottedUnderlineTextView) view.useSourceScale = selectorUsesSourceScale ++ if (view is TextView && usesSelectorTypography) { ++ val selectorLineHeight = selectorLineHeights.getOrPut(view) { view.lineHeight } ++ val original = view.fontFeatureSettings ++ if (!selectorOriginalFontFeatures.containsKey(view)) selectorOriginalFontFeatures[view] = original ++ view.fontFeatureSettings = if (original.isNullOrEmpty()) "tnum" else if (original.contains("tnum")) original else "$original, 'tnum' 1" ++ // OneKey patch: SizableText disables font scaling and rounds font sizes to whole pixels. ++ val sourceTypography = selectorUsesSourceScale || item.type == "system" && item.json.optString("variant") == "warning" ++ if (sourceTypography) { ++ // OneKey patch: React Native CustomStyleSpan disables hinting and preserves fractional advances. ++ selectorOriginalPaintFlags.putIfAbsent(view, view.paintFlags) ++ view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG ++ val originalSize = selectorFontSizes.getOrPut(view) { view.textSize } ++ val sourceSize = originalSize * resources.displayMetrics.density / resources.displayMetrics.scaledDensity ++ view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, kotlin.math.ceil(sourceSize.toDouble()).toFloat()) ++ view.letterSpacing = 0f ++ } ++ if (sourceTypography && view.text.isNotEmpty()) { ++ val text = SpannableStringBuilder(view.text) ++ text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) ++ text.setSpan(SelectorLineHeightSpan(selectorLineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) ++ view.setLineSpacing(0f, 1f) ++ view.text = text ++ } ++ } ++ if (view is ViewGroup) for (index in 0 until view.childCount) visit(view.getChildAt(index)) ++ } ++ visit(this) ++ } ++ + private fun invalidateCurrentBinding() { ++ selectorImageRetries.forEach { (image, retry) -> image.removeCallbacks(retry) } ++ selectorImageRetries.clear() + if (boundKey == null) return + onBindingInvalidated?.invoke(this, bindingEpoch) + bindingEpoch += 1 + } + + private fun resetViews() { ++ clipToPadding = true ++ selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } ++ selectorOriginalFontFeatures.clear() ++ selectorOriginalPaintFlags.forEach { (view, flags) -> view.paintFlags = flags } ++ selectorOriginalPaintFlags.clear() ++ selectorLineHeights.clear() ++ selectorFontSizes.clear() ++ // OneKey patch: selector-only views cannot survive a recycled binding. ++ selectorViews.forEach { (it.parent as? ViewGroup)?.removeView(it) } ++ selectorViews.clear() ++ selectorImages.forEach(OneKeyImageReusableView::dispose) ++ selectorImages.clear() ++ selectorHeight = null ++ title.setOnClickListener(null) ++ title.isClickable = false + walletGroupRows.forEach { it.invalidateCurrentBinding() } + walletGroupExpandAnimator?.removeAllListeners() + walletGroupExpandAnimator?.cancel() +@@ -801,6 +1009,7 @@ internal class NativeListRowView( + trailingColumn.layoutParams = wrap() + trailingViews.forEach { + it.visibility = GONE ++ it.setTag(com.facebook.react.R.id.react_test_id, null) + it.gravity = Gravity.END + it.maxLines = 1 + it.layoutParams = wrap() +@@ -879,7 +1088,8 @@ internal class NativeListRowView( + mainColumn.removeView(skeletonSecondary) + setOnClickListener { view -> + (view.tag as? NativeListItem)?.let { item -> +- onRowPress?.invoke(item, actionOrigin(view, "row")) ++ // OneKey patch: allow create-address accessories when whole-row press is gated. ++ if (!item.json.optBoolean("pressDisabled", false)) onRowPress?.invoke(item, actionOrigin(view, "row")) + } + } + } +@@ -1003,9 +1213,12 @@ internal class NativeListRowView( + members.add(children.getJSONObject(index)) + } + } ++ if (members.first().has("height")) setPadding(dp(1), dp(1), dp(1), dp(1)) + walletGroupDragChildCount = members.size - 1 + walletGroupExpandedHeightPx = +- dp(members.size * 68 + walletGroupDragChildCount * 12) ++ // OneKey patch: expanded groups include individual wallet badge heights. ++ // dp(members.size * 68 + walletGroupDragChildCount * 12) ++ dp(members.sumOf { it.optInt("height", if ((it.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) } + walletGroupDragChildCount * 12 + if (members.first().has("height")) 2 else 0) + walletGroupDragBadgeBackgroundPaint.color = color( + theme, + "inverseBackground", +@@ -1048,8 +1261,11 @@ internal class NativeListRowView( + null, + memberJson.optBoolean("selected", false), + checkboxState, ++ useSourceScale = selectorUsesSourceScale, + ) +- memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(68)).apply { ++ // OneKey patch: member geometry matches the outer group height calculation. ++ // memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(68)).apply { ++ memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(memberJson.optInt("height", if ((memberJson.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68))).apply { + if (index > 0) topMargin = dp(12) + } + addView(memberRow) +@@ -1082,8 +1298,11 @@ internal class NativeListRowView( + titleLine.gravity = Gravity.CENTER + titleLine.packsChildrenAtStart = false + titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) +- title.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) ++ // OneKey patch: the original wallet title ellipsizes within the full inner row width. ++ title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) + title.gravity = Gravity.CENTER ++ // OneKey patch: this branch returns before the common identity ellipsis setup. ++ if (item.json.has("height")) title.ellipsize = TextUtils.TruncateAt.END + showText(title, item.json.optString("title"), 1) + title.setTextColor( + color( +@@ -1095,13 +1314,41 @@ internal class NativeListRowView( + addView( + mainColumn, + LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { +- topMargin = dp(4) ++ // OneKey patch: snap the source 4 + 40 + 4 sequence once instead of rounding each gap. ++ topMargin = if (item.json.has("height")) dp(48) - dp(44) else dp(4) + }, + ) ++ // OneKey patch: wallet tags are a centered line below the wallet name. ++ item.json.optJSONArray("badges")?.takeIf { it.length() > 0 }?.let { badges -> ++ val isSelector = item.json.has("height") ++ val badgeLineHeight = if (isSelector) 14 else 16 ++ val badgeHeight = badgeLineHeight + 4 ++ val line = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER } ++ for (index in 0 until badges.length()) { ++ val badge = TextView(context).apply { ++ text = badges.getJSONObject(index).optString("text") ++ textSize = sp(if (isSelector) 11f else 12f) ++ typeface = NativeListFonts.regular(context) ++ includeFontPadding = false ++ maxLines = 1 ++ ellipsize = TextUtils.TruncateAt.END ++ val warning = isSelector && badges.getJSONObject(index).optString("tone") == "warning" ++ setTextColor(color(theme, if (warning) "caution" else "secondaryText", if (warning) "#AB6400" else "#0000009B")) ++ background = roundedFill(color(theme, if (warning) "cautionBackground" else if (isSelector) "subduedBackground" else "strongBackground", if (warning) "#FFF4D5" else "#00000006"), 4f) ++ setPadding(dp(if (isSelector) 6 else 4), dp(2), dp(if (isSelector) 6 else 4), dp(2)) ++ } ++ TextViewCompat.setLineHeight(badge, dp(badgeLineHeight)) ++ line.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { if (index > 0) marginStart = dp(4) }) ++ } ++ mainColumn.addView(line, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { topMargin = dp(4) }) ++ selectorViews.add(line) ++ } + return + } + if (item.json.optString("presentation") == "networkSelector") { +- setPadding(dp(12), dp(7), dp(12), dp(8)) ++ // OneKey patch: the explicit 48-point row centers its 32-point network icon. ++ // setPadding(dp(12), dp(7), dp(12), dp(8)) ++ setPadding(dp(12), dp(if (item.json.has("height")) 8 else 7), dp(12), dp(8)) + } + val leading = item.json.optJSONObject("leading") + item.json.optJSONObject("leadingAction")?.let { action -> +@@ -1136,6 +1383,13 @@ internal class NativeListRowView( + item.json.optString("presentation") == "accountSelector" + ) 32 else 40, + ) ++ // OneKey patch: custom network initials retain LetterAvatar typography. ++ if (item.json.optString("presentation") == "networkSelector" && leading?.optJSONObject("image") == null && leading?.optJSONObject("fallbackIcon") == null && !leading?.optString("fallbackText").isNullOrEmpty()) { ++ leadingFallback.textSize = sp(19f) ++ leadingFallback.typeface = NativeListFonts.semibold(context) ++ leadingFallback.setTextColor(color(theme, "inverseText", "#FCFCFC")) ++ TextViewCompat.setLineHeight(leadingFallback, dp(27)) ++ } + addView(mainColumn, weighted()) + titleLine.packsChildrenAtStart = true + title.ellipsize = TextUtils.TruncateAt.END +@@ -1144,6 +1398,43 @@ internal class NativeListRowView( + title.typeface = NativeListFonts.regular(context) + } + showText(subtitle, item.json.optString("subtitle"), item.json.optInt("subtitleLines", 2)) ++ // OneKey patch: use separate labels for independently truncated balance/address. ++ item.json.optJSONArray("subtitleSegments")?.takeIf { it.length() > 0 }?.let { segments -> ++ subtitle.visibility = GONE ++ val line = SelectorSubtitleLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } ++ for (index in 0 until segments.length()) { ++ val segment = segments.getJSONObject(index) ++ if (segment.optBoolean("separatorBefore", false)) { ++ val dot = View(context).apply { background = roundedFill(color(theme, "disabledText", "#00000072"), 2f) } ++ line.addView(dot, LayoutParams(dp(4), dp(4)).apply { marginStart = dp(6); marginEnd = dp(6) }) ++ } ++ val label = TextView(context).apply { ++ text = segment.optString("text") ++ typeface = NativeListFonts.regular(context) ++ textSize = sp(14f) ++ includeFontPadding = false ++ maxLines = 1 ++ ellipsize = TextUtils.TruncateAt.END ++ val toneKey = when (segment.optString("tone")) { "primary" -> "primaryText"; "disabled" -> "disabledText"; "caution" -> "caution"; "positive" -> "positive"; "negative" -> "negative"; else -> "secondaryText" } ++ setTextColor(color(theme, toneKey, if (toneKey == "caution") "#AB6400" else "#0000009B")) ++ } ++ TextViewCompat.setLineHeight(label, dp(20)) ++ applyValueSegments(label, segment.optJSONArray("textSegments"), 14, 20, false) ++ line.addView(label, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) ++ } ++ mainColumn.addView(line, 2, LayoutParams(LayoutParams.MATCH_PARENT, dp(20))) ++ selectorViews.add(line) ++ } ++ item.json.optJSONArray("titleMatch")?.takeIf { it.length() > 0 }?.let { matches -> ++ val highlighted = SpannableStringBuilder(title.text) ++ for (index in 0 until matches.length()) { ++ val match = matches.getJSONObject(index) ++ val start = match.optInt("start") ++ val end = match.optInt("end") ++ if (start >= 0 && end > start && end <= highlighted.length) highlighted.setSpan(ForegroundColorSpan(color(theme, "info", "#0D74CE")), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) ++ } ++ title.text = highlighted ++ } + showText(tertiary, item.json.optString("tertiary"), 1) + tertiary.setTextColor( + color( +@@ -1164,6 +1455,10 @@ internal class NativeListRowView( + } + addView(trailingColumn, wrap()) + val accessories = item.json.optJSONArray("trailing") ++ if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && accessories.hasAccessory("checkbox")) { ++ // OneKey patch: retain ListItem's title-to-accessory gap when measuring truncation. ++ (mainColumn.layoutParams as LayoutParams).marginEnd = dp(12) ++ } + if (accessories.hasAccessory("checkbox") && accessories.hasAccessory("value")) { + trailingColumn.orientation = HORIZONTAL + trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL +@@ -1847,10 +2142,15 @@ internal class NativeListRowView( + val isSummary = variant == "summary" + val isGallery = variant == "gallery" + val isTable = currentLayout == "table" ++ // OneKey patch: title help emits its own frame instead of toggling the section. ++ item.json.optString("titleActionKey").takeIf { it.isNotEmpty() }?.let { key -> ++ title.setOnClickListener { emitAction(item, key, null, title, "leadingAction") } ++ } + val isNetworkSelector = item.json.optString("presentation") == "networkSelector" + val isHistory = variant == "history" || item.sectionKey?.startsWith("history-") == true + val isTokenManager = item.sectionKey in setOf("linear-tokens", "action-tokens") +- val hasDottedTitle = isSummary || isNetworkSelector || ++ val isExplicitNetworkHeader = isNetworkSelector && item.json.has("height") ++ val hasDottedTitle = isSummary || (isNetworkSelector && (!isExplicitNetworkHeader || item.json.optString("titleActionKey").isNotEmpty())) || + (item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null) + // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView + // level, so header-local insets below are source px minus that outer inset. +@@ -1870,7 +2170,8 @@ internal class NativeListRowView( + ), + ) + if (isNetworkSelector) { +- setPadding(dp(headerHorizontalInset), dp(12), dp(headerHorizontalInset), dp(12)) ++ val verticalInset = if (isExplicitNetworkHeader && item.json.optString("titleActionKey").isEmpty()) 8 else 12 ++ setPadding(dp(headerHorizontalInset), dp(verticalInset), dp(headerHorizontalInset), dp(verticalInset)) + title.textSize = sp(14f) + title.typeface = NativeListFonts.medium(context) + TextViewCompat.setLineHeight(title, dp(20)) +@@ -1924,14 +2225,22 @@ internal class NativeListRowView( + item.json.optString("valueActionKey"), + color(theme, "secondaryText", "#0000009B"), + ) ++ trailingViews[0].setTag(com.facebook.react.R.id.react_test_id, item.json.optString("valueActionTestID").takeIf { it.isNotEmpty() }) ++ trailingViews[0].accessibilityDelegate = selectorAccessibilityDelegate + trailingViews[0].textSize = sp(16f) + trailingViews[0].typeface = NativeListFonts.medium(context) + TextViewCompat.setLineHeight(trailingViews[0], dp(24)) +- trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) ++ // OneKey patch: the migrated media button shares the original 24-point text box. ++ if (isExplicitNetworkHeader) trailingViews[0].setPadding(0, 0, 0, 0) ++ else trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) + trailingViews[0].layoutParams = wrap() + } else if (!isGallery && !isHistory && !isTokenManager) { + val value = item.json.optString("value") + val checkboxData = item.json.optJSONObject("checkbox") ++ if (isExplicitNetworkHeader && checkboxData != null) { ++ // OneKey patch: the asset section title reserves its original 8dp trailing margin. ++ (mainColumn.layoutParams as LayoutParams).marginEnd = dp(8) ++ } + if (checkboxData != null && value.isNotEmpty()) { + // Value and checkbox share the trailing edge as one compound accessory. + trailingColumn.orientation = HORIZONTAL +@@ -1966,6 +2275,27 @@ internal class NativeListRowView( + } + checkboxData?.let { bindCheckbox(item, it, checkboxState) } + } ++ applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) ++ } ++ ++ // OneKey patch: preserve compact zero-count digits without changing their baseline. ++ private fun applyValueSegments(view: TextView, segments: JSONArray?, fontSize: Int = 16, lineHeight: Int = 24, medium: Boolean = true) { ++ if (segments == null || segments.length() == 0) return ++ val value = SpannableStringBuilder() ++ for (index in 0 until segments.length()) { ++ val segment = segments.getJSONObject(index) ++ val start = value.length ++ value.append(segment.optString("text")) ++ if (segment.optString("style") == "subscript") { ++ val size = kotlin.math.ceil(fontSize * 0.6).toFloat() ++ val span = if (selectorUsesSourceScale) AbsoluteSizeSpan(kotlin.math.ceil((size * resources.displayMetrics.density).toDouble()).toInt(), false) else AbsoluteSizeSpan(sp(size).roundToInt(), true) ++ value.setSpan(span, start, value.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) ++ } ++ } ++ view.textSize = sp(fontSize.toFloat()) ++ view.typeface = if (medium) NativeListFonts.medium(context) else NativeListFonts.regular(context) ++ TextViewCompat.setLineHeight(view, dp(lineHeight)) ++ view.text = value + } + + private fun bindAction( +@@ -1978,12 +2308,13 @@ internal class NativeListRowView( + addLeading(icon, if (isAccountSelector) 32 else 40) + leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) + if (!icon.has("backgroundColor")) leadingFrame.background = null ++ if (isAccountSelector) leadingFrame.background = roundedFill(safeColor(icon.optString("backgroundColor"), color(theme, "strongBackground", "#0000000F")), 8f) + } + addView(mainColumn, weighted()) + showText(title, item.json.optString("title"), 1) + if (isAccountSelector) { +- title.typeface = NativeListFonts.regular(context) +- title.setTextColor(color(theme, "secondaryText", "#0000009B")) ++ title.typeface = if (item.json.has("icon")) NativeListFonts.medium(context) else NativeListFonts.regular(context) ++ title.setTextColor(color(theme, if (item.json.optString("tone") == "primary") "primaryText" else "secondaryText", "#0000009B")) + } else if (item.json.optString("tone") == "danger") { + title.setTextColor(color(theme, "negative", "#C40006D3")) + } +@@ -2003,6 +2334,17 @@ internal class NativeListRowView( + + private fun bindSystem(item: NativeListItem, theme: JSONObject?) { + val variant = item.json.optString("variant") ++ // OneKey patch: warning title/description wrap inside the actual scroll content. ++ if (variant == "warning") { ++ setPadding(dp(12), dp(14), dp(12), dp(14)) ++ addView(mainColumn, weighted()) ++ showText(title, item.json.optString("title"), Int.MAX_VALUE) ++ showText(subtitle, item.json.optString("message"), Int.MAX_VALUE) ++ title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) ++ subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) } ++ titleLine.packsChildrenAtStart = false ++ return ++ } + if (variant == "spacer") { + minimumHeight = dp(item.json.optInt("height", 0)) + return +@@ -2072,7 +2414,11 @@ internal class NativeListRowView( + spacingDp: Int = 12, + ) { + leadingFrame.visibility = VISIBLE +- leadingFrame.layoutParams = LayoutParams(dp(sizeDp), dp(sizeDp)).apply { marginEnd = dp(spacingDp) } ++ leadingFrame.layoutParams = LayoutParams(dp(sizeDp), dp(sizeDp)).apply { ++ val item = tag as? NativeListItem ++ // OneKey patch: Yoga rounds cumulative selector edges, not each 12dp gap separately. ++ marginEnd = if (item?.json?.has("height") == true && item.json.optString("presentation") in setOf("accountSelector", "networkSelector")) dp(12 + sizeDp + spacingDp) - dp(12) - dp(sizeDp) else dp(spacingDp) ++ } + addView(leadingFrame) + leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) + if (visual == null) return +@@ -2104,7 +2450,7 @@ internal class NativeListRowView( + leadingFrame.background = GradientDrawable().apply { + setColor(visualBackground) + setStroke(1, parseNativeListColor("#0000001F")) +- cornerRadius = NativeListScale.dp(resources, leadingCornerRadius(shape, sizeDp)) ++ cornerRadius = scaledDp(leadingCornerRadius(shape, sizeDp)) + } + leadingIcon.iconName = visual.optString("name") + leadingIcon.tintColor = safeColor( +@@ -2161,7 +2507,89 @@ internal class NativeListRowView( + else -> leadingOutlineProvider(shape) + } + image.clipToOutline = true +- bindImage(source, image, boundKey ?: "", index, variant) ++ val fallbackIcon = if (index == 0) visual.optJSONObject("fallbackIcon") else null ++ val expectedEpoch = bindingEpoch ++ bindImage(source, image, boundKey ?: "", index, variant, ++ onLoad = if (fallbackIcon == null) null else ({ ++ if (bindingEpoch == expectedEpoch) { image.visibility = VISIBLE; leadingIcon.visibility = GONE } ++ }), ++ onError = if (fallbackIcon == null) null else ({ ++ if (bindingEpoch == expectedEpoch) { ++ image.visibility = GONE ++ leadingFallback.visibility = GONE ++ leadingIcon.iconName = fallbackIcon.optString("name") ++ leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B")) ++ leadingIcon.visibility = VISIBLE ++ } ++ }), ++ ) ++ } ++ // OneKey patch: wallet overlays retain source images, provider colors and QR text. ++ visual.optJSONArray("overlays")?.let { overlays -> ++ for (index in 0 until overlays.length()) { ++ val overlay = overlays.getJSONObject(index) ++ val size = overlay.optInt("size", 20) ++ val inset = dp(overlay.optInt("padding", 0)) ++ val isWalletText = selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && overlay.optString("text").isNotEmpty() && overlay.optJSONObject("image") == null && overlay.optString("name").isEmpty() ++ val width = overlay.optInt("width", size) ++ val height = overlay.optInt("height", if (isWalletText) 16 else size) ++ val offsetX = dp(overlay.optInt("offsetX", overlay.optInt("offset", 2))) ++ val offsetY = dp(overlay.optInt("offsetY", overlay.optInt("offset", 2))) ++ val frame = FrameLayout(context).apply { ++ setPadding(if (isWalletText) dp(2) else inset, if (isWalletText) 0 else inset, if (isWalletText) dp(2) else inset, if (isWalletText) 0 else inset) ++ background = roundedFill(safeColor(overlay.optString("backgroundColor"), Color.TRANSPARENT), minOf(width, height) / 2f) ++ outlineProvider = ViewOutlineProvider.BACKGROUND ++ clipToOutline = true ++ } ++ val image = overlay.optJSONObject("image") ++ val view = when { ++ image != null -> OneKeyImageReusableView(reactContext).also { ++ bindImage(image, it, boundKey ?: "", 10 + index, "generic") ++ selectorImages.add(it) ++ } ++ overlay.optString("text").isNotEmpty() -> TextView(context).apply { ++ text = overlay.optString("text") ++ textSize = sp(if (isWalletText) 12f else 10f) ++ typeface = if (isWalletText) NativeListFonts.regular(context) else NativeListFonts.medium(context) ++ includeFontPadding = false ++ gravity = Gravity.CENTER ++ if (isWalletText) TextViewCompat.setLineHeight(this, dp(16)) ++ setTextColor(safeColor(overlay.optString("tintColor"), color(null, "secondaryText", "#0000009B"))) ++ } ++ else -> OneKeyIconView(context).apply { ++ iconName = overlay.optString("name") ++ tintColor = safeColor(overlay.optString("tintColor"), parseNativeListColor("#0000009B")) ++ } ++ } ++ frame.addView(view, FrameLayout.LayoutParams(if (isWalletText && !overlay.has("width")) FrameLayout.LayoutParams.WRAP_CONTENT else FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) ++ val topLeft = overlay.optString("position") == "topLeft" ++ leadingFrame.addView(frame, FrameLayout.LayoutParams(if (isWalletText && !overlay.has("width")) FrameLayout.LayoutParams.WRAP_CONTENT else dp(width), dp(height), if (topLeft) Gravity.START or Gravity.TOP else Gravity.END or Gravity.BOTTOM).apply { ++ if (topLeft) { marginStart = -offsetX; topMargin = -offsetY } else { marginEnd = -offsetX; bottomMargin = -offsetY } ++ }) ++ selectorViews.add(frame) ++ } ++ } ++ visual.optJSONObject("fallbackIcon")?.takeIf { sources.isEmpty() }?.let { fallbackIcon -> ++ leadingFallback.visibility = GONE ++ leadingIcon.visibility = VISIBLE ++ leadingIcon.iconName = fallbackIcon.optString("name") ++ leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B")) ++ if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && leadingIcon.iconName == "PlusSmallOutline") { ++ leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) ++ leadingIcon.glyphSizeDp = 24 ++ } ++ if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && leadingIcon.iconName == "LockSolid") { ++ leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(40), dp(40), Gravity.CENTER) ++ leadingIcon.glyphSizeDp = 40 ++ } ++ } ++ if (visual.optString("borderStyle") == "dashed") { ++ leadingFrame.background = GradientDrawable().apply { ++ setColor(visualBackground) ++ cornerRadius = dp(sizeDp / 2).toFloat() ++ setStroke(dp(if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar") 1 else 2), safeColor(visual.optString("borderColor"), parseNativeListColor("#00000072")), dp(4).toFloat(), dp(4).toFloat()) ++ } ++ leadingFallback.background = null + } + } + +@@ -2223,7 +2651,11 @@ internal class NativeListRowView( + for (index in 0 until minOf(2, accessories.length())) { + val accessory = accessories.getJSONObject(index) + when (accessory.optString("kind")) { +- "value" -> showTrailing(textIndex++, accessory.optString("text"), !accessory.optBoolean("secondary", false)) ++ "value" -> { ++ showTrailing(textIndex, accessory.optString("text"), !accessory.optBoolean("secondary", false)) ++ applyValueSegments(trailingViews[textIndex], accessory.optJSONArray("textSegments")) ++ textIndex++ ++ } + "valuePair" -> showTrailingValuePair(textIndex++, accessory, theme) + "checkbox" -> bindCheckbox(item, accessory, checkboxState) + "radio" -> showTrailing( +@@ -2285,11 +2717,25 @@ internal class NativeListRowView( + return + } + checkbox.visibility = VISIBLE +- checkbox.setState(state, checkboxUncheckedColor) +- checkbox.background = if (state == "unchecked") { +- roundedStroke(checkboxBorderColor, checkboxUncheckedColor, 4f) ++ checkbox.setState(state, checkboxIconColor) ++ val usesSourceCheckboxGeometry = checkboxUsesSelectorStyle && item.json.has("height") ++ checkbox.usesSelectorGeometry = usesSourceCheckboxGeometry ++ if (usesSourceCheckboxGeometry) { ++ // OneKey patch: source Yoga children may round into padding; retain their complete border. ++ clipToPadding = false ++ // OneKey patch: even a transparent source border changes RN's background clipping path. ++ checkbox.background = null ++ BackgroundStyleApplicator.setBackgroundColor(checkbox, if (state == "unchecked") checkboxUncheckedColor else checkboxCheckedColor) ++ BackgroundStyleApplicator.setBorderWidth(checkbox, LogicalEdge.ALL, 2f) ++ BackgroundStyleApplicator.setBorderColor(checkbox, LogicalEdge.ALL, if (state == "unchecked") checkboxBorderColor else Color.TRANSPARENT) ++ BackgroundStyleApplicator.setBorderRadius(checkbox, BorderRadiusProp.BORDER_RADIUS, LengthPercentage(4f, LengthPercentageType.POINT)) + } else { +- roundedFill(checkboxCheckedColor, 4f) ++ checkbox.background = if (state == "unchecked") { ++ if (checkboxUsesSelectorStyle) GradientDrawable().apply { setColor(checkboxUncheckedColor); setStroke(dp(2), checkboxBorderColor); cornerRadius = scaledDp(4f) } ++ else roundedStroke(checkboxBorderColor, checkboxUncheckedColor, 4f) ++ } else { ++ roundedFill(checkboxCheckedColor, 4f) ++ } + } + // Row-level disabled opacity already applies to this child. Only apply a + // local 0.5 when the accessory alone is disabled, never 0.5 * 0.5. +@@ -2330,6 +2776,8 @@ internal class NativeListRowView( + } + val groupPosition = when { + item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> "single" ++ // OneKey patch: explicit selector rows preserve the v1 ListItem corner radius. ++ item.type == "identity" && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") && item.json.has("height") -> "single" + else -> when (item.type) { + "metricCard" -> "single" + "rail" -> "rail" +@@ -2337,7 +2785,7 @@ internal class NativeListRowView( + } + } + var backgroundGroupPosition = if (item.type == "mediaTile") "mediaTile" else groupPosition +- if (layout == "sectioned") { ++ if (layout == "sectioned" && !item.json.optBoolean("selected", false)) { + // Selection in sectioned lists is represented by the OneKey checkbox, + // matching iOS and the app-monorepo network selector. + rowBackground = color(theme, "rowBackground", "#FFFFFF") +@@ -2351,6 +2799,8 @@ internal class NativeListRowView( + rowBackground = color(theme, "subduedBackground", "#F9F9F9") + backgroundGroupPosition = "" + } ++ // OneKey patch: section heading backgrounds are independent of list rows. ++ if (item.json.has("backgroundColor")) rowBackground = safeColor(item.json.optString("backgroundColor"), rowBackground) + restingRowBackground = groupedBackground(backgroundGroupPosition, rowBackground) + background = restingRowBackground + } +@@ -2466,7 +2916,26 @@ internal class NativeListRowView( + val icon = trailingIcons[index] + icon.iconName = data.optString("name") + icon.tintColor = safeColor(data.optString("tintColor"), iconSubduedColor) +- if (icon.iconName == "ChevronRightSmallOutline") { ++ // OneKey patch: preserve the original 38dp press target around its 24dp layout slot. ++ icon.setTag(com.facebook.react.R.id.react_test_id, data.optString("testID").takeIf { it.isNotEmpty() }) ++ icon.accessibilityDelegate = selectorAccessibilityDelegate ++ icon.contentDescription = data.optString("accessibilityLabel").takeIf { it.isNotEmpty() } ++ if (item.json.optString("presentation") == "accountSelector") { ++ icon.glyphSizeDp = 24 ++ val isSourceMenu = item.json.has("height") && icon.iconName == "DotHorOutline" ++ val size = if (isSourceMenu) 24 else if (item.json.has("height") && icon.iconName == "PlusSmallOutline") 36 else 38 ++ icon.layoutParams = LayoutParams(dp(size), dp(size)).apply { ++ gravity = Gravity.CENTER_VERTICAL ++ if (!isSourceMenu) { ++ marginStart = -dp(7) ++ marginEnd = -dp(7) ++ } ++ } ++ if (isSourceMenu) { ++ // OneKey patch: the native ActionList trigger measures 24dp; Yoga rounds its trailing edge cumulatively. ++ setPadding(paddingLeft, paddingTop, (12 * resources.displayMetrics.density).toInt(), paddingBottom) ++ } ++ } else if (icon.iconName == "ChevronRightSmallOutline") { + icon.glyphSizeDp = null + // ListItem.DrillIn is a 24dp icon with mx=-6, for a 12dp layout footprint. + icon.layoutParams = LayoutParams(dp(24), dp(24)).apply { +@@ -2516,7 +2985,7 @@ internal class NativeListRowView( + when (item.type) { + "message" -> 14f + "sectionHeader" -> when { +- isNetworkSelectorSection -> 14f ++ isNetworkSelectorSection && item.json.optString("variant") != "summary" -> 14f + item.json.optString("variant") == "gallery" -> 18f + item.json.optString("variant") == "summary" -> 16f + currentLayout == "table" -> 11f +@@ -2530,11 +2999,14 @@ internal class NativeListRowView( + } + }, + ) +- title.typeface = if (isWalletSidebar || isAccountSelectorIdentity || isAccountSelectorAction) { ++ title.typeface = if (isAccountSelectorAction && item.json.has("icon")) { ++ NativeListFonts.medium(context) ++ } else if (isWalletSidebar || isAccountSelectorIdentity || isAccountSelectorAction) { + NativeListFonts.regular(context) + } else { + when (item.type) { + "sectionHeader" -> when { ++ isNetworkSelectorSection && item.json.has("height") && item.json.optString("variant") != "summary" && (item.json.optJSONObject("checkbox") != null || item.json.optString("titleActionKey").isEmpty()) -> NativeListFonts.semibold(context) + isNetworkSelectorSection -> NativeListFonts.medium(context) + currentLayout == "table" -> NativeListFonts.regular(context) + item.json.optString("variant") == "summary" -> NativeListFonts.medium(context) +@@ -2552,7 +3024,7 @@ internal class NativeListRowView( + else -> 14f + }) + tertiary.textSize = sp(14f) +- if (isNetworkSelectorSection) { ++ if (isNetworkSelectorSection && item.json.optString("variant") != "summary") { + TextViewCompat.setLineHeight(title, dp(20)) + } else if (item.type == "sectionHeader" && item.json.optString("variant") == "gallery") { + TextViewCompat.setLineHeight(title, dp(24)) +@@ -2589,11 +3061,42 @@ internal class NativeListRowView( + column.typeface = NativeListFonts.medium(context) + column.fontFeatureSettings = "tnum" + } ++ // OneKey patch: exact selector row dimensions override template minimums. ++ selectorHeight = if (item.json.has("height")) dp(item.json.optInt("height")) else null ++ // OneKey patch: React Native's section spacers and letter blocks truncate physical heights. ++ val isSelectorLetter = isNetworkSelectorSection && item.json.has("height") && ++ item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isEmpty() && ++ item.json.optJSONObject("checkbox") == null ++ val isSelectorSectionSpacer = selectorUsesSourceScale && currentLayout == "sectioned" && ++ item.type == "system" && item.json.optString("variant") == "spacer" ++ if (isSelectorLetter || isSelectorSectionSpacer) { ++ selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() ++ } ++ if (isSelectorLetter) { ++ val inset = (20 * resources.displayMetrics.density).toInt() - dp(8) ++ // OneKey patch: SectionHeader has a fixed height and centered text, without vertical padding. ++ setPadding(inset, 0, inset, 0) ++ } ++ if (item.json.has("height") && isNetworkSelectorSection && item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isNotEmpty() && item.json.optJSONObject("checkbox") == null) { ++ // OneKey patch: the text-and-underline header's fractional measured height rounds up in React Native. ++ selectorHeight = kotlin.math.ceil((item.json.optInt("height") * (if (selectorUsesSourceScale) 1f else NativeListScale.factor(resources)) * resources.displayMetrics.density).toDouble()).toInt() ++ } ++ // OneKey patch: an explicit per-row policy preserves each source list's measured heights. ++ if (item.json.has("height")) { ++ when (item.json.optString("heightRounding")) { ++ "floor" -> selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() ++ "nearest" -> selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).roundToInt() ++ } ++ } + val baseHeight = when { ++ item.json.has("height") -> item.json.optInt("height") + item.type == "system" && item.json.optString("variant") == "spacer" -> item.json.optInt("height", 0) + item.type == "walletGroup" -> { + val childCount = item.json.optJSONArray("children")?.length() ?: 0 +- (childCount + 1) * 68 + childCount * 12 ++ // OneKey patch: include badge heights in the group layout. ++ // (childCount + 1) * 68 + childCount * 12 ++ val members = listOf(item.json.getJSONObject("parent")) + (0 until childCount).map { item.json.getJSONArray("children").getJSONObject(it) } ++ members.sumOf { it.optInt("height", if ((it.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) } + childCount * 12 + if (members.first().has("height")) 2 else 0 + } + isNetworkSelectorIdentity -> 47 + else -> when (item.type) { +@@ -2617,6 +3120,7 @@ internal class NativeListRowView( + else -> 36 + } + "system" -> when (item.json.optString("variant")) { ++ "warning" -> 0 + "noMatch", "end" -> 36 + "retry" -> 44 + else -> 56 +@@ -2636,14 +3140,14 @@ internal class NativeListRowView( + 56 + } + else -> when { +- item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> 68 ++ item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68 + item.type == "identity" && item.json.optString("tertiary").isNotEmpty() -> 72 + item.type == "identity" && item.json.optString("subtitle").isNotEmpty() -> 60 + else -> 56 + } + } + } +- val modifier = if (isNetworkSelectorIdentity) { ++ val modifier = if (item.json.has("height") || isNetworkSelectorIdentity) { + 0 + } else if ( + item.type == "sectionHeader" && item.json.optString("variant") in listOf("summary", "gallery") +@@ -2713,7 +3217,12 @@ internal class NativeListRowView( + override fun getOutline(view: View, outline: Outline) { + when (shape) { + "square" -> outline.setRect(0, 0, view.width, view.height) +- "rounded" -> outline.setRoundRect(0, 0, view.width, view.height, dp(10).toFloat()) ++ "rounded" -> { ++ // OneKey patch: the account avatar has an 8-point radius at its 32-point size. ++ // outline.setRoundRect(0, 0, view.width, view.height, dp(10).toFloat()) ++ val radius = if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && (tag as? NativeListItem)?.json?.has("height") == true) dp(8).toFloat() else dp(10).toFloat() ++ outline.setRoundRect(0, 0, view.width, view.height, radius) ++ } + else -> outline.setOval(0, 0, view.width, view.height) + } + } +@@ -2742,7 +3251,13 @@ internal class NativeListRowView( + token: String, + slot: Int, + variant: String, ++ onLoad: (() -> Unit)? = null, ++ onError: (() -> Unit)? = null, ++ retryAttempt: Int = 0, + ) { ++ selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks) ++ val expectedEpoch = bindingEpoch ++ val retryLimit = source.optInt("retryTimes", 0).coerceAtLeast(0) + val uri = source.optString("uri").trim().takeIf(String::isNotEmpty) + imageView.configure( + sourceUri = uri, +@@ -2751,17 +3266,39 @@ internal class NativeListRowView( + contentFit = source.optString("contentFit", "cover"), + cachePolicy = source.optString("cachePolicy", "memory-disk"), + autoplay = source.optBoolean("autoplay", false), +- recyclingKey = "$token:$slot", +- optimizeTos = source.optBoolean("optimizeTos", true), ++ recyclingKey = if (retryAttempt == 0) "$token:$slot" else "$token:$slot:retry:$retryAttempt", ++ optimizeTos = retryAttempt == 0 && source.optBoolean("optimizeTos", true), + overscan = source.optDouble("overscan", 1.1), + loadingStrategy = source.optString("loadingStrategy", "static"), ++ onLoad = if (retryLimit == 0) onLoad else ({ ++ if (bindingEpoch == expectedEpoch) { ++ selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks) ++ onLoad?.invoke() ++ } ++ }), ++ onError = if (retryLimit == 0) onError else ({ ++ if (bindingEpoch == expectedEpoch) { ++ if (retryAttempt >= retryLimit) onError?.invoke() ++ else if (!selectorImageRetries.containsKey(imageView)) { ++ val retry = Runnable { ++ if (bindingEpoch == expectedEpoch) { ++ selectorImageRetries.remove(imageView) ++ bindImage(source, imageView, token, slot, variant, onLoad, onError, retryAttempt + 1) ++ } ++ } ++ selectorImageRetries[imageView] = retry ++ imageView.postDelayed(retry, kotlin.random.Random.nextLong(3) * 1000L) ++ } ++ } ++ }), + ) + } + + private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) + private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) +- private fun dp(value: Int): Int = NativeListScale.dp(resources, value) +- private fun sp(value: Float): Float = NativeListScale.font(resources, value) ++ private fun dp(value: Int): Int = if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) ++ private fun scaledDp(value: Float): Float = if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt().toFloat() else NativeListScale.dp(resources, value) ++ private fun sp(value: Float): Float = if (selectorUsesSourceScale) value else NativeListScale.font(resources, value) + + private fun color(theme: JSONObject?, key: String, fallback: String): Int = + safeColor(theme?.optString(key, fallback), parseNativeListColor(fallback)) +@@ -2774,30 +3311,30 @@ internal class NativeListRowView( + + private fun roundedFill(color: Int, radiusDp: Float) = GradientDrawable().apply { + setColor(color) +- cornerRadius = NativeListScale.dp(resources, radiusDp) ++ cornerRadius = scaledDp(radiusDp) + } + + private fun roundedStroke(stroke: Int, fill: Int, radiusDp: Float) = GradientDrawable().apply { + setColor(fill) + setStroke(dp(2), stroke) +- cornerRadius = NativeListScale.dp(resources, radiusDp) ++ cornerRadius = scaledDp(radiusDp) + } + + private fun roundedHairlineStroke(stroke: Int, radiusDp: Float) = GradientDrawable().apply { + setColor(Color.TRANSPARENT) + setStroke(1, stroke) +- cornerRadius = NativeListScale.dp(resources, radiusDp) ++ cornerRadius = scaledDp(radiusDp) + } + + private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply { + setColor(color) +- val radius = NativeListScale.dp(resources, 12f) ++ val radius = scaledDp(12f) + cornerRadii = when (position) { + "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) + "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) + "single" -> FloatArray(8) { radius } +- "rail" -> FloatArray(8) { NativeListScale.dp(resources, 8f) } +- "mediaTile" -> FloatArray(8) { NativeListScale.dp(resources, 16f) } ++ "rail" -> FloatArray(8) { scaledDp(8f) } ++ "mediaTile" -> FloatArray(8) { scaledDp(16f) } + else -> FloatArray(8) + } + } +@@ -2805,6 +3342,7 @@ internal class NativeListRowView( + } + + private class OneKeyIconView(context: android.content.Context) : View(context) { ++ var useSourceScale = false + var iconName: String = "" + set(value) { + field = value +@@ -2828,9 +3366,11 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { + val pathData = iconPaths[iconName] ?: return + val drawSize = minOf( + minOf(width, height).toFloat(), +- glyphSizeDp?.let { NativeListScale.dp(resources, it).toFloat() } ?: Float.MAX_VALUE, ++ glyphSizeDp?.let { if (useSourceScale) (it * resources.displayMetrics.density).roundToInt().toFloat() else NativeListScale.dp(resources, it).toFloat() } ?: Float.MAX_VALUE, + ) +- val scale = drawSize / 24f ++ // OneKey patch: custom account-error artwork uses an 18-point viewBox. ++ // val scale = drawSize / 24f ++ val scale = drawSize / (selectorIconViewBoxes[iconName] ?: 24f) + fill.color = tintColor + canvas.save() + canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) +@@ -2839,6 +3379,8 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { + pathData.forEachIndexed { index, data -> + PathParser.createPathFromPathData(data)?.let { path -> + path.fillType = sourceFillTypes?.getOrNull(index) ?: Path.FillType.EVEN_ODD ++ // OneKey patch: provider illustration colors are part of their source asset. ++ fill.color = selectorIconColors[iconName]?.getOrNull(index) ?: tintColor + canvas.drawPath(path, fill) + } + } +@@ -2846,10 +3388,42 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { + } + + companion object { ++ // OneKey patch: preserve provider colors and non-24 viewBoxes. ++ private val selectorIconColors: Map> = mapOf( ++ "GlobusOutline" to listOf(null), ++ "LockSolid" to listOf(null), ++ "GoogleIllus" to listOf(parseNativeListColor("#4285F4"), parseNativeListColor("#34A853"), parseNativeListColor("#FBBC05"), parseNativeListColor("#EA4335")), ++ "AppleBrand" to listOf(null), ++ "BotIllus" to listOf(parseNativeListColor("#8897A5"), parseNativeListColor("#3FA9F5"), parseNativeListColor("#8897A5"), parseNativeListColor("#8897A5"), parseNativeListColor("#10243E"), parseNativeListColor("#10243E"), parseNativeListColor("#10243E")), ++ "AllNetworksSolid" to listOf(null, null), ++ "CrossedSmallSolid" to listOf(null), ++ "AccountErrorCustom" to listOf(Color.argb(0x72, 0, 0, 0), Color.argb(0x72, 0, 0, 0)), ++ "Circle" to listOf(null), ++ ) ++ private val selectorIconViewBoxes = mapOf( ++ "GlobusOutline" to 24f, ++ "LockSolid" to 24f, ++ "GoogleIllus" to 24f, ++ "AppleBrand" to 16f, ++ "BotIllus" to 24f, ++ "AllNetworksSolid" to 24f, ++ "CrossedSmallSolid" to 24f, ++ "AccountErrorCustom" to 18f, ++ "Circle" to 24f, ++ ) + // Keep the SVG fill-rule used by each Action-row source path. React Native + // SVG defaults to nonzero (WINDING); only paths declaring fillRule="evenodd" + // use EVEN_ODD. Other existing icons retain their prior rendering behavior. + private val actionIconFillTypes = mapOf( ++ "GlobusOutline" to listOf(Path.FillType.EVEN_ODD), ++ "LockSolid" to listOf(Path.FillType.EVEN_ODD), ++ "GoogleIllus" to listOf(Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING), ++ "AppleBrand" to listOf(Path.FillType.WINDING), ++ "BotIllus" to listOf(Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING), ++ "AllNetworksSolid" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), ++ "CrossedSmallSolid" to listOf(Path.FillType.WINDING), ++ "AccountErrorCustom" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), ++ "Circle" to listOf(Path.FillType.WINDING), + "ChevronRightSmallOutline" to listOf(Path.FillType.WINDING), + "MinusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), + "PlusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), +@@ -2867,6 +3441,16 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { + + // Exact 24x24 paths from app-monorepo packages/components Icon sources. + private val iconPaths = mapOf( ++ // OneKey patch: official selector SVG path geometry. ++ "GlobusOutline" to listOf("M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361"), ++ "LockSolid" to listOf("M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3"), ++ "GoogleIllus" to listOf("M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09", "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23", "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z", "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53"), ++ "AppleBrand" to listOf("M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z"), ++ "BotIllus" to listOf("M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z", "M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z", "M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", "M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", "M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", "M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", "M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06"), ++ "AllNetworksSolid" to listOf("M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67", "M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z"), ++ "CrossedSmallSolid" to listOf("M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z"), ++ "AccountErrorCustom" to listOf("M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5", "M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5"), ++ "Circle" to listOf("M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0"), + "ArrowBottomOutline" to listOf("m13 17.586 5-5L19.414 14 12 21.414 4.586 14 6 12.586l5 5V3h2z"), + "ArrowTopOutline" to listOf("M19.414 10 18 11.414l-5-5V21h-2V6.414l-5 5L4.586 10 12 2.586z"), + "ChartTrendingUpOutline" to listOf("M22 13h-2V9.414l-7 7-4-4-6 6L1.586 17 9 9.586l4 4L18.586 8H15V6h7z"), +@@ -2910,6 +3494,7 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { + } + + private class OneKeyCheckboxView(context: android.content.Context) : View(context) { ++ var usesSelectorGeometry = false + private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } + private var state = "unchecked" + +@@ -2926,9 +3511,11 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex + "indeterminate" -> "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1" + else -> return + } +- val drawSize = minOf(width, height) * 0.8f ++ // OneKey patch: the source icon is a 16dp child after the 2dp border, not 80% of a rounded frame. ++ val drawSize = if (usesSelectorGeometry) (16 * resources.displayMetrics.density).roundToInt().toFloat() else minOf(width, height) * 0.8f ++ val borderOffset = (2 * resources.displayMetrics.density).roundToInt().toFloat() + canvas.save() +- canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) ++ canvas.translate(if (usesSelectorGeometry) borderOffset else (width - drawSize) / 2f, if (usesSelectorGeometry) borderOffset else (height - drawSize) / 2f) + canvas.scale(drawSize / 16f, drawSize / 16f) + PathParser.createPathFromPathData(pathData)?.let { canvas.drawPath(it, glyphPaint) } + canvas.restore() +diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +index f0af895..ec5c3fe 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt ++++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +@@ -102,6 +102,7 @@ class NativeListView( + private val sectionIndexView = NativeListSectionIndexView(context) + private val sectionIndexPreview = TextView(context) + private var config: NativeListConfig? = null ++ private var usesSelectorSourceScale = false + private var stickyDecoration: StickySectionHeaderDecoration? = null + private var spacingDecoration: ItemSpacingDecoration? = null + private var itemTouchHelper: ItemTouchHelper? = null +@@ -133,6 +134,8 @@ class NativeListView( + recyclerView.layoutManager = layoutManager + recyclerView.itemAnimator = null + recyclerView.addItemDecoration(reorderPlaceholderDecoration) ++ // OneKey patch: full-width selector header backgrounds do not change row content insets. ++ recyclerView.addItemDecoration(SelectorBackgroundDecoration(adapter)) + recyclerView.setHasFixedSize(false) + layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + override fun getSpanSize(position: Int): Int { +@@ -244,17 +247,29 @@ class NativeListView( + invalidateActionAnchor("snapshot") + val previous = config + if (previous != null && canApplyStableContentUpdate(previous, next)) { ++ // OneKey patch: authorize a diff payload only against this exact validated baseline. ++ next.items.forEachIndexed { index, item -> ++ if (item.content != previous.items[index].content && ++ adapter.currentList.getOrNull(index) === previous.items[index] ++ ) { ++ item.selectionUpdateFromContent = previous.items[index].content ++ } ++ } + val changedSummaryKeys = previous.items.indices.mapNotNull { index -> + previous.items[index].key.takeIf { + previous.items[index].content != next.items[index].content + } + }.toSet() + config = next ++ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } ++ adapter.usesSelectorSourceScale = usesSelectorSourceScale + adapter.theme = next.theme + adapter.layout = next.layout + adapter.orientation = next.orientation + adapter.selectedKeys = next.selectedKeys + adapter.submitList(next.items) { ++ // OneKey patch: DiffUtil has dispatched its payloads; release the old serialized rows. ++ next.items.forEach { it.selectionUpdateFromContent = null } + recyclerView.post { + bindVisibleSelection(changedSummaryKeys) + bindFooterSelection() +@@ -264,6 +279,8 @@ class NativeListView( + return + } + config = next ++ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } ++ adapter.usesSelectorSourceScale = usesSelectorSourceScale + endReachedGeneration = null + pendingReorder = null + adapter.theme = next.theme +@@ -327,30 +344,98 @@ class NativeListView( + val newItem = next.items[index] + oldItem.key == newItem.key && + oldItem.type == newItem.type && +- (oldItem.content == newItem.content || isStableSummaryUpdate(oldItem, newItem)) ++ // OneKey patch: controlled echoes can also carry row and checkbox selection state. ++ // (oldItem.content == newItem.content || isStableSummaryUpdate(oldItem, newItem)) ++ (oldItem.content == newItem.content || isStableSelectionUpdate( ++ oldItem, ++ newItem, ++ next.selectionMode == "single" || next.selectionMode == "multiple", ++ )) + } + } + +- private fun isStableSummaryUpdate( ++ // OneKey patch: preserve the original summary-only comparison for upstream reference. ++ // private fun isStableSummaryUpdate( ++ // previous: NativeListItem, ++ // next: NativeListItem, ++ // ): Boolean { ++ // if ( ++ // previous.type != "sectionHeader" || ++ // previous.json.optString("variant") != "summary" || ++ // next.json.optString("variant") != "summary" ++ // ) { ++ // return false ++ // } ++ // val previousStructure = JSONObject(previous.content).apply { ++ // remove("title") ++ // remove("value") ++ // } ++ // val nextStructure = JSONObject(next.content).apply { ++ // remove("title") ++ // remove("value") ++ // } ++ // return previousStructure.toString() == nextStructure.toString() ++ // } ++ ++ private fun isStableSelectionUpdate( + previous: NativeListItem, + next: NativeListItem, ++ controlled: Boolean, + ): Boolean { +- if ( +- previous.type != "sectionHeader" || +- previous.json.optString("variant") != "summary" || +- next.json.optString("variant") != "summary" +- ) { +- return false ++ val previousStructure = selectionComparisonData(previous.json, controlled) ?: return false ++ val nextStructure = selectionComparisonData(next.json, controlled) ?: return false ++ return previousStructure.toString() == nextStructure.toString() ++ } ++ ++ // OneKey patch: ignore only fields refreshed by the lightweight selection binder. ++ private fun selectionComparisonData(data: JSONObject, controlled: Boolean): JSONObject? { ++ val type = data.opt("type") as? String ?: return null ++ val result = JSONObject(data.toString()) ++ if (result.has("selected")) { ++ if (result.opt("selected") !is Boolean) return null ++ result.remove("selected") ++ } ++ if (type == "walletGroup") { ++ val parent = data.optJSONObject("parent") ?: return null ++ if (parent.optString("type") != "identity") return null ++ val children = data.optJSONArray("children") ?: return null ++ result.put("parent", selectionComparisonData(parent, false) ?: return null) ++ val normalizedChildren = JSONArray() ++ for (index in 0 until children.length()) { ++ val child = children.optJSONObject(index) ?: return null ++ if (child.optString("type") != "identity") return null ++ normalizedChildren.put(selectionComparisonData(child, false) ?: return null) ++ } ++ result.put("children", normalizedChildren) + } +- val previousStructure = JSONObject(previous.content).apply { +- remove("title") +- remove("value") ++ if (type == "sectionHeader" && data.optString("variant") == "summary") { ++ result.remove("title") ++ result.remove("value") + } +- val nextStructure = JSONObject(next.content).apply { +- remove("title") +- remove("value") ++ if (!controlled) return result ++ fun checkboxData(value: Any?): JSONObject? { ++ val checkbox = value as? JSONObject ?: return null ++ if (checkbox.optString("kind") != "checkbox") return null ++ if (checkbox.has("state") && checkbox.opt("state") !in setOf("checked", "unchecked", "indeterminate")) return null ++ checkbox.remove("state") ++ return checkbox + } +- return previousStructure.toString() == nextStructure.toString() ++ if (type in setOf("dataRow", "sectionHeader", "action") && result.has("checkbox")) { ++ result.put("checkbox", checkboxData(result.opt("checkbox")) ?: return null) ++ } ++ if (type == "identity" && result.has("trailing")) { ++ val accessories = result.optJSONArray("trailing") ?: return null ++ if (accessories.length() > 2) return null ++ var checkboxCount = 0 ++ for (index in 0 until accessories.length()) { ++ val accessory = accessories.optJSONObject(index) ?: return null ++ if (accessory.optString("kind") == "checkbox") { ++ if (++checkboxCount > 1) return null ++ accessories.put(index, checkboxData(accessory) ?: return null) ++ } ++ } ++ } ++ return result + } + + fun applyPatches(patchesJson: String) { +@@ -389,6 +474,8 @@ class NativeListView( + invalidateActionAnchor("snapshot") + val next = current.copy(items = nextItems, selectedKeys = selected) + config = next ++ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } ++ adapter.usesSelectorSourceScale = usesSelectorSourceScale + adapter.selectedKeys = selected + adapter.submitList(nextItems) { relayoutContents() } + bindFooter(next) +@@ -742,7 +829,7 @@ class NativeListView( + recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty() + + spacingDecoration?.let(recyclerView::removeItemDecoration) +- spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing)).also(recyclerView::addItemDecoration) ++ spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing), next.itemSpacing, density).also(recyclerView::addItemDecoration) + stickyDecoration?.let(recyclerView::removeItemDecoration) + stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) { + StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration) +@@ -846,6 +933,7 @@ class NativeListView( + null, + next.selectedKeys.contains(footer.key), + ::resolveCheckboxState, ++ useSourceScale = usesSelectorSourceScale, + ) + } + } +@@ -898,15 +986,17 @@ class NativeListView( + origin.sourceView.getLocationInWindow(location) + val record = ActionAnchorRecord(token, origin) + actionAnchor = record ++ // OneKey patch: selector menus anchor to the glyph slot, not its expanded press target. ++ val anchorInset = origin.anchorInsetPixels + return JSONObject() + .put("token", token) + .put( + "windowRect", + JSONObject() +- .put("x", location[0] / density) +- .put("y", location[1] / density) +- .put("width", origin.sourceView.width / density) +- .put("height", origin.sourceView.height / density), ++ .put("x", (location[0] + anchorInset) / density) ++ .put("y", (location[1] + anchorInset) / density) ++ .put("width", (origin.sourceView.width - anchorInset * 2) / density) ++ .put("height", (origin.sourceView.height - anchorInset * 2) / density), + ) + .put("source", origin.source) + .put("generation", generation) +@@ -1037,7 +1127,9 @@ class NativeListView( + current.theme, + current.layout, + position, +- current.selectedKeys.contains(item.key), ++ // OneKey patch: match the full binder when the snapshot carries explicit row selection. ++ // current.selectedKeys.contains(item.key), ++ item.json.optBoolean("selected", false) || current.selectedKeys.contains(item.key), + ::resolveCheckboxState, + ) + } +@@ -1297,12 +1389,14 @@ class NativeListView( + ?.let(recyclerView::getChildViewHolder) + ?.takeIf { holder -> + adapter.itemAt(holder.bindingAdapterPosition)?.let { item -> +- val holderLocation = IntArray(2) +- holder.itemView.getLocationOnScreen(holderLocation) +- item.isReorderable && ( +- item.type != "walletGroup" || +- event.rawY in holderLocation[1].toFloat()..(holderLocation[1] + dp(68)).toFloat() +- ) ++ // OneKey patch: any wallet group member can initiate the group drag. ++ // val holderLocation = IntArray(2) ++ // holder.itemView.getLocationOnScreen(holderLocation) ++ // item.isReorderable && ( ++ // item.type != "walletGroup" || ++ // event.rawY in holderLocation[1].toFloat()..(holderLocation[1] + dp(68)).toFloat() ++ // ) ++ item.isReorderable + } == true + } + if (candidate != null) handler.postDelayed(startDrag, REORDER_LONG_PRESS_MS) +@@ -1460,7 +1554,7 @@ class NativeListView( + ) + } + +- private fun dp(value: Int): Int = NativeListScale.dp(resources, value) ++ private fun dp(value: Int): Int = if (usesSelectorSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) + + companion object { + private const val REORDER_LONG_PRESS_MS = 200L +@@ -1697,7 +1791,11 @@ private class NativeListSectionIndexView( + } + } + +-private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() { ++private class ItemSpacingDecoration( ++ private val spacing: Int, ++ private val sourceSpacing: Int, ++ private val density: Float, ++) : RecyclerView.ItemDecoration() { + override fun getItemOffsets( + outRect: android.graphics.Rect, + view: View, +@@ -1706,7 +1804,14 @@ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.Ite + ) { + if (spacing <= 0) return + val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL +- if (horizontal) outRect.right = spacing else outRect.bottom = spacing ++ val item = view.tag as? NativeListItem ++ val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.json.has("height") ++ // OneKey patch: V1 measures the wallet and its bottom padding as one sortable row. ++ val itemSpacing = if (!horizontal && sourceWallet) { ++ val sourceHeight = item!!.json.optInt("height") ++ ((sourceHeight + sourceSpacing) * density).roundToInt() - (sourceHeight * density).roundToInt() ++ } else spacing ++ if (horizontal) outRect.right = itemSpacing else outRect.bottom = itemSpacing + } + } + +@@ -1733,22 +1838,32 @@ private class StickySectionHeaderDecoration( + for (index in first downTo 0) { + val candidate = adapter.itemAt(index) + if (candidate?.type == "sectionHeader") { +- if (candidate.json.optString("variant") == "summary") continue ++ if (candidate.json.optString("variant") == "summary" || !candidate.json.optBoolean("sticky", true)) continue + header = candidate.takeIf(::isSimpleStickySectionHeader) + break + } + } + val item = header ?: return ++ // OneKey patch: a pinned selector heading must use the same text rasterization as its row. ++ textPaint.flags = if (item.usesSelectorSourceScale) Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG else Paint.ANTI_ALIAS_FLAG + val isHistory = item.json.optString("variant") == "history" || + item.sectionKey?.startsWith("history-") == true +- val height = NativeListScale.dp(context.resources, if (isHistory) 16 else 36) +- val textSize = NativeListScale.font(context.resources, if (isHistory) 12f else 14f) +- textPaint.textSize = textSize * density +- val horizontalInset = NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat() ++ val sourceHeight = item.json.optInt("height", 36) * density ++ val height = if (item.usesSelectorSourceScale) { ++ if (item.json.optString("heightRounding") == "nearest") sourceHeight.roundToInt() else sourceHeight.toInt() ++ } else NativeListScale.dp(context.resources, if (isHistory) 16 else 36) ++ val textSize = if (item.usesSelectorSourceScale) 14f else NativeListScale.font(context.resources, if (isHistory) 12f else 14f) ++ textPaint.textSize = if (item.usesSelectorSourceScale) kotlin.math.ceil((textSize * density).toDouble()).toFloat() else textSize * density ++ val horizontalInset = if (item.usesSelectorSourceScale) ((20 * density).toInt() - parent.paddingLeft).toFloat() else NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat() + val left = parent.paddingLeft.toFloat() + val right = (parent.width - parent.paddingRight).toFloat() + canvas.drawRect(left, 0f, right, height.toFloat(), backgroundPaint) +- val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f ++ val baseline = if (item.usesSelectorSourceScale) { ++ val metrics = textPaint.fontMetricsInt ++ val lineHeight = kotlin.math.ceil(20 * density.toDouble()).toInt() ++ val leading = lineHeight - (metrics.descent - metrics.ascent) ++ (height - lineHeight) / 2 - metrics.ascent + kotlin.math.ceil(leading / 2.0).toFloat() ++ } else height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f + val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it } + val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL + val textWidth = if (isHistory) { +@@ -1789,6 +1904,29 @@ private class StickySectionHeaderDecoration( + + internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean = + item.type == "sectionHeader" && ++ item.json.optBoolean("sticky", true) && + item.json.optString("variant") != "summary" && + item.json.optString("value").isEmpty() && + item.json.optJSONObject("checkbox") == null ++ ++// OneKey patch: paint only explicitly requested backgrounds into list side padding. ++private class SelectorBackgroundDecoration(private val adapter: NativeListAdapter) : RecyclerView.ItemDecoration() { ++ private val paint = Paint() ++ override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { ++ for (index in 0 until parent.childCount) { ++ val child = parent.getChildAt(index) ++ val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue ++ if (!item.json.optBoolean("backgroundFullWidth", false)) continue ++ val color = item.json.optString("backgroundColor") ++ if (color.isEmpty()) continue ++ paint.color = try { parseNativeListColor(color) } catch (_: IllegalArgumentException) { Color.TRANSPARENT } ++ val top = child.y ++ canvas.drawRect(0f, top, parent.width.toFloat(), top + child.height, paint) ++ if (item.type == "system" && item.json.optString("variant") == "warning") { ++ paint.color = try { parseNativeListColor(item.json.optString("borderColor", "#E0E0E0")) } catch (_: IllegalArgumentException) { Color.TRANSPARENT } ++ canvas.drawRect(0f, top, parent.width.toFloat(), top + 1, paint) ++ canvas.drawRect(0f, top + child.height - 1, parent.width.toFloat(), top + child.height, paint) ++ } ++ } ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift +index e0bf60b..c53c679 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift +@@ -1,4 +1,6 @@ + import Foundation ++// OneKey patch: preserve native font faces while enabling tabular number features. ++import CoreText + import OneKeyImage + import UIKit + +@@ -8,19 +10,56 @@ final class NativeListActionOrigin { + let bindingEpoch: Int + let source: String + let slot: Int? ++ // OneKey patch: expose the layout slot while preserving the larger hit target. ++ let anchorInset: CGFloat + + init( + sourceView: UIView, + ownerCell: NativeListCell, + bindingEpoch: Int, + source: String, +- slot: Int? = nil ++ slot: Int? = nil, ++ anchorInset: CGFloat = 0 + ) { + self.sourceView = sourceView + self.ownerCell = ownerCell + self.bindingEpoch = bindingEpoch + self.source = source + self.slot = slot ++ self.anchorInset = anchorInset ++ } ++} ++ ++// OneKey patch: explicit summary actions use the source text's physical-pixel line box. ++private final class NativeListAccessoryButton: UIButton { ++ var selectorSummaryLineHeight: CGFloat? { ++ didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } ++ } ++ ++ private var sourcePixelScale: CGFloat { ++ max(1, window?.screen.scale ?? traitCollection.displayScale) ++ } ++ ++ override var intrinsicContentSize: CGSize { ++ var size = super.intrinsicContentSize ++ guard selectorSummaryLineHeight != nil, let title = attributedTitle(for: .normal) else { return size } ++ let width = title.boundingRect( ++ with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), ++ options: [.usesLineFragmentOrigin, .usesFontLeading], ++ context: nil ++ ).width ++ size.width = ceil(width * sourcePixelScale) / sourcePixelScale ++ return size ++ } ++ ++ override func layoutSubviews() { ++ super.layoutSubviews() ++ guard let lineHeight = selectorSummaryLineHeight, let titleLabel else { return } ++ // OneKey patch: position the final source line box after UIKit has measured the button. ++ let top = ceil((bounds.height - lineHeight) / 2 * sourcePixelScale) / sourcePixelScale ++ var frame = titleLabel.frame ++ frame.origin.y = top ++ titleLabel.frame = frame + } + } + +@@ -54,6 +93,24 @@ private final class NativeListDottedUnderlineLabel: UILabel { + didSet { setNeedsLayout() } + } + ++ // OneKey patch: migrated section titles include the source 3-point underline box. ++ var reservesDottedUnderlineSpace = false { ++ didSet { invalidateIntrinsicContentSize(); setNeedsLayout(); setNeedsDisplay() } ++ } ++ ++ override var intrinsicContentSize: CGSize { ++ var size = super.intrinsicContentSize ++ if reservesDottedUnderlineSpace && showsDottedUnderline { size.height += 3 } ++ return size ++ } ++ ++ override func drawText(in rect: CGRect) { ++ let textRect = reservesDottedUnderlineSpace && showsDottedUnderline ++ ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - 3)) ++ : rect ++ super.drawText(in: textRect) ++ } ++ + var dottedUnderlineColor: UIColor = .clear { + didSet { + dottedUnderlineLayer.strokeColor = dottedUnderlineColor.cgColor +@@ -101,7 +158,9 @@ private final class NativeListDottedUnderlineLabel: UILabel { + width: bounds.width, + height: bounds.height + 2 + dottedUnderlineVerticalOffset + ) +- let y = bounds.height + 1 + dottedUnderlineVerticalOffset ++ // OneKey patch: explicit header underline occupies the reserved final two points. ++ // let y = bounds.height + 1 + dottedUnderlineVerticalOffset ++ let y = reservesDottedUnderlineSpace ? bounds.height - 1 : bounds.height + 1 + dottedUnderlineVerticalOffset + let path = UIBezierPath() + path.move(to: CGPoint(x: 1, y: y)) + path.addLine(to: CGPoint(x: max(1, textWidth - 1), y: y)) +@@ -257,6 +316,9 @@ private final class NativeListTableColumnView: UIStackView { + + private func textColor(_ tone: String, theme: [String: Any]?) -> UIColor { + switch tone { ++ // OneKey patch: account warnings and hidden balances use existing theme tokens. ++ case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") ++ case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") +@@ -273,6 +335,13 @@ final class NativeListCell: UICollectionViewCell { + private let leadingOverlayBackground = UIView() + private let leadingCornerIconBackground = UIView() + private let leadingCornerIconImageView = UIImageView() ++ // OneKey patch: selector-only views are reset on every native cell binding. ++ private var selectorViews: [UIView] = [] ++ private var selectorConstraints: [NSLayoutConstraint] = [] ++ private var selectorImages: [OneKeyImageReusableView] = [] ++ private var selectorBorder: CAShapeLayer? ++ private let selectorFullWidthBackground = CALayer() ++ private lazy var selectorTitleTap = UITapGestureRecognizer(target: self, action: #selector(selectorTitlePressed)) + private let secondaryImage = OneKeyImageReusableView(frame: .zero) + private let mediaNetworkImage = OneKeyImageReusableView(frame: .zero) + private let fallbackLabel = UILabel() +@@ -296,7 +365,9 @@ final class NativeListCell: UICollectionViewCell { + private let actionStack = UIStackView() + private let actionButtons = (0..<3).map { _ in UIButton(type: .system) } + private let trailingStack = UIStackView() +- private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) } ++ // OneKey patch: summary actions opt into source typography while other buttons keep UIKit layout. ++ // private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) } ++ private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } + private let checkboxButton = UIButton(type: .system) + private let spinner = UIActivityIndicatorView(style: .medium) + private let dataStack = UIStackView() +@@ -332,6 +403,8 @@ final class NativeListCell: UICollectionViewCell { + private var leadingSlotConstraints: [NSLayoutConstraint] = [] + private var dataWeightConstraints: [NSLayoutConstraint] = [] + private var accessorySizeConstraints: [NSLayoutConstraint] = [] ++ // OneKey patch: restore selector-only font features before a cell is reused. ++ private var selectorTypographyRestorers: [() -> Void] = [] + private var currentItem: NativeListItem? + private var accessoryActions: [(String, NativeSelectionTarget?)] = [] + private var footerActionKeys: [String] = [] +@@ -345,12 +418,15 @@ final class NativeListCell: UICollectionViewCell { + fallback: .lightGray + ) + private var checkboxCheckedColor = UIColor(nativeListHex: "#202020", fallback: .black) ++ private var checkboxIconColor = UIColor.white + private var checkboxUncheckedColor = UIColor(nativeListHex: "#FCFCFC", fallback: .white) + private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray) + private var visualBackdropColor = UIColor.white + private var currentLayout = "linear" + private var currentTheme: [String: Any]? + private var currentItemIndex: Int? ++ // OneKey patch: delayed image retries belong to the current reusable cell binding. ++ private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:] + private(set) var bindingEpoch = 0 + + var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? +@@ -615,9 +691,23 @@ final class NativeListCell: UICollectionViewCell { + + override func layoutSubviews() { + super.layoutSubviews() ++ // OneKey patch: extend only the background across the section list outer inset. ++ if currentItem?.data.bool("backgroundFullWidth") == true { ++ selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) ++ } + if currentItem?.type == "mediaTile" { + mediaHeight.constant = max(0, contentView.bounds.width - 20) + } ++ if currentItem?.type == "identity", currentItem?.data["height"] != nil, ++ currentItem?.data.string("presentation") == "accountSelector", ++ let accessory = currentItem?.data.dictionaries("trailing").first, ++ accessory.string("kind") == "icon", accessory.string("name") == "PlusSmallOutline" { ++ // OneKey patch: PlusButton's fixed top18 slot and negative7 margin place its frame at11. ++ let button = accessoryButtons[0] ++ button.transform = .identity ++ let origin = button.convert(button.bounds, to: contentView).minY ++ button.transform = CGAffineTransform(translationX: 0, y: 11 - origin) ++ } + } + + override func prepareForReuse() { +@@ -664,6 +754,14 @@ final class NativeListCell: UICollectionViewCell { + // Checkbox uses the literal neutral7 alpha token. Applying opacity to the + // opaque primary text color produces a different RGB result. + checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) ++ checkboxIconColor = checkboxUncheckedColor ++ if item.data.string("presentation") == "networkSelector" { ++ checkboxCheckedColor = nativeListColor(theme, "checkboxBackground", "#202020") ++ checkboxBorderColor = nativeListColor(theme, "checkboxBorder", "#00000031") ++ checkboxIconColor = nativeListColor(theme, "checkboxIcon", "#FFFFFF") ++ // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse. ++ checkboxUncheckedColor = checkboxIconColor ++ } + visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF") + titleLabel.textColor = primary + subtitleLabel.textColor = secondary +@@ -693,6 +791,11 @@ final class NativeListCell: UICollectionViewCell { + pressedBackgroundColor = restingBackgroundColor + } + updateBackgroundColor() ++ if item.data.bool("backgroundFullWidth"), let background = item.data["backgroundColor"] as? String { ++ selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor ++ contentView.layer.insertSublayer(selectorFullWidthBackground, at: 0) ++ clipsToBounds = false ++ } + if layout == "table" { + if item.type == "dataRow" { + rootLeadingConstraint.constant = 20 +@@ -707,8 +810,12 @@ final class NativeListCell: UICollectionViewCell { + } + applyGroupPosition(item.data.string("groupPosition")) + isUserInteractionEnabled = !item.data.bool("disabled") +- contentView.alpha = isUserInteractionEnabled ? 1 : 0.5 ++ // OneKey patch: deprecated wallets remain interactive while dimmed. ++ // contentView.alpha = isUserInteractionEnabled ? 1 : 0.5 ++ contentView.alpha = CGFloat(item.data.double("opacity", default: 1)) * (isUserInteractionEnabled ? 1 : 0.5) + accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) ++ // OneKey patch: keep existing selector automation identifiers. ++ accessibilityIdentifier = item.data["testID"] as? String + + switch item.type { + case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) +@@ -724,6 +831,14 @@ final class NativeListCell: UICollectionViewCell { + case "system": bindSystem(item, theme: theme) + default: break + } ++ applySelectorTypography(item) ++ if item.type == "sectionHeader", item.data.string("presentation") == "networkSelector", item.data["height"] != nil, item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty { ++ // OneKey patch: UIKit must reserve only the total's intrinsic width before the checkbox. ++ let valueWidth = accessoryButtons[0].intrinsicContentSize.width ++ let width = trailingStack.widthAnchor.constraint(equalToConstant: valueWidth + 12 + 20) ++ width.isActive = true ++ selectorConstraints.append(width) ++ } + } + + func updateSelection( +@@ -732,6 +847,8 @@ final class NativeListCell: UICollectionViewCell { + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { + guard currentItem?.key == item.key else { return } ++ restoreSelectorTypography() ++ defer { applySelectorTypography(item) } + if item.type == "walletGroup" { + currentItem = item + let memberData = [item.data.dictionary("parent")].compactMap { $0 } +@@ -777,6 +894,13 @@ final class NativeListCell: UICollectionViewCell { + selected ? "#FFFFFFED" : "#FFFFFFAF" + ) + } ++ // OneKey patch: retain the latest descriptor when a controlled echo avoids full binding. ++ if boundCheckboxData != nil { ++ let latestCheckbox = item.type == "identity" ++ ? item.data.dictionaries("trailing").last { $0.string("kind") == "checkbox" } ++ : item.data.dictionary("checkbox") ++ boundCheckboxData = latestCheckbox ?? boundCheckboxData ++ } + guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return } + updateCheckboxPresentation( + item, +@@ -786,12 +910,55 @@ final class NativeListCell: UICollectionViewCell { + ) + } + ++ // OneKey patch: match SizableText TABULAR_NUMS on every selector text run, retaining its face and size. ++ private func selectorTabularFont(_ font: UIFont) -> UIFont { ++ var settings = font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] ?? [] ++ settings.removeAll { $0[.type] == kNumberSpacingType } ++ settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector]) ++ return UIFont(descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), size: font.pointSize) ++ } ++ ++ private func selectorTabularText(_ original: NSAttributedString) -> NSAttributedString { ++ let result = NSMutableAttributedString(attributedString: original) ++ // OneKey patch: body typography explicitly supplies letterSpacing=0 in Tamagui. ++ result.addAttribute(.kern, value: 0, range: NSRange(location: 0, length: result.length)) ++ original.enumerateAttribute(.font, in: NSRange(location: 0, length: original.length)) { value, range, _ in ++ if let font = value as? UIFont { result.addAttribute(.font, value: self.selectorTabularFont(font), range: range) } ++ } ++ return result ++ } ++ ++ private func restoreSelectorTypography() { ++ selectorTypographyRestorers.reversed().forEach { $0() } ++ selectorTypographyRestorers.removeAll() ++ } ++ ++ private func applySelectorTypography(_ item: NativeListItem) { ++ guard ["accountSelector", "networkSelector", "walletSidebar"].contains(item.data.string("presentation")) || item.type == "system" && item.data.string("variant") == "warning" else { return } ++ func visit(_ view: UIView) { ++ if let button = view as? UIButton { ++ if let original = button.attributedTitle(for: .normal) { ++ selectorTypographyRestorers.append { button.setAttributedTitle(original, for: .normal) } ++ button.setAttributedTitle(selectorTabularText(original), for: .normal) ++ } ++ } else if let label = view as? UILabel, let font = label.font { ++ let original = label.attributedText ++ selectorTypographyRestorers.append { label.font = font; label.attributedText = original } ++ label.font = selectorTabularFont(font) ++ if let original { label.attributedText = selectorTabularText(original) } ++ } ++ for child in view.subviews { visit(child) } ++ } ++ visit(contentView) ++ } ++ + private func updateSummaryText(_ item: NativeListItem) { + let title = item.data.string("title") + titleLabel.isHidden = title.isEmpty + setLineHeight(titleLabel, text: title, lineHeight: 24) + + let value = item.data.string("value") ++ let isExplicitNetworkHeader = item.data.string("presentation") == "networkSelector" && item.data["height"] != nil + let valueButton = accessoryButtons[0] + valueButton.isHidden = value.isEmpty + if value.isEmpty { +@@ -801,7 +968,7 @@ final class NativeListCell: UICollectionViewCell { + setButtonLine( + valueButton, + text: value, +- font: nativeListFont(ofSize: 16), ++ font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), + color: nativeListColor(currentTheme, "secondaryText", "#646464"), + lineHeight: 24 + ) +@@ -826,7 +993,7 @@ final class NativeListCell: UICollectionViewCell { + // Selection is communicated by the destination state for these source + // components; neither has a persistent selected tile background. + color = nativeListColor(theme, "rowBackground", "#FFFFFF") +- } else if layout == "sectioned" { ++ } else if layout == "sectioned", !item.data.bool("selected") { + // Checkbox-backed section lists in app-monorepo keep rows on $bg; + // selection is represented by the checkbox itself. + color = nativeListColor(theme, "rowBackground", "#FFFFFF") +@@ -836,10 +1003,27 @@ final class NativeListCell: UICollectionViewCell { + !selected { + color = nativeListColor(theme, "subduedBackground", "#F9F9F9") + } ++ // OneKey patch: portfolio group headers retain their source background. ++ if let backgroundColor = item.data["backgroundColor"] as? String { ++ return UIColor(nativeListHex: backgroundColor, fallback: color) ++ } + return color + } + + private func reset() { ++ restoreSelectorTypography() ++ // OneKey patch: remove selector decorations before rebinding recycled cells. ++ selectorViews.forEach { $0.removeFromSuperview() } ++ selectorViews.removeAll() ++ NSLayoutConstraint.deactivate(selectorConstraints) ++ selectorConstraints.removeAll() ++ selectorImages.forEach { $0.prepareForReuse() } ++ selectorImages.removeAll() ++ selectorFullWidthBackground.removeFromSuperlayer() ++ selectorBorder?.removeFromSuperlayer() ++ selectorBorder = nil ++ titleLabel.removeGestureRecognizer(selectorTitleTap) ++ titleLabel.isUserInteractionEnabled = false + walletGroupCompactCell?.invalidateCurrentBinding() + walletGroupCells.forEach { $0.invalidateCurrentBinding() } + isHighlighted = false +@@ -978,11 +1162,13 @@ final class NativeListCell: UICollectionViewCell { + $0.backgroundColor = .clear + } + accessoryButtons.enumerated().forEach { index, button in ++ button.selectorSummaryLineHeight = nil + button.titleLabel?.font = nativeListFont( + ofSize: index == 0 ? 16 : 14, + weight: index == 0 ? .medium : .regular + ) + button.isHidden = true ++ button.accessibilityIdentifier = nil + button.setTitle(nil, for: .normal) + button.setAttributedTitle(nil, for: .normal) + button.setImage(nil, for: .normal) +@@ -994,6 +1180,7 @@ final class NativeListCell: UICollectionViewCell { + button.backgroundColor = .clear + button.layer.cornerRadius = 0 + button.contentEdgeInsets = .zero ++ button.transform = .identity + } + checkboxButton.isHidden = true + checkboxButton.alpha = 1 +@@ -1028,6 +1215,7 @@ final class NativeListCell: UICollectionViewCell { + contentView.clipsToBounds = false + leadingContainer.alpha = 1 + titleLabel.showsDottedUnderline = false ++ titleLabel.reservesDottedUnderlineSpace = false + titleLabel.dottedUnderlineVerticalOffset = 0 + } + +@@ -1073,7 +1261,8 @@ final class NativeListCell: UICollectionViewCell { + while walletGroupCells.count < walletGroupMembers.count { + let memberCell = NativeListCell(frame: .zero) + memberCell.translatesAutoresizingMaskIntoConstraints = false +- memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true ++ // OneKey patch: each member's current height is applied when bound. ++ // memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true + walletGroupCells.append(memberCell) + } + rootStack.axis = .vertical +@@ -1083,8 +1272,18 @@ final class NativeListCell: UICollectionViewCell { + rootTrailingConstraint.constant = 0 + rootTopConstraint.constant = 0 + rootBottomConstraint.constant = 0 ++ if memberData.first?["height"] != nil { ++ // OneKey patch: the source group's one-point border occupies layout space. ++ rootLeadingConstraint.constant = 1 ++ rootTrailingConstraint.constant = -1 ++ rootTopConstraint.constant = 1 ++ rootBottomConstraint.constant = -1 ++ } + walletGroupMembers.enumerated().forEach { index, member in + let memberCell = walletGroupCells[index] ++ // OneKey patch: badges add a second line within their logical wallet group. ++ memberCell.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.isActive = false } ++ memberCell.heightAnchor.constraint(equalToConstant: CGFloat(member.data.double("height", default: member.data.dictionaries("badges").isEmpty ? 68 : 92))).isActive = true + memberCell.onAction = { [weak self] source, action, target, origin in + self?.onAction?(source, action, target, origin) + } +@@ -1140,7 +1339,10 @@ final class NativeListCell: UICollectionViewCell { + let point = gesture.location(in: rootStack) + for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() + where cell.frame.contains(point) { +- onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin()) ++ // OneKey patch: group member press gating must not disable accessory controls. ++ if !walletGroupMembers[index].data.bool("pressDisabled") { ++ onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin()) ++ } + return + } + } +@@ -1269,8 +1471,14 @@ final class NativeListCell: UICollectionViewCell { + contentView.clipsToBounds = true + } else { + applyGroupPosition(currentItem?.data.string("groupPosition") ?? "") +- let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0 ++ // OneKey patch: explicit account and network selectors preserve ListItem radius while idle. ++ // let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0 ++ let isSelectorIdentity = currentItem?.type == "identity" && currentItem?.data["height"] != nil ++ let isAccountSelector = isSelectorIdentity && ["accountSelector", "networkSelector"].contains(currentItem?.data.string("presentation") ?? "") ++ let isWalletSidebar = isSelectorIdentity && currentItem?.data.string("presentation") == "walletSidebar" ++ let restingRadius: CGFloat = isWalletSidebar ? 20 : currentItem?.type == "metricCard" || isAccountSelector ? 12 : 0 + contentView.layer.cornerRadius = restingRadius ++ contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular + contentView.clipsToBounds = restingRadius > 0 + } + } +@@ -1299,6 +1507,17 @@ final class NativeListCell: UICollectionViewCell { + fallbackLabel.font = nativeListFont(ofSize: 28) + addLeading(item.data.dictionary("leading"), key: item.key) + rootStack.addArrangedSubview(mainStack) ++ // OneKey patch: activate width constraints only after both stacks share an ancestor. ++ if item.data["height"] != nil { ++ titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) ++ titleRowStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) ++ let width = mainStack.widthAnchor.constraint(equalTo: rootStack.widthAnchor) ++ width.isActive = true ++ selectorConstraints.append(width) ++ let titleWidth = titleRowStack.widthAnchor.constraint(lessThanOrEqualTo: mainStack.widthAnchor) ++ titleWidth.isActive = true ++ selectorConstraints.append(titleWidth) ++ } + show(titleLabel, item.data.string("title"), lines: 1) + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) + titleLabel.textColor = nativeListColor( +@@ -1306,6 +1525,32 @@ final class NativeListCell: UICollectionViewCell { + selected ? "primaryText" : "secondaryText", + selected ? "#FFFFFFED" : "#FFFFFFAF" + ) ++ // OneKey patch: wallet tags belong below the centered name. ++ let badges = item.data.dictionaries("badges") ++ if !badges.isEmpty { ++ let line = UIStackView() ++ line.axis = .horizontal ++ line.spacing = 4 ++ line.alignment = .center ++ for badge in badges { ++ let label = NativeListInsetLabel() ++ let isSelector = item.data["height"] != nil ++ let isWarning = badge.string("tone") == "warning" ++ label.font = nativeListFont(ofSize: isSelector ? 11 : 12) ++ label.textColor = nativeListColor(theme, isSelector && isWarning ? "caution" : "secondaryText", isSelector && isWarning ? "#AB6400" : "#646464") ++ label.backgroundColor = nativeListColor(theme, isSelector ? (isWarning ? "cautionBackground" : "subduedBackground") : "strongBackground", isSelector && isWarning ? "#FFF8C5" : "#F0F0F0") ++ label.horizontalInset = isSelector ? 6 : 4 ++ label.topInset = 2 ++ label.bottomInset = 2 ++ label.layer.cornerRadius = 4 ++ label.clipsToBounds = true ++ setLineHeight(label, text: badge.string("text"), lineHeight: isSelector ? 14 : 16) ++ line.addArrangedSubview(label) ++ } ++ mainStack.spacing = 4 ++ mainStack.addArrangedSubview(line) ++ selectorViews.append(line) ++ } + return + } + if item.data.string("presentation") == "accountSelector" { +@@ -1344,6 +1589,12 @@ final class NativeListCell: UICollectionViewCell { + rootStack.setCustomSpacing(5, after: leadingActionButton) + } + addLeading(item.data.dictionary("leading"), key: item.key) ++ // OneKey patch: custom network initials match LetterAvatar size 32. ++ if item.data.string("presentation") == "networkSelector", let leading = item.data.dictionary("leading"), leading.dictionary("image") == nil, leading.dictionary("fallbackIcon") == nil, !leading.string("fallbackText").isEmpty { ++ fallbackLabel.font = nativeListFont(ofSize: 19, weight: .semibold) ++ fallbackLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") ++ setLineHeight(fallbackLabel, text: leading.string("fallbackText"), lineHeight: 27) ++ } + rootStack.addArrangedSubview(mainStack) + show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1)) + show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1)) +@@ -1353,6 +1604,77 @@ final class NativeListCell: UICollectionViewCell { + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) + setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20) + } ++ // OneKey patch: preserve independent balance/address truncation and warning tones. ++ let segments = item.data.dictionaries("subtitleSegments") ++ if !segments.isEmpty { ++ subtitleLabel.isHidden = true ++ mainStack.spacing = 0 ++ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) ++ let line = UIStackView() ++ line.axis = .horizontal ++ line.alignment = .center ++ line.spacing = 0 ++ for segment in segments { ++ if segment.bool("separatorBefore") { ++ let gap = UIView() ++ gap.translatesAutoresizingMaskIntoConstraints = false ++ let dot = UIView() ++ dot.translatesAutoresizingMaskIntoConstraints = false ++ dot.backgroundColor = nativeListColor(theme, "disabledText", "#8D8D8D") ++ dot.layer.cornerRadius = 2 ++ gap.addSubview(dot) ++ NSLayoutConstraint.activate([ ++ gap.widthAnchor.constraint(equalToConstant: 16), ++ gap.heightAnchor.constraint(equalToConstant: 20), ++ dot.widthAnchor.constraint(equalToConstant: 4), ++ dot.heightAnchor.constraint(equalToConstant: 4), ++ dot.centerXAnchor.constraint(equalTo: gap.centerXAnchor), ++ dot.centerYAnchor.constraint(equalTo: gap.centerYAnchor), ++ ]) ++ line.addArrangedSubview(gap) ++ } ++ let label = UILabel() ++ label.font = nativeListFont(ofSize: 14) ++ label.lineBreakMode = .byTruncatingTail ++ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) ++ label.textColor = dataTextColor(segment.string("tone", default: "secondary"), theme: theme) ++ setLineHeight(label, text: segment.string("text"), lineHeight: 20) ++ let runs = segment.dictionaries("textSegments") ++ if !runs.isEmpty { ++ let value = NSMutableAttributedString(string: "") ++ let paragraph = NSMutableParagraphStyle() ++ paragraph.minimumLineHeight = 20 ++ paragraph.maximumLineHeight = 20 ++ for run in runs { ++ value.append(NSAttributedString(string: run.string("text"), attributes: [ ++ .font: nativeListFont(ofSize: run.string("style") == "subscript" ? 9 : 14), ++ .foregroundColor: label.textColor as Any, ++ .paragraphStyle: paragraph, ++ .baselineOffset: max(0, (20 - nativeListFont(ofSize: 14).lineHeight) / 2), ++ ])) ++ } ++ label.attributedText = value ++ } ++ line.addArrangedSubview(label) ++ } ++ let filler = UIView() ++ filler.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) ++ line.addArrangedSubview(filler) ++ mainStack.insertArrangedSubview(line, at: 2) ++ selectorViews.append(line) ++ } ++ let matches = item.data.dictionaries("titleMatch") ++ if !matches.isEmpty { ++ let text = NSMutableAttributedString(attributedString: titleLabel.attributedText ?? NSAttributedString(string: item.data.string("title"))) ++ for match in matches { ++ let start = match.int("start") ++ let end = match.int("end") ++ if start >= 0 && end > start && end <= text.length { ++ text.addAttribute(.foregroundColor, value: nativeListColor(theme, "info", "#0D74CE"), range: NSRange(location: start, length: end - start)) ++ } ++ } ++ titleLabel.attributedText = text ++ } + tertiaryLabel.textColor = nativeListColor( + theme, + item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText", +@@ -1997,15 +2319,29 @@ final class NativeListCell: UICollectionViewCell { + ) { + rootStack.addArrangedSubview(mainStack) + let variant = item.data.string("variant") ++ // OneKey patch: title help is a separate target from checkbox and value actions. ++ if !item.data.string("titleActionKey").isEmpty { ++ titleLabel.isUserInteractionEnabled = true ++ titleLabel.addGestureRecognizer(selectorTitleTap) ++ titleLabel.setContentHuggingPriority(.required, for: .horizontal) ++ } + let isSummary = variant == "summary" + let isGallery = variant == "gallery" + let isTable = layout == "table" + let isNetworkSelector = item.data.string("presentation") == "networkSelector" ++ let isExplicitNetworkHeader = isNetworkSelector && item.data["height"] != nil ++ if isExplicitNetworkHeader { ++ // OneKey patch: the flexible title consumes spare space before trailing totals. ++ trailingStack.setContentHuggingPriority(.required, for: .horizontal) ++ trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) ++ } ++ titleLabel.reservesDottedUnderlineSpace = isExplicitNetworkHeader && !item.data.string("titleActionKey").isEmpty + let isHistory = variant == "history" || + item.key.hasPrefix("history-") || + (item.sectionKey?.hasPrefix("history-") ?? false) + let headerWeight: NativeListFontWeight = isSummary + ? .medium ++ : isExplicitNetworkHeader && (item.data.dictionary("checkbox") != nil || item.data.string("titleActionKey").isEmpty) ? .semibold + : isNetworkSelector ? .medium + : isGallery || layout == "sectioned" ? .semibold : .regular + titleLabel.font = nativeListFont( +@@ -2019,8 +2355,8 @@ final class NativeListCell: UICollectionViewCell { + ) + show(titleLabel, item.data.string("title"), lines: 1) + if isNetworkSelector { +- titleLabel.showsDottedUnderline = true +- titleLabel.dottedUnderlineVerticalOffset = 2 ++ titleLabel.showsDottedUnderline = !isExplicitNetworkHeader || !item.data.string("titleActionKey").isEmpty ++ titleLabel.dottedUnderlineVerticalOffset = isExplicitNetworkHeader ? 1 : 2 + titleLabel.dottedUnderlineColor = nativeListColor( + theme, + "secondaryText", +@@ -2028,8 +2364,9 @@ final class NativeListCell: UICollectionViewCell { + ) + rootLeadingConstraint.constant = 12 + rootTrailingConstraint.constant = -12 +- rootTopConstraint.constant = 12 +- rootBottomConstraint.constant = -15 ++ let isAlphabet = isExplicitNetworkHeader && item.data.string("titleActionKey").isEmpty ++ rootTopConstraint.constant = isAlphabet ? 8 : 12 ++ rootBottomConstraint.constant = isAlphabet ? -8 : isExplicitNetworkHeader ? -12 : -15 + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) + } else if isHistory { + rootLeadingConstraint.constant = 0 +@@ -2095,6 +2432,7 @@ final class NativeListCell: UICollectionViewCell { + rootTopConstraint.constant = 24 + rootBottomConstraint.constant = -20 + setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) ++ accessoryButtons[0].accessibilityIdentifier = item.data["valueActionTestID"] as? String + let valueActionKey = item.data.string("valueActionKey") + let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty + ? nil +@@ -2105,11 +2443,11 @@ final class NativeListCell: UICollectionViewCell { + action: action, + color: nativeListColor(theme, "secondaryText", "#646464") + ) +- accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16) ++ accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular) + setButtonLine( + accessoryButtons[0], + text: item.data.string("value"), +- font: nativeListFont(ofSize: 16), ++ font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), + color: nativeListColor(theme, "secondaryText", "#646464"), + lineHeight: 24 + ) +@@ -2169,6 +2507,28 @@ final class NativeListCell: UICollectionViewCell { + ) + } + } ++ applyValueSegments(item.data.dictionaries("valueSegments"), to: accessoryButtons[0], theme: theme) ++ } ++ ++ // OneKey patch: small zero-count digits remain on the regular amount baseline. ++ private func applyValueSegments(_ segments: [[String: Any]], to button: UIButton, theme: [String: Any]?) { ++ guard !segments.isEmpty else { return } ++ let value = NSMutableAttributedString(string: "") ++ let color = nativeListColor(theme, "primaryText", "#202020") ++ // OneKey patch: rich currency changes font runs without dropping the established line baseline. ++ let current = button.attributedTitle(for: .normal) ++ var attributes = current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] ++ attributes[.foregroundColor] = color ++ if currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil { ++ let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() ++ paragraph.alignment = .right ++ attributes[.paragraphStyle] = paragraph ++ } ++ for segment in segments { ++ attributes[.font] = nativeListTabularFont(ofSize: segment.string("style") == "subscript" ? 10 : 16, weight: .medium) ++ value.append(NSAttributedString(string: segment.string("text"), attributes: attributes)) ++ } ++ button.setAttributedTitle(value, for: .normal) + } + + private func bindAction( +@@ -2185,6 +2545,8 @@ final class NativeListCell: UICollectionViewCell { + addLeading(icon, key: item.key) + if isAccountSelector { + leadingContainer.layer.cornerCurve = .continuous ++ leadingContainer.layer.cornerRadius = 8 ++ leadingContainer.layer.borderWidth = 0 + } + if icon["backgroundColor"] == nil { + leadingContainer.backgroundColor = .clear +@@ -2195,8 +2557,9 @@ final class NativeListCell: UICollectionViewCell { + rootStack.addArrangedSubview(mainStack) + show(titleLabel, item.data.string("title"), lines: 1) + if isAccountSelector { +- titleLabel.font = nativeListFont(ofSize: 16) +- titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") ++ // OneKey patch: ListItem.Text is medium; empty-search actions use regular body text. ++ titleLabel.font = nativeListFont(ofSize: 16, weight: item.data.dictionary("icon") == nil ? .regular : .medium) ++ titleLabel.textColor = nativeListColor(theme, item.data.string("tone") == "primary" ? "primaryText" : "secondaryText", item.data.string("tone") == "primary" ? "#202020" : "#646464") + } else if item.data.string("tone") == "danger" { + titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") + } +@@ -2217,8 +2580,19 @@ final class NativeListCell: UICollectionViewCell { + } + } + ++ // OneKey patch: preserve the actual title frame for Popover placement. ++ @objc private func selectorTitlePressed() { ++ guard let item = currentItem else { return } ++ let action = item.data.string("titleActionKey") ++ guard !action.isEmpty else { return } ++ onAction?(item, action, nil, actionOrigin(sourceView: titleLabel, source: "leadingAction")) ++ } ++ + private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor { + switch tone.isEmpty ? "primary" : tone { ++ // OneKey patch: account warnings and hidden balances use existing theme tokens. ++ case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") ++ case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") +@@ -2230,6 +2604,36 @@ final class NativeListCell: UICollectionViewCell { + rootStack.alignment = .center + rootStack.distribution = .fill + let variant = item.data.string("variant") ++ // OneKey patch: deprecated-wallet warnings stay inside the scrolling list. ++ if variant == "warning" { ++ rootStack.addArrangedSubview(mainStack) ++ rootTopConstraint.constant = 14 ++ rootBottomConstraint.constant = -14 ++ mainStack.spacing = 4 ++ titleLabel.font = nativeListFont(ofSize: 14, weight: .medium) ++ titleLabel.numberOfLines = 0 ++ subtitleLabel.font = nativeListFont(ofSize: 14) ++ subtitleLabel.numberOfLines = 0 ++ show(titleLabel, item.data.string("title"), lines: 0) ++ show(subtitleLabel, item.data.string("message"), lines: 0) ++ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) ++ setLineHeight(subtitleLabel, text: item.data.string("message"), lineHeight: 20) ++ let borderColor = UIColor(nativeListHex: item.data.string("borderColor", default: "#E0E0E0"), fallback: .lightGray) ++ for top in [true, false] { ++ let border = UIView() ++ border.translatesAutoresizingMaskIntoConstraints = false ++ border.backgroundColor = borderColor ++ contentView.addSubview(border) ++ NSLayoutConstraint.activate([ ++ border.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: -8), ++ border.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 8), ++ border.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), ++ top ? border.topAnchor.constraint(equalTo: contentView.topAnchor) : border.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), ++ ]) ++ selectorViews.append(border) ++ } ++ return ++ } + if variant == "loading" { + leadingWidth.constant = 40 + leadingHeight.constant = 40 +@@ -2357,7 +2761,105 @@ final class NativeListCell: UICollectionViewCell { + tokenPair: tokenPair, + shape: shape + )) +- bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant) ++ if currentItem?.data.string("presentation") == "networkSelector", currentItem?.data["height"] != nil, visibleSources.count == 1, cornerIcon == nil, visual.dictionaries("overlays").isEmpty { ++ // OneKey patch: a single outer mask matches NetworkAvatar's edge antialiasing. ++ imageView.layer.cornerRadius = 0 ++ imageView.clipsToBounds = false ++ } ++ let fallbackIcon = index == 0 ? visual.dictionary("fallbackIcon") : nil ++ let expectedEpoch = bindingEpoch ++ bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant, ++ onLoad: fallbackIcon == nil ? nil : { [weak self, weak imageView] in ++ guard let self, self.bindingEpoch == expectedEpoch else { return } ++ imageView?.isHidden = false ++ self.leadingIconImageView.isHidden = true ++ }, ++ onError: fallbackIcon == nil ? nil : { [weak self, weak imageView] in ++ guard let self, self.bindingEpoch == expectedEpoch, let fallbackIcon else { return } ++ imageView?.isHidden = true ++ self.fallbackLabel.isHidden = true ++ self.leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name")) ++ self.leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray) ++ self.leadingIconImageView.isHidden = false ++ }) ++ } ++ // OneKey patch: source-derived wallet decorations may occupy both corners. ++ let overlays = visual.dictionaries("overlays") ++ if !overlays.isEmpty { leadingContainer.clipsToBounds = false } ++ for (index, overlay) in overlays.enumerated() { ++ let size = CGFloat(overlay.double("size", default: 20)) ++ let isWalletText = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil && !overlay.string("text").isEmpty && overlay.dictionary("image") == nil && overlay.string("name").isEmpty ++ let inset = CGFloat(overlay.double("padding", default: 0)) ++ let offsetX = CGFloat(overlay.double("offsetX", default: overlay.double("offset", default: 2))) ++ let offsetY = CGFloat(overlay.double("offsetY", default: overlay.double("offset", default: 2))) ++ let height = CGFloat(overlay.double("height", default: isWalletText ? 16 : Double(size))) ++ let textWidth = (overlay.string("text") as NSString).size(withAttributes: [.font: nativeListTabularFont(ofSize: 12), .kern: 0]).width ++ let naturalTextWidth = ceil(textWidth * UIScreen.main.scale) / UIScreen.main.scale + 4 ++ let width = CGFloat(overlay.double("width", default: isWalletText ? Double(naturalTextWidth) : Double(size))) ++ let frame = UIView() ++ frame.translatesAutoresizingMaskIntoConstraints = false ++ frame.backgroundColor = UIColor(nativeListHex: overlay.string("backgroundColor", default: "#FFFFFF"), fallback: .clear) ++ frame.layer.cornerRadius = min(width, height) / 2 ++ frame.clipsToBounds = true ++ leadingContainer.addSubview(frame) ++ selectorViews.append(frame) ++ let topLeft = overlay.string("position") == "topLeft" ++ leadingSlotConstraints.append(contentsOf: [ ++ frame.widthAnchor.constraint(equalToConstant: width), ++ frame.heightAnchor.constraint(equalToConstant: height), ++ topLeft ? frame.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: -offsetX) : frame.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: offsetX), ++ topLeft ? frame.topAnchor.constraint(equalTo: leadingContainer.topAnchor, constant: -offsetY) : frame.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: offsetY), ++ ]) ++ let content: UIView ++ if let image = overlay.dictionary("image") { ++ let imageView = OneKeyImageReusableView(frame: .zero) ++ bindImage(image, into: imageView, token: key, slot: 10 + index, variant: "generic") ++ selectorImages.append(imageView) ++ content = imageView ++ } else if !overlay.string("text").isEmpty { ++ let label = UILabel() ++ label.text = overlay.string("text") ++ label.textAlignment = .center ++ label.font = nativeListFont(ofSize: isWalletText ? 12 : 10, weight: isWalletText ? .regular : .medium) ++ label.textColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray) ++ if isWalletText { setLineHeight(label, text: overlay.string("text"), lineHeight: 16) } ++ content = label ++ } else { ++ let imageView = UIImageView(image: nativeListIcon(named: overlay.string("name"))) ++ imageView.contentMode = .scaleAspectFit ++ imageView.tintColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray) ++ content = imageView ++ } ++ content.translatesAutoresizingMaskIntoConstraints = false ++ frame.addSubview(content) ++ NSLayoutConstraint.activate([ ++ content.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: isWalletText ? 2 : inset), ++ content.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: isWalletText ? -2 : -inset), ++ content.topAnchor.constraint(equalTo: frame.topAnchor, constant: isWalletText ? 0 : inset), ++ content.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: isWalletText ? 0 : -inset), ++ ]) ++ } ++ if let fallbackIcon = visual.dictionary("fallbackIcon"), sources.isEmpty { ++ fallbackLabel.isHidden = true ++ leadingIconImageView.isHidden = false ++ leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name")) ++ leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray) ++ if currentItem?.data.string("presentation") == "walletSidebar", currentItem?.data["height"] != nil, fallbackIcon.string("name") == "LockSolid" { ++ leadingIconWidth.constant = 40 ++ leadingIconHeight.constant = 40 ++ leadingContainer.clipsToBounds = false ++ } ++ } ++ if visual.string("borderStyle") == "dashed" { ++ let border = CAShapeLayer() ++ border.strokeColor = UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray).cgColor ++ border.fillColor = UIColor.clear.cgColor ++ border.lineWidth = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil ? 1 : 2 ++ border.lineDashPattern = [4, 4] ++ let borderInset = border.lineWidth / 2 ++ border.path = UIBezierPath(ovalIn: CGRect(x: borderInset, y: borderInset, width: leadingWidth.constant - border.lineWidth, height: leadingHeight.constant - border.lineWidth)).cgPath ++ leadingContainer.layer.addSublayer(border) ++ selectorBorder = border + } + NSLayoutConstraint.activate(leadingSlotConstraints) + } +@@ -2439,6 +2941,11 @@ final class NativeListCell: UICollectionViewCell { + switch accessory.string("kind") { + case "value": + showAccessory(textIndex, accessory.string("text")) ++ if item.data.string("presentation") == "networkSelector" && item.data["height"] != nil { ++ accessoryButtons[textIndex].contentHorizontalAlignment = .trailing ++ accessoryButtons[textIndex].titleLabel?.textAlignment = .right ++ } ++ applyValueSegments(accessory.dictionaries("textSegments"), to: accessoryButtons[textIndex], theme: theme) + textIndex += 1 + case "valuePair": + showValuePairAccessory(textIndex, accessory, theme: theme) +@@ -2519,7 +3026,7 @@ final class NativeListCell: UICollectionViewCell { + state == "unchecked" ? nil : nativeListIcon(named: glyphName), + for: .normal + ) +- checkboxButton.tintColor = checkboxUncheckedColor ++ checkboxButton.tintColor = checkboxIconColor + // A disabled ListItem already applies 0.5 to its complete content. Avoid + // multiplying that opacity on the nested control a second time. + checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 +@@ -2543,11 +3050,24 @@ final class NativeListCell: UICollectionViewCell { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight ++ if currentItem?.data.string("presentation") == "walletSidebar" { ++ // OneKey patch: attributed paragraphs must preserve wallet name alignment and tail ellipsis. ++ paragraphStyle.alignment = label.textAlignment ++ paragraphStyle.lineBreakMode = label.lineBreakMode ++ } + var attributes: [NSAttributedString.Key: Any] = [ + .font: label.font as Any, + .foregroundColor: label.textColor as Any, + .paragraphStyle: paragraphStyle, + ] ++ if (currentItem?.data["height"] != nil && (["accountSelector", "walletSidebar"].contains(currentItem?.data.string("presentation") ?? "") || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector")) || currentItem?.type == "system" && currentItem?.data.string("variant") == "warning" { ++ // OneKey patch: React Native centers font metrics inside explicit line heights. ++ let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) ++ // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. ++ let isSelectorHeading = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && lineHeight == 20 ++ let scale = window?.screen.scale ?? traitCollection.displayScale ++ attributes[.baselineOffset] = isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset ++ } + if letterSpacing != 0 { attributes[.kern] = letterSpacing } + label.attributedText = NSAttributedString(string: text, attributes: attributes) + } +@@ -2563,7 +3083,20 @@ final class NativeListCell: UICollectionViewCell { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.minimumLineHeight = lineHeight + paragraphStyle.maximumLineHeight = lineHeight +- paragraphStyle.alignment = .center ++ let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" ++ let isSelectorSummary = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" ++ (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil ++ // OneKey patch: summary text uses its source line box; currency retains trailing alignment. ++ paragraphStyle.alignment = isSelectorValue ? .right : isSelectorSummary ? .natural : .center ++ if isSelectorSummary { ++ button.contentHorizontalAlignment = .leading ++ button.titleLabel?.textAlignment = .natural ++ } ++ if isSelectorValue { ++ button.contentHorizontalAlignment = .trailing ++ button.titleLabel?.textAlignment = .right ++ } ++ let baselineOffset: CGFloat = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil ? max(0, (lineHeight - font.lineHeight) / 2) : 0 + button.setAttributedTitle( + NSAttributedString( + string: text, +@@ -2571,6 +3104,7 @@ final class NativeListCell: UICollectionViewCell { + .font: font, + .foregroundColor: color, + .paragraphStyle: paragraphStyle, ++ .baselineOffset: baselineOffset, + ] + ), + for: .normal +@@ -2671,6 +3205,9 @@ final class NativeListCell: UICollectionViewCell { + switch tone.isEmpty ? defaultTone : tone { + case "positive": return nativeListColor(theme, "positive", "#218358") + case "negative": return nativeListColor(theme, "negative", "#CE2C31") ++ // OneKey patch: account warnings and hidden balances use existing theme tokens. ++ case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") ++ case "caution": return nativeListColor(theme, "caution", "#AB6400") + case "secondary": return nativeListColor(theme, "secondaryText", "#646464") + default: return nativeListColor(theme, "primaryText", "#202020") + } +@@ -2682,10 +3219,10 @@ final class NativeListCell: UICollectionViewCell { + button.isHidden = false + button.isEnabled = !data.bool("disabled") + button.alpha = button.isEnabled ? 1 : 0.4 +- let tintColor = UIColor( +- nativeListHex: data.string("tintColor", default: "#646464"), +- fallback: .darkGray +- ) ++ let isAccountCreate = currentItem?.data.string("presentation") == "accountSelector" && currentItem?.data["height"] != nil && data.string("name") == "PlusSmallOutline" ++ let tintColor = data["tintColor"] == nil && isAccountCreate ++ ? nativeListColor(currentTheme, "iconSubdued", "#8D8D8D") ++ : UIColor(nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) + button.tintColor = tintColor + if let image = nativeListIcon(named: data.string("name")) { + button.setImage(image, for: .normal) +@@ -2695,7 +3232,12 @@ final class NativeListCell: UICollectionViewCell { + ) + } + let isDrillIn = data.string("kind") == "chevron" +- let size: CGFloat = isDrillIn ? 24 : 36 ++ let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" && !isDrillIn ++ let size: CGFloat = isDrillIn ? 24 : isAccountIcon && !isAccountCreate ? 38 : 36 ++ if isAccountCreate { button.layer.cornerRadius = 8 } ++ if isAccountIcon { rootStack.setCustomSpacing(5, after: mainStack) } ++ button.accessibilityIdentifier = data["testID"] as? String ++ button.accessibilityLabel = data["accessibilityLabel"] as? String + if !isDrillIn, !data.string("actionKey").isEmpty { + // Reproduce the trailing edge of IconButton's m=-7 while keeping its + // full 36-point frame for padding/highlight behavior. +@@ -2726,8 +3268,15 @@ final class NativeListCell: UICollectionViewCell { + into imageView: OneKeyImageReusableView, + token: String, + slot: Int, +- variant: String ++ variant: String, ++ onLoad: (() -> Void)? = nil, ++ onError: (() -> Void)? = nil, ++ retryAttempt: Int = 0 + ) { ++ let imageID = ObjectIdentifier(imageView) ++ selectorImageRetries.removeValue(forKey: imageID)?.cancel() ++ let expectedEpoch = bindingEpoch ++ let retryLimit = max(0, source.int("retryTimes", default: 0)) + let headersJson: String? + if let headers = source.dictionary("headers"), + JSONSerialization.isValidJSONObject(headers), +@@ -2743,10 +3292,27 @@ final class NativeListCell: UICollectionViewCell { + contentFit: source.string("contentFit", default: "cover"), + cachePolicy: source.string("cachePolicy", default: "memory-disk"), + autoplay: source.bool("autoplay"), +- recyclingKey: "\(token):\(slot)", +- optimizeTos: source["optimizeTos"] == nil || source.bool("optimizeTos"), ++ recyclingKey: retryAttempt == 0 ? "\(token):\(slot)" : "\(token):\(slot):retry:\(retryAttempt)", ++ optimizeTos: retryAttempt == 0 && (source["optimizeTos"] == nil || source.bool("optimizeTos")), + overscan: source["overscan"] == nil ? 1.1 : source.double("overscan"), +- loadingStrategy: source.string("loadingStrategy", default: "static") ++ loadingStrategy: source.string("loadingStrategy", default: "static"), ++ onLoad: retryLimit == 0 ? onLoad : { [weak self] in ++ guard let self, self.bindingEpoch == expectedEpoch else { return } ++ self.selectorImageRetries.removeValue(forKey: imageID)?.cancel() ++ onLoad?() ++ }, ++ onError: retryLimit == 0 ? onError : { [weak self, weak imageView] in ++ guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return } ++ guard retryAttempt < retryLimit else { onError?(); return } ++ guard self.selectorImageRetries[imageID] == nil else { return } ++ let retry = DispatchWorkItem { [weak self, weak imageView] in ++ guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return } ++ self.selectorImageRetries.removeValue(forKey: imageID) ++ self.bindImage(source, into: imageView, token: token, slot: slot, variant: variant, onLoad: onLoad, onError: onError, retryAttempt: retryAttempt + 1) ++ } ++ self.selectorImageRetries[imageID] = retry ++ DispatchQueue.main.asyncAfter(deadline: .now() + Double(Int.random(in: 0...2)), execute: retry) ++ } + ) + } + +@@ -2837,12 +3403,17 @@ final class NativeListCell: UICollectionViewCell { + source: String, + slot: Int? = nil + ) -> NativeListActionOrigin { +- NativeListActionOrigin( ++ let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" ++ && source == "trailingAccessory" ++ && accessoryButtons.contains { $0 === sourceView } ++ && sourceView.bounds.width == 38 ++ return NativeListActionOrigin( + sourceView: sourceView, + ownerCell: self, + bindingEpoch: bindingEpoch, + source: source, +- slot: slot ++ slot: slot, ++ anchorInset: isAccountIcon ? 7 : 0 + ) + } + +@@ -2851,6 +3422,8 @@ final class NativeListCell: UICollectionViewCell { + } + + private func invalidateCurrentBinding() { ++ selectorImageRetries.values.forEach { $0.cancel() } ++ selectorImageRetries.removeAll() + guard currentItem != nil else { return } + onBindingInvalidated?(self, bindingEpoch) + bindingEpoch &+= 1 +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift +index 7dd7368..3f3b90a 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift +@@ -76,8 +76,31 @@ func nativeListTabularFont( + } + + func nativeListIcon(named name: String) -> UIImage? { ++ // OneKey patch: the connection indicator is a solid circle at its requested size. ++ if name == "Circle" { ++ return UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in ++ UIColor.black.setFill() ++ UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 24, height: 24)).fill() ++ }.withRenderingMode(.alwaysTemplate) ++ } + let assetName: String + switch name { ++ // OneKey patch: official selector icon geometry. ++ case "AccountErrorCustom": assetName = "onekey_selector_account_error_custom" ++ // OneKey patch: official selector icon geometry. ++ case "CrossedSmallSolid": assetName = "onekey_selector_crossed_small_solid" ++ // OneKey patch: official selector icon geometry. ++ case "AllNetworksSolid": assetName = "onekey_selector_all_networks_solid" ++ // OneKey patch: official selector icon geometry. ++ case "BotIllus": assetName = "onekey_selector_bot_illus" ++ // OneKey patch: official selector icon geometry. ++ case "AppleBrand": assetName = "onekey_selector_apple_brand" ++ // OneKey patch: official selector icon geometry. ++ case "GoogleIllus": assetName = "onekey_selector_google_illus" ++ // OneKey patch: official selector icon geometry. ++ case "LockSolid": assetName = "onekey_selector_lock_solid" ++ // OneKey patch: official selector icon geometry. ++ case "GlobusOutline": assetName = "onekey_selector_globus_outline" + case "ArrowBottomOutline": assetName = "onekey_arrow_bottom" + case "ArrowTopOutline": assetName = "onekey_arrow_top" + case "ChartTrendingUpOutline": assetName = "onekey_chart_trending_up" +@@ -106,5 +129,5 @@ func nativeListIcon(named name: String) -> UIImage? { + default: return nil + } + return UIImage(named: assetName, in: NativeListResources.bundle, compatibleWith: nil)? +- .withRenderingMode(.alwaysTemplate) ++ .withRenderingMode(["GoogleIllus", "BotIllus", "AccountErrorCustom"].contains(name) ? .alwaysOriginal : .alwaysTemplate) + } +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift +index d89c4c2..127acb3 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift +@@ -1,4 +1,5 @@ + import Foundation ++import CoreFoundation + import UIKit + import UniformTypeIdentifiers + +@@ -485,7 +486,7 @@ final class NativeListView: UIView { + ) + flowLayout.stickyItemIndexes = config.stickyHeaders + ? Set(config.items.enumerated().compactMap { +- $0.element.type == "sectionHeader" && $0.element.data.string("variant") != "summary" ++ $0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary" + ? $0.offset + : nil + }) +@@ -512,11 +513,12 @@ final class NativeListView: UIView { + interactiveReorderCell = nil + return + } +- if item.type == "walletGroup", gesture.location(in: cell).y > 68 { +- interactiveReorderSource = nil +- interactiveReorderCell = nil +- return +- } ++ // OneKey patch: a hidden wallet child starts dragging its whole logical group. ++ // if item.type == "walletGroup", gesture.location(in: cell).y > 68 { ++ // interactiveReorderSource = nil ++ // interactiveReorderCell = nil ++ // return ++ // } + interactiveReorderSource = (item.key, indexPath.item) + interactiveReorderCell = cell + interactiveReorderUsesAtomicTargeting = item.type == "identity" && +@@ -932,7 +934,7 @@ final class NativeListView: UIView { + theme: config.theme, + layout: config.layout, + itemIndex: itemIndex, +- selected: config.selectedKeys.contains(item.key), ++ selected: item.data.bool("selected") || config.selectedKeys.contains(item.key), + checkboxState: { [weak self] item, target, fallback in + self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback + } +@@ -943,7 +945,8 @@ final class NativeListView: UIView { + } + + private func handleRowPress(_ item: NativeListItem, origin: NativeListActionOrigin?) { +- guard let config, !item.data.bool("disabled") else { return } ++ // OneKey patch: missing-address rows keep accessory actions available. ++ guard let config, !item.data.bool("disabled"), !item.data.bool("pressDisabled") else { return } + if config.rowPressToggles && item.isSelectable && config.selectionMode != "none" { + updateSelection(target: NativeSelectionTarget(scope: "row", key: item.key), sourceKey: item.key) + return +@@ -1037,7 +1040,7 @@ final class NativeListView: UIView { + let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue } + cell.updateSelection( + item: item, +- selected: config.selectedKeys.contains(item.key), ++ selected: item.data.bool("selected") || config.selectedKeys.contains(item.key), + checkboxState: checkboxState + ) + } +@@ -1080,19 +1083,73 @@ final class NativeListView: UIView { + return zip(current.items, next.items).allSatisfy { old, new in + guard old.key == new.key, old.type == new.type else { return false } + if old.content == new.content { return true } +- guard old.type == "sectionHeader", +- old.data.string("variant") == "summary", +- new.data.string("variant") == "summary" else { return false } +- var oldData = old.data +- var newData = new.data +- oldData.removeValue(forKey: "title") +- oldData.removeValue(forKey: "value") +- newData.removeValue(forKey: "title") +- newData.removeValue(forKey: "value") ++ // OneKey patch: controlled echoes can also update row and checkbox selection fields. ++ // guard old.type == "sectionHeader", ++ // old.data.string("variant") == "summary", ++ // new.data.string("variant") == "summary" else { return false } ++ // var oldData = old.data ++ // var newData = new.data ++ // oldData.removeValue(forKey: "title") ++ // oldData.removeValue(forKey: "value") ++ // newData.removeValue(forKey: "title") ++ // newData.removeValue(forKey: "value") ++ let controlled = next.selectionMode == "single" || next.selectionMode == "multiple" ++ guard let oldData = selectionComparisonData(old.data, controlled: controlled), ++ let newData = selectionComparisonData(new.data, controlled: controlled) else { return false } + return jsonData(oldData) == jsonData(newData) + } + } + ++ // OneKey patch: remove only fields that the existing lightweight binder refreshes. ++ private func selectionComparisonData(_ data: [String: Any], controlled: Bool) -> [String: Any]? { ++ guard let type = data["type"] as? String else { return nil } ++ var result = data ++ if let selected = result["selected"] { ++ guard CFGetTypeID(selected as CFTypeRef) == CFBooleanGetTypeID() else { return nil } ++ result.removeValue(forKey: "selected") ++ } ++ if type == "walletGroup" { ++ guard let parent = data["parent"] as? [String: Any], parent["type"] as? String == "identity", ++ let children = data["children"] as? [[String: Any]], ++ let normalizedParent = selectionComparisonData(parent, controlled: false) else { return nil } ++ var normalizedChildren: [[String: Any]] = [] ++ for child in children { ++ guard child["type"] as? String == "identity", ++ let normalized = selectionComparisonData(child, controlled: false) else { return nil } ++ normalizedChildren.append(normalized) ++ } ++ result["parent"] = normalizedParent ++ result["children"] = normalizedChildren ++ } ++ if type == "sectionHeader", data["variant"] as? String == "summary" { ++ result.removeValue(forKey: "title") ++ result.removeValue(forKey: "value") ++ } ++ guard controlled else { return result } ++ func checkboxData(_ value: Any) -> [String: Any]? { ++ guard var checkbox = value as? [String: Any], checkbox["kind"] as? String == "checkbox" else { return nil } ++ if let state = checkbox["state"] { ++ guard let state = state as? String, ["checked", "unchecked", "indeterminate"].contains(state) else { return nil } ++ } ++ checkbox.removeValue(forKey: "state") ++ return checkbox ++ } ++ if ["dataRow", "sectionHeader", "action"].contains(type), let checkbox = result["checkbox"] { ++ guard let normalized = checkboxData(checkbox) else { return nil } ++ result["checkbox"] = normalized ++ } ++ if type == "identity", let trailing = result["trailing"] { ++ guard var accessories = trailing as? [[String: Any]], accessories.count <= 2, ++ accessories.filter({ $0["kind"] as? String == "checkbox" }).count <= 1 else { return nil } ++ for index in accessories.indices where accessories[index]["kind"] as? String == "checkbox" { ++ guard let normalized = checkboxData(accessories[index]) else { return nil } ++ accessories[index] = normalized ++ } ++ result["trailing"] = accessories ++ } ++ return result ++ } ++ + private func dictionariesEqual(_ lhs: [String: Any]?, _ rhs: [String: Any]?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): return true +@@ -1107,16 +1164,36 @@ final class NativeListView: UIView { + } + + private func rowHeight(_ item: NativeListItem) -> CGFloat { ++ // OneKey patch: honor selector baseline geometry; keep compact drag sizing. ++ if item.type != "walletGroup", item.data["height"] != nil { return CGFloat(item.data.double("height")) } + if item.type == "system", item.data.string("variant") == "spacer" { + return CGFloat(item.data.int("height")) + } ++ // OneKey patch: warning height follows the current native font and available width. ++ if item.type == "system", item.data.string("variant") == "warning" { ++ let textWidth = max(1, collectionView.bounds.width - (config?.contentPaddingHorizontal ?? 0) * 2 - 24) ++ func textHeight(_ key: String, weight: NativeListFontWeight) -> CGFloat { ++ let paragraph = NSMutableParagraphStyle() ++ paragraph.minimumLineHeight = 20 ++ paragraph.maximumLineHeight = 20 ++ return ceil((item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: 14, weight: weight), .paragraphStyle: paragraph], context: nil).height / 20) * 20 ++ } ++ return 32 + textHeight("title", weight: .medium) + textHeight("message", weight: .regular) ++ } + if item.type == "walletGroup" { + if item.key == interactiveReorderCompactKey { return 68 } + let childCount = item.data.dictionaries("children").count +- return CGFloat((childCount + 1) * 68 + childCount * 12) ++ // OneKey patch: wallet badges contribute their own member heights. ++ // return CGFloat((childCount + 1) * 68 + childCount * 12) ++ let members = [item.data.dictionary("parent")].compactMap { $0 } + item.data.dictionaries("children") ++ return members.reduce(CGFloat(childCount * 12 + (members.first?["height"] != nil ? 2 : 0))) { total, data in ++ total + CGFloat(data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) ++ } + } + if item.type == "identity", item.data.string("presentation") == "walletSidebar" { +- return 68 ++ // OneKey patch: default sidebar badge geometry is 24 points taller. ++ // return 68 ++ return item.data.dictionaries("badges").isEmpty ? 68 : 92 + } + if item.type == "identity", item.data.string("presentation") == "networkSelector" { + return 47 +@@ -1268,7 +1345,7 @@ final class NativeListView: UIView { + actionAnchorCounter &+= 1 + let generation = config?.generation ?? 0 + let token = "\(actionAnchorInstanceID):\(generation):\(actionAnchorCounter):\(origin.bindingEpoch)" +- let rect = sourceView.convert(sourceView.bounds, to: window) ++ let rect = sourceView.convert(sourceView.bounds, to: window).insetBy(dx: origin.anchorInset, dy: origin.anchorInset) + let record = ActionAnchorRecord(token: token, origin: origin) + actionAnchor = record + var anchor: [String: Any] = [ +@@ -1604,7 +1681,9 @@ private struct NativeListSectionIndexEntry { + let position: Int + } + +-private final class NativeListSectionIndexView: UIControl { ++// OneKey patch: arbitrate index scrubbing against ancestor dismissal gestures. ++// private final class NativeListSectionIndexView: UIControl { ++private final class NativeListSectionIndexView: UIControl, UIGestureRecognizerDelegate { + var onSelect: ((Int, Bool) -> Void)? + var onInteractionEnded: (() -> Void)? + private var titles: [String] = [] +@@ -1620,6 +1699,27 @@ private final class NativeListSectionIndexView: UIControl { + accessibilityLabel = "Section index" + accessibilityTraits = [.adjustable] + isExclusiveTouch = true ++ ++ // UIControl tracking alone cannot prevent an ancestor sheet pan from taking the touch. ++ // Recognize immediately, but keep delivering touches to the existing tracking methods. ++ let scrubGesture = UILongPressGestureRecognizer(target: nil, action: nil) ++ scrubGesture.minimumPressDuration = 0 ++ scrubGesture.allowableMovement = .greatestFiniteMagnitude ++ scrubGesture.cancelsTouchesInView = false ++ scrubGesture.delaysTouchesEnded = false ++ scrubGesture.delegate = self ++ addGestureRecognizer(scrubGesture) ++ } ++ ++ func gestureRecognizer( ++ _ gestureRecognizer: UIGestureRecognizer, ++ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer ++ ) -> Bool { ++ guard otherGestureRecognizer is UIPanGestureRecognizer, ++ let otherView = otherGestureRecognizer.view, ++ otherView !== self else { return false } ++ // The dependency applies only to touches starting in this index, including moves outside it. ++ return isDescendant(of: otherView) + } + + required init?(coder: NSCoder) { +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/icon.svg +new file mode 100644 +index 0000000..ba0aa29 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/icon.svg +@@ -0,0 +1,14 @@ ++ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/icon.svg +new file mode 100644 +index 0000000..fe9dd70 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/icon.svg +@@ -0,0 +1,12 @@ ++ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/icon.svg +new file mode 100644 +index 0000000..64ec28d +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/icon.svg +@@ -0,0 +1,6 @@ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/icon.svg +new file mode 100644 +index 0000000..53b5043 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/icon.svg +@@ -0,0 +1,15 @@ ++ ++ ++ ++ ++ ++ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/icon.svg +new file mode 100644 +index 0000000..0c5feb4 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/icon.svg +@@ -0,0 +1,3 @@ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/icon.svg +new file mode 100644 +index 0000000..d388f1e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/icon.svg +@@ -0,0 +1,7 @@ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/icon.svg +new file mode 100644 +index 0000000..ab862c4 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/icon.svg +@@ -0,0 +1,18 @@ ++ ++ ++ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/Contents.json +new file mode 100644 +index 0000000..ae4d23e +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/Contents.json +@@ -0,0 +1,15 @@ ++{ ++ "images": [ ++ { ++ "filename": "icon.svg", ++ "idiom": "universal" ++ } ++ ], ++ "info": { ++ "author": "xcode", ++ "version": 1 ++ }, ++ "properties": { ++ "preserves-vector-representation": true ++ } ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/icon.svg +new file mode 100644 +index 0000000..893a7a3 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/icon.svg +@@ -0,0 +1,7 @@ ++ ++ ++ +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js +index ce0b82d..438d88b 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js +@@ -6,7 +6,7 @@ import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, reso + import { serializePatches, validateSnapshot } from "./validation.js"; + import { NativeListWebEngine } from "./web/NativeListWebEngine.js"; + import { jsx as _jsx } from "react/jsx-runtime"; +-export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ ++export const NativeList = forwardRef(function NativeList({ + snapshot, + webVirtualizationEnabled = true, + onRowAction, +@@ -29,7 +29,14 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + webVirtualizationEnabledRef.current = webVirtualizationEnabled; + const validatedSnapshot = useMemo(() => validateSnapshot(snapshot), [snapshot]); + const snapshotRef = useRef(validatedSnapshot); +- snapshotRef.current = validatedSnapshot; ++ const snapshotPropRef = useRef(validatedSnapshot); ++ // An imperative snapshot survives host mounting until the snapshot prop changes. ++ if (snapshotPropRef.current !== validatedSnapshot) { ++ snapshotPropRef.current = validatedSnapshot; ++ snapshotRef.current = validatedSnapshot; ++ } ++ // OneKey patch: React refs can be ready before the DOM engine is mounted. ++ const pendingPatchesRef = useRef(undefined); + const appliedSnapshotRef = useRef(undefined); + const callbacksRef = useRef({}); + callbacksRef.current = { +@@ -61,6 +68,9 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + const engine = new NativeListWebEngine(host, snapshotRef.current, callbacksRef.current, webVirtualizationEnabledRef.current); + engineRef.current = engine; + appliedSnapshotRef.current = snapshotRef.current; ++ const pending = pendingPatchesRef.current; ++ pendingPatchesRef.current = undefined; ++ if (pending?.snapshot === snapshotRef.current) pending.batches.forEach(patches => engine.applyPatches(patches)); + const initial = initialScrollRef.current; + if (initial && !didApplyInitialScroll.current) { + didApplyInitialScroll.current = true; +@@ -89,6 +99,7 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + useImperativeHandle(forwardedRef, () => ({ + applySnapshot(nextSnapshot) { + const next = validateSnapshot(nextSnapshot); ++ pendingPatchesRef.current = undefined; + snapshotRef.current = next; + appliedSnapshotRef.current = next; + engineRef.current?.applySnapshot(next); +@@ -96,7 +107,14 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + applyPatches(patches) { + if (patches.length > 0) { + serializePatches(patches); +- engineRef.current?.applyPatches(patches); ++ // engineRef.current?.applyPatches(patches); ++ if (engineRef.current) engineRef.current.applyPatches(patches);else { ++ if (pendingPatchesRef.current?.snapshot !== snapshotRef.current) pendingPatchesRef.current = { ++ snapshot: snapshotRef.current, ++ batches: [] ++ }; ++ pendingPatchesRef.current.batches.push(patches); ++ } + } + }, + reconcileSelection(selectedKeys) { +@@ -164,4 +182,3 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + ref: setHostRef + }); + }); +-//# sourceMappingURL=NativeList.web.js.map +\ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js +index ef38e33..eabec38 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js +@@ -1,2 +1,3 @@ + "use strict"; +-//# sourceMappingURL=models.js.map +\ No newline at end of file ++ ++export {}; +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js +index f9ed44b..eae597c 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js +@@ -124,6 +124,26 @@ function assertLeadingVisual(visual, path) { + assertImage(visual.image, `${path}.image`); + assertVisualShape(visual.shape, `${path}.shape`); + assertText(visual.cornerIcon?.name, `${path}.cornerIcon.name`); ++ // OneKey patch: validate optional selector overlays at the JSON boundary. ++ assertText(visual.fallbackIcon?.name, `${path}.fallbackIcon.name`); ++ if ((visual.overlays?.length ?? 0) > 2) fail(`${path}.overlays`, 'supports at most two overlays'); ++ visual.overlays?.forEach((overlay, index) => { ++ if (!['topLeft', 'bottomRight'].includes(overlay.position)) fail(`${path}.overlays[${index}].position`, 'must be topLeft or bottomRight'); ++ if (overlay.size !== undefined && (overlay.size <= 0 || overlay.size > 40)) fail(`${path}.overlays[${index}].size`, 'must be within 1...40'); ++ if (overlay.padding !== undefined && (!Number.isFinite(overlay.padding) || overlay.padding < 0 || overlay.padding * 2 >= (overlay.size ?? 20))) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); ++ if (overlay.offset !== undefined && (!Number.isFinite(overlay.offset) || overlay.offset < 0 || overlay.offset > 20)) fail(`${path}.overlays[${index}].offset`, 'must be within 0...20'); ++ for (const field of ['width', 'height']) { ++ const value = overlay[field]; ++ if (value !== undefined && (!Number.isFinite(value) || value <= 0 || value > 40)) fail(`${path}.overlays[${index}].${field}`, 'must be within 1...40'); ++ } ++ for (const field of ['offsetX', 'offsetY']) { ++ const value = overlay[field]; ++ if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 20)) fail(`${path}.overlays[${index}].${field}`, 'must be within 0...20'); ++ } ++ if (overlay.padding !== undefined && overlay.padding * 2 >= Math.min(overlay.width ?? overlay.size ?? 20, overlay.height ?? overlay.size ?? 20)) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); ++ assertImage(overlay.image, `${path}.overlays[${index}].image`); ++ assertText(overlay.text, `${path}.overlays[${index}].text`); ++ }); + if (visual.kind === 'token') { + assertImage(visual.networkImage, `${path}.networkImage`); + } +@@ -169,6 +189,10 @@ function assertWalletGroup(row, path) { + } + function assertRow(row, index, path = `rows[${index}]`) { + assertKey(row.key, `${path}.key`); ++ // OneKey patch: explicit dimensions and opacity cannot corrupt list layout. ++ if (row.height !== undefined && (row.height < 0 || row.height > 4096)) fail(`${path}.height`, 'must be within 0...4096'); ++ if (row.heightRounding !== undefined && (row.height === undefined || !['floor', 'nearest'].includes(row.heightRounding))) fail(`${path}.heightRounding`, 'requires an explicit height and must be floor or nearest'); ++ if (row.opacity !== undefined && (row.opacity < 0 || row.opacity > 1)) fail(`${path}.opacity`, 'must be within 0...1'); + if (row.groupId && !row.groupPosition) { + fail(`${path}.groupPosition`, 'is required when groupId is present'); + } +@@ -186,6 +210,16 @@ function assertRow(row, index, path = `rows[${index}]`) { + } + assertText(row.title, `${path}.title`); + assertText(row.subtitle, `${path}.subtitle`); ++ // OneKey patch: selector text segments truncate independently. ++ row.subtitleSegments?.forEach((segment, segmentIndex) => { ++ assertText(segment.text, `${path}.subtitleSegments[${segmentIndex}].text`); ++ if (segment.tone !== undefined && !['primary', 'secondary', 'disabled', 'caution', 'positive', 'negative'].includes(segment.tone)) fail(`${path}.subtitleSegments[${segmentIndex}].tone`, 'invalid selector text tone'); ++ }); ++ let previousMatchEnd = 0; ++ row.titleMatch?.forEach(match => { ++ if (!Number.isInteger(match.start) || !Number.isInteger(match.end) || match.start < previousMatchEnd || match.end <= match.start || match.end > row.title.length) fail(`${path}.titleMatch`, 'must contain ordered, non-overlapping UTF-16 ranges inside title'); ++ previousMatchEnd = match.end; ++ }); + assertText(row.tertiary, `${path}.tertiary`); + if (row.tertiaryTone !== undefined && !['secondary', 'info'].includes(row.tertiaryTone)) { + fail(`${path}.tertiaryTone`, 'must be secondary or info'); +@@ -293,9 +327,12 @@ function assertRow(row, index, path = `rows[${index}]`) { + assertTrailingAccessories(row.trailing, `${path}.trailing`); + break; + case 'system': +- if (!['loading', 'retry', 'noMatch', 'end', 'spacer'].includes(row.variant)) { +- fail(`${path}.variant`, 'must be loading, retry, noMatch, end, or spacer'); ++ if ( ++ // OneKey patch: deprecated-wallet warnings retain the original scrolling semantics. ++ !['loading', 'retry', 'noMatch', 'end', 'spacer', 'warning'].includes(row.variant)) { ++ fail(`${path}.variant`, 'must be loading, retry, noMatch, end, spacer, or warning'); + } ++ if (row.variant === 'warning') assertText(row.title, `${path}.title`); + if (row.variant !== 'spacer') { + assertText(row.message, `${path}.message`); + } +@@ -397,6 +434,12 @@ export function validateSnapshot(snapshot) { + } + function assertPatchChanges(patch, index) { + const path = `patches[${index}].changes`; ++ // OneKey patch: partial balance updates retain a valid, current accessibility label. ++ if ('accessibilityLabel' in patch.changes) { ++ assertText(patch.changes.accessibilityLabel, `${path}.accessibilityLabel`); ++ } ++ // OneKey patch: partial updates may refer to an existing height but still require a valid policy. ++ if ('heightRounding' in patch.changes && patch.changes.heightRounding !== undefined && !['floor', 'nearest'].includes(patch.changes.heightRounding)) fail(`${path}.heightRounding`, 'must be floor or nearest'); + if (patch.changes.revision !== undefined && (!Number.isSafeInteger(patch.changes.revision) || patch.changes.revision < 0)) { + fail(`${path}.revision`, 'must be a non-negative safe integer'); + } +@@ -559,4 +602,3 @@ export function serializeSnapshot(snapshot) { + export function serializePatches(patches) { + return JSON.stringify(validatePatches(patches)); + } +-//# sourceMappingURL=validation.js.map +\ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js +new file mode 100644 +index 0000000..fff4081 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js +@@ -0,0 +1,258 @@ ++// OneKey patch: avatar generation and PNG bytes stay in this external worker. ++// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64 ++// Permission is hereby granted, free of charge, to any person obtaining a copy ++// of this software and associated documentation files (the "Software"), to deal ++// in the Software without restriction, including without limitation the rights ++// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ++// copies of the Software, and to permit persons to whom the Software is ++// furnished to do so, subject to the following conditions: ++// The above copyright notice and this permission notice shall be included in ++// all copies or substantial portions of the Software. ++// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ++// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ++// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ++// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ++// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ++// THE SOFTWARE. ++ ++const PREFIX = 'onekey-avatar://blockie/v1/'; ++const DATABASE = 'onekey-native-list-avatar-v1'; ++const MAX_DISK_BYTES = 32 * 1024 * 1024; ++const MAX_DISK_ENTRIES = 2048; ++const MAX_CONCURRENT_LOADS = 2; ++const requests = new Map(); ++const images = new Map(); ++const jobs = new Map(); ++const queue = []; ++let activeLoads = 0; ++let databasePromise; ++ ++function openDatabase() { ++ if (!databasePromise) { ++ databasePromise = new Promise((resolve, reject) => { ++ const request = indexedDB.open(DATABASE, 1); ++ request.onupgradeneeded = () => { ++ const store = request.result.createObjectStore('images', { keyPath: 'uri' }); ++ store.createIndex('accessed', 'accessed'); ++ request.result.createObjectStore('metadata'); ++ }; ++ let blocked = false; ++ request.onsuccess = () => { ++ const database = request.result; ++ if (blocked) { database.close(); return; } ++ database.onversionchange = () => { database.close(); databasePromise = undefined; }; ++ resolve(database); ++ }; ++ request.onerror = () => reject(request.error); ++ request.onblocked = () => { ++ blocked = true; ++ reject(new Error('Avatar cache database is blocked')); ++ }; ++ }).catch(() => undefined); ++ } ++ return databasePromise; ++} ++ ++async function readDisk(uri) { ++ const database = await openDatabase(); ++ if (!database) return undefined; ++ return new Promise((resolve) => { ++ try { ++ const transaction = database.transaction('images', 'readwrite'); ++ const store = transaction.objectStore('images'); ++ const request = store.get(uri); ++ let blob; ++ request.onsuccess = () => { ++ const record = request.result; ++ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) { ++ blob = record.blob; ++ store.put({ ...record, accessed: Date.now() }); ++ } ++ }; ++ transaction.oncomplete = () => resolve(blob); ++ transaction.onerror = transaction.onabort = () => resolve(undefined); ++ } catch { resolve(undefined); } ++ }); ++} ++ ++async function writeDisk(uri, blob) { ++ const database = await openDatabase(); ++ if (!database || blob.size > MAX_DISK_BYTES) return; ++ await new Promise((resolve) => { ++ try { ++ const transaction = database.transaction(['images', 'metadata'], 'readwrite'); ++ const store = transaction.objectStore('images'); ++ const metadata = transaction.objectStore('metadata'); ++ const previous = store.get(uri); ++ previous.onsuccess = () => { ++ const counter = metadata.get('size'); ++ counter.onsuccess = () => { ++ const size = counter.result || { bytes: 0, count: 0 }; ++ size.bytes += blob.size - (previous.result?.blob?.size || 0); ++ size.count += previous.result ? 0 : 1; ++ store.put({ uri, blob, accessed: Date.now() }); ++ const save = () => metadata.put(size, 'size'); ++ if (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES) { save(); return; } ++ const cursor = store.index('accessed').openCursor(); ++ cursor.onsuccess = () => { ++ const item = cursor.result; ++ if (!item || (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES)) { save(); return; } ++ if (item.value.uri !== uri) { ++ size.bytes -= item.value.blob.size; ++ size.count -= 1; ++ item.delete(); ++ } ++ item.continue(); ++ }; ++ }; ++ }; ++ transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); ++ } catch { resolve(); } ++ }); ++} ++ ++// PRNG and HSL conversion adapted from ethereum-blockies-base64 1.0.2 (MIT), MyCrypto. ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/main.js ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/hsl2rgb.js ++// Preserve signed shifts, color order, and RGB rounding to match V1's decoded pixels. ++async function generateBlob(seed) { ++ const state = [0, 0, 0, 0]; ++ for (let i = 0; i < seed.length; i += 1) { ++ state[i % 4] = (state[i % 4] << 5) - state[i % 4] + seed.charCodeAt(i); ++ } ++ const rand = () => { ++ const t = state[0] ^ (state[0] << 11); ++ state[0] = state[1]; ++ state[1] = state[2]; ++ state[2] = state[3]; ++ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8); ++ return (state[3] >>> 0) / ((1 << 31) >>> 0); ++ }; ++ const hue = (p, q, value) => { ++ let t = value; ++ if (t < 0) t += 1; ++ if (t > 1) t -= 1; ++ if (t < 1 / 6) return p + (q - p) * 6 * t; ++ if (t < 1 / 2) return q; ++ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; ++ return p; ++ }; ++ const color = () => { ++ const h = Math.floor(rand() * 360) / 360; ++ const s = (rand() * 60 + 40) / 100; ++ const l = ((rand() + rand() + rand() + rand()) * 25) / 100; ++ const q = l < 0.5 ? l * (1 + s) : l + s - l * s; ++ const p = 2 * l - q; ++ const rgb = s === 0 ? [l, l, l] : [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)]; ++ return `rgb(${rgb.map((value) => Math.round(value * 255)).join(',')})`; ++ }; ++ const foreground = color(); ++ const background = color(); ++ const spot = color(); ++ const canvas = new OffscreenCanvas(128, 128); ++ const context = canvas.getContext('2d'); ++ if (!context) throw new Error('Avatar canvas is unavailable'); ++ context.fillStyle = background; ++ context.fillRect(0, 0, 128, 128); ++ for (let row = 0; row < 8; row += 1) { ++ for (let column = 0; column < 4; column += 1) { ++ const value = Math.floor(rand() * 2.3); ++ if (value === 0) continue; ++ context.fillStyle = value === 1 ? foreground : spot; ++ context.fillRect(column * 16, row * 16, 16, 16); ++ context.fillRect((7 - column) * 16, row * 16, 16, 16); ++ } ++ } ++ return canvas.convertToBlob({ type: 'image/png' }); ++} ++ ++function release(id) { ++ const uri = requests.get(id); ++ requests.delete(id); ++ const image = images.get(uri); ++ image?.references.delete(id); ++ if (image && image.references.size === 0) { ++ URL.revokeObjectURL(image.url); ++ images.delete(uri); ++ } ++ const job = jobs.get(uri); ++ job?.ids.delete(id); ++ if (job && job.ids.size === 0 && !job.active) { ++ jobs.delete(uri); ++ const index = queue.indexOf(job); ++ if (index !== -1) queue.splice(index, 1); ++ } ++} ++ ++async function runJob(job) { ++ let blob = await readDisk(job.uri); ++ if (blob) { ++ try { ++ const bitmap = await createImageBitmap(blob); ++ const valid = bitmap.width === 128 && bitmap.height === 128; ++ bitmap.close(); ++ if (!valid) blob = undefined; ++ } catch { blob = undefined; } ++ } ++ if (!blob && job.ids.size) { ++ blob = await generateBlob(job.seed); ++ await writeDisk(job.uri, blob); ++ } ++ if (!blob || !job.ids.size) return; ++ const image = { url: URL.createObjectURL(blob), references: new Set() }; ++ images.set(job.uri, image); ++ job.ids.forEach((id) => { ++ if (requests.get(id) !== job.uri) return; ++ image.references.add(id); ++ postMessage({ type: 'resolved', id, url: image.url }); ++ }); ++ if (!image.references.size) { URL.revokeObjectURL(image.url); images.delete(job.uri); } ++} ++ ++function pump() { ++ while (activeLoads < MAX_CONCURRENT_LOADS && queue.length) { ++ const job = queue.shift(); ++ if (jobs.get(job.uri) !== job || !job.ids.size) continue; ++ job.active = true; ++ activeLoads += 1; ++ runJob(job).catch(() => { ++ job.ids.forEach((id) => { ++ if (requests.get(id) === job.uri) postMessage({ type: 'error', id }); ++ }); ++ }).finally(() => { ++ if (jobs.get(job.uri) === job) jobs.delete(job.uri); ++ activeLoads -= 1; ++ pump(); ++ }); ++ } ++} ++ ++onmessage = ({ data }) => { ++ if (!data || !Number.isSafeInteger(data.id)) return; ++ if (data.type === 'release') { release(data.id); return; } ++ if (data.type !== 'acquire') return; ++ release(data.id); ++ try { ++ if (typeof data.uri !== 'string' || !data.uri.startsWith(PREFIX)) throw new Error('Invalid avatar URI'); ++ const seed = decodeURIComponent(data.uri.slice(PREFIX.length)).toLowerCase(); ++ if (!seed) throw new Error('Empty avatar seed'); ++ const uri = PREFIX + encodeURIComponent(seed); ++ requests.set(data.id, uri); ++ const image = images.get(uri); ++ if (image) { ++ image.references.add(data.id); ++ postMessage({ type: 'resolved', id: data.id, url: image.url }); ++ return; ++ } ++ let job = jobs.get(uri); ++ if (!job) { ++ job = { uri, seed, ids: new Set(), active: false }; ++ jobs.set(uri, job); ++ queue.push(job); ++ } ++ job.ids.add(data.id); ++ pump(); ++ } catch { postMessage({ type: 'error', id: data.id }); } ++}; +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js +new file mode 100644 +index 0000000..bbcc33a +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js +@@ -0,0 +1,137 @@ ++"use strict"; ++ ++// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. ++const AVATAR_PREFIX = 'onekey-avatar://blockie/v1/'; ++const MAX_RETAINED_AVATARS = 128; ++export function canonicalNativeListAvatarUri(uri) { ++ if (!uri.startsWith(AVATAR_PREFIX)) return undefined; ++ try { ++ const seed = decodeURIComponent(uri.slice(AVATAR_PREFIX.length)); ++ return seed ? AVATAR_PREFIX + encodeURIComponent(seed.toLowerCase()) : undefined; ++ } catch { ++ return undefined; ++ } ++} ++class NativeListWebAvatarCache { ++ entries = new Map(); ++ requests = new Map(); ++ nextId = 0; ++ acquire(uri, resolve, reject) { ++ let entry = this.entries.get(uri); ++ const isNew = !entry; ++ if (!entry) { ++ entry = { ++ id: ++this.nextId, ++ uri, ++ references: 0, ++ listeners: new Set() ++ }; ++ this.entries.set(uri, entry); ++ this.requests.set(entry.id, entry); ++ } ++ const current = entry; ++ this.entries.delete(uri); ++ this.entries.set(uri, current); ++ current.references += 1; ++ const listener = { ++ resolve, ++ reject ++ }; ++ if (current.url) resolve(current.url);else current.listeners.add(listener); ++ if (isNew) { ++ try { ++ if (!this.worker) { ++ // Deliberately avoid *.worker.js and its inline Blob-worker loader. ++ this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { ++ name: 'onekey-native-list-avatar' ++ }); ++ this.worker.addEventListener('message', this.handleMessage); ++ this.worker.addEventListener('error', this.handleFailure); ++ this.worker.addEventListener('messageerror', this.handleFailure); ++ } ++ this.worker.postMessage({ ++ type: 'acquire', ++ id: current.id, ++ uri ++ }); ++ } catch { ++ queueMicrotask(this.handleFailure); ++ } ++ } ++ this.trim(); ++ let disposed = false; ++ return () => { ++ if (disposed) return; ++ disposed = true; ++ current.listeners.delete(listener); ++ current.references = Math.max(0, current.references - 1); ++ if (current.references === 0 && !current.url && this.entries.get(uri) === current) { ++ this.entries.delete(uri); ++ this.requests.delete(current.id); ++ this.worker?.postMessage({ ++ type: 'release', ++ id: current.id ++ }); ++ } ++ this.trim(); ++ }; ++ } ++ handleMessage = event => { ++ const response = event.data; ++ if (!response || !Number.isSafeInteger(response.id)) return; ++ const entry = this.requests.get(response.id); ++ if (!entry) { ++ this.worker?.postMessage({ ++ type: 'release', ++ id: response.id ++ }); ++ return; ++ } ++ if (response.type === 'resolved' && typeof response.url === 'string' && response.url.startsWith('blob:')) { ++ entry.url = response.url; ++ entry.listeners.forEach(listener => listener.resolve(response.url)); ++ } else { ++ this.entries.delete(entry.uri); ++ this.requests.delete(entry.id); ++ this.worker?.postMessage({ ++ type: 'release', ++ id: entry.id ++ }); ++ entry.listeners.forEach(listener => listener.reject()); ++ } ++ entry.listeners.clear(); ++ this.trim(); ++ }; ++ handleFailure = () => { ++ this.worker?.terminate(); ++ this.worker = undefined; ++ this.requests.forEach(entry => { ++ entry.listeners.forEach(listener => listener.reject()); ++ entry.listeners.clear(); ++ }); ++ this.requests.clear(); ++ this.entries.clear(); ++ }; ++ trim() { ++ for (const [uri, entry] of this.entries) { ++ if (this.entries.size <= MAX_RETAINED_AVATARS) break; ++ if (entry.references === 0) { ++ this.entries.delete(uri); ++ this.requests.delete(entry.id); ++ this.worker?.postMessage({ ++ type: 'release', ++ id: entry.id ++ }); ++ } ++ } ++ } ++} ++const documentCaches = new WeakMap(); ++export function acquireNativeListAvatar(document, uri, resolve, reject) { ++ let cache = documentCaches.get(document); ++ if (!cache) { ++ cache = new NativeListWebAvatarCache(); ++ documentCaches.set(document, cache); ++ } ++ return cache.acquire(uri, resolve, reject); ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js +index 625a913..c1d40b4 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js +@@ -3,6 +3,7 @@ + import { checkboxStateForKeys, checkboxStateForSection, isSelectableRow, reduceSelection, selectionStateFromSnapshot } from "../selection.js"; + import { calculateAlignedScrollOffset, resolveLocationIndex, scrollFailure, validateOffset } from "../scrolling.js"; + import { applyRowPatches, validateSnapshot } from "../validation.js"; ++import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from "./NativeListWebAvatarCache.js"; + const SECTION_INDEX_GUTTER = 44; + const DEFAULT_VIEWPORT_WIDTH = 320; + const DEFAULT_VIEWPORT_HEIGHT = 640; +@@ -147,9 +148,22 @@ function approximateMessageHeight(row, availableWidth) { + return 32 + titleLines * 20 + bodyLines * 20 + 22; + } + export function estimateWebRowHeight(row, snapshot, availableWidth) { +- if (row.type === 'system' && row.variant === 'spacer') return row.height; +- if (row.type === 'walletGroup') return (row.children.length + 1) * 68 + row.children.length * 12; +- if (row.type === 'identity' && row.presentation === 'walletSidebar') return 68; ++ // OneKey patch: explicit selector height takes precedence over presets. ++ // if (row.type === 'system' && row.variant === 'spacer') return row.height; ++ if (row.height !== undefined) return row.height; ++ if (row.type === 'system' && row.variant === 'warning') { ++ const width = Math.max(1, availableWidth - 24); ++ const lines = text => Math.max(1, Math.ceil(Array.from(text).reduce((length, char) => length + (char.charCodeAt(0) > 255 ? 14 : 7), 0) / width)); ++ return 32 + 20 * (lines(row.title) + lines(row.message)); ++ } ++ if (row.type === 'walletGroup') ++ // OneKey patch: wallet badges participate in the outer group height. ++ // return (row.children.length + 1) * 68 + row.children.length * 12; ++ return [row.parent, ...row.children].reduce((height, member) => height + estimateWebRowHeight(member, snapshot, availableWidth), 0) + row.children.length * 12 + (row.parent.height !== undefined ? 2 : 0); ++ // OneKey patch: reserve the source badge line below wallet names. ++ // if (row.type === 'identity' && row.presentation === 'walletSidebar') ++ // return 68; ++ if (row.type === 'identity' && row.presentation === 'walletSidebar') return 68 + (row.badges?.length ? 24 : 0); + if (row.type === 'identity' && row.presentation === 'networkSelector') return 47; + if (row.type === 'identity' && row.presentation === 'accountSelector') return 58; + let base; +@@ -371,7 +385,10 @@ function rowWithoutSelectionState(row) { + export function webRowRenderSignature(row) { + return JSON.stringify(rowWithoutSelectionState(row)); + } ++ ++// OneKey patch: selector names must shrink before the sidebar clips their contents. + export const WEB_LIST_CSS = ` ++[data-native-list-selector="walletSidebar"] .ok-native-list-title{max-width:100%;min-width:0} + .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} + .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} + .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} +@@ -426,7 +443,27 @@ export const WEB_LIST_CSS = ` + .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} + .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} + .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} ++.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} ++.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} + @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner{animation:none}} ++/* OneKey patch: selector controls follow their original semantic colors and geometry. */ ++.ok-native-list-checkbox[data-selector="networkSelector"]{padding:0;border-radius:4px;border-color:var(--nl-checkbox-border,var(--nl-separator));background:var(--nl-checkbox-icon,var(--nl-inverse-text))} ++.ok-native-list-checkbox[data-selector="networkSelector"]::after{display:none} ++.ok-native-list-checkbox[data-selector="networkSelector"]>svg{display:none;width:16px;height:16px;color:var(--nl-checkbox-icon,var(--nl-inverse-text));flex-shrink:0} ++.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]{border-color:transparent;background:var(--nl-checkbox-background,var(--nl-primary))} ++.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"]>svg[data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]>svg[data-state="indeterminate"]{display:block} ++/* OneKey patch: source WalletListItem uses four-point padding on every side. */ ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"]{border-radius:12px;padding:4px} ++/* OneKey patch: selected group members use the same primary title color as standalone wallets. */ ++.ok-native-list-wallet-member[data-native-list-selected="true"]>.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-title{color:var(--nl-primary)} ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges{height:18px} ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge{font-size:11px;line-height:14px;font-weight:400;height:18px;padding:2px 6px;border-radius:4px;background:var(--nl-subdued);color:var(--nl-secondary)} ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge[data-tone="warning"]{background:var(--nl-caution-background);color:var(--nl-caution)} ++.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>.ok-native-list-icon-button{box-sizing:border-box;flex:0 0 38px;width:38px;height:38px;margin:-7px;padding:7px} ++/* OneKey patch: AccountSelectorAccountListItem fixes the borderless Plus slot at top18/right20. */ ++.ok-native-list-account-row[data-native-list-selector="accountSelector"]>.ok-native-list-accessories[data-native-list-account-control="createAddress"]{position:absolute;top:18px;right:12px} ++.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>[data-native-list-account-control="createAddress"]{flex-basis:36px;width:36px;height:36px;padding:6px;border-radius:8px} ++.ok-native-list-account-action-row{padding-left:12px;padding-right:12px}.ok-native-list-account-action-row .ok-native-list-action-title{font-size:16px;line-height:24px;font-weight:400}.ok-native-list-account-action-row .ok-native-list-action-title[data-tone="primary"]{color:var(--nl-primary)} + `; + function createElement(document, tag, className, text) { + const element = document.createElement(tag); +@@ -442,12 +479,105 @@ function safeImageUri(uri) { + if (/^(https?:|data:image\/|blob:|file:)/i.test(trimmed) || trimmed.startsWith('/')) return trimmed; + return undefined; + } ++ ++// OneKey patch: retries belong to an image binding and must not outlive recycled rows. ++const webImageRetryCleanup = new WeakMap(); ++const webAvatarCleanup = new WeakMap(); ++const webAvatarSources = new WeakMap(); ++function disposeWebImageRetries(element) { ++ let disposed = false; ++ const images = element.matches('img') ? [element] : element.querySelectorAll('img'); ++ images.forEach(image => { ++ const avatarCleanup = webAvatarCleanup.get(image); ++ const cleanup = webImageRetryCleanup.get(image); ++ if (!cleanup && !avatarCleanup) return; ++ avatarCleanup?.(); ++ webAvatarCleanup.delete(image); ++ webAvatarSources.delete(image); ++ cleanup?.(); ++ webImageRetryCleanup.delete(image); ++ disposed = true; ++ }); ++ return disposed; ++} ++function configureWebImageRetry(image, source, initialUri) { ++ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; ++ const retryLimit = Number.isFinite(source.retryTimes) ? Math.max(0, Math.floor(source.retryTimes ?? 0)) : 0; ++ if (!fallbackUri && retryLimit === 0) return; ++ const view = image.ownerDocument.defaultView; ++ let currentUri = initialUri; ++ let usedFallback = false; ++ let retryCount = 0; ++ let retryTimer; ++ let disposed = false; ++ const clearRetry = () => { ++ if (retryTimer !== undefined) view?.clearTimeout(retryTimer); ++ retryTimer = undefined; ++ }; ++ const handleError = event => { ++ if (disposed || !image.isConnected || retryTimer !== undefined) { ++ event.stopImmediatePropagation(); ++ return; ++ } ++ if (fallbackUri && !usedFallback && fallbackUri !== currentUri) { ++ event.stopImmediatePropagation(); ++ usedFallback = true; ++ currentUri = fallbackUri; ++ image.src = currentUri; ++ return; ++ } ++ if (retryCount >= retryLimit || !view) return; ++ event.stopImmediatePropagation(); ++ retryCount += 1; ++ retryTimer = view.setTimeout(() => { ++ retryTimer = undefined; ++ if (disposed || !image.isConnected) return; ++ image.removeAttribute('src'); ++ image.src = currentUri; ++ }, Math.floor(Math.random() * 3) * 1000); ++ }; ++ image.addEventListener('error', handleError); ++ image.addEventListener('load', clearRetry); ++ webImageRetryCleanup.set(image, () => { ++ disposed = true; ++ clearRetry(); ++ image.removeEventListener('error', handleError); ++ image.removeEventListener('load', clearRetry); ++ }); ++} ++function configureWebAvatar(image, source, uri) { ++ const dispose = acquireNativeListAvatar(image.ownerDocument, uri, resolvedUri => { ++ configureWebImageRetry(image, source, resolvedUri); ++ image.src = resolvedUri; ++ }, () => { ++ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; ++ if (fallbackUri) { ++ configureWebImageRetry(image, source, fallbackUri); ++ image.src = fallbackUri; ++ return; ++ } ++ const ImageEvent = image.ownerDocument.defaultView?.Event; ++ if (ImageEvent) image.dispatchEvent(new ImageEvent('error')); ++ }); ++ webAvatarSources.set(image, { ++ uri, ++ source ++ }); ++ webAvatarCleanup.set(image, dispose); ++} + function createImage(context, source, className) { +- const uri = safeImageUri(source.uri); ++ const avatarUri = canonicalNativeListAvatarUri(source.uri); ++ const uri = avatarUri ?? safeImageUri(source.uri); + if (!uri) return undefined; + const image = context.document.createElement('img'); + if (className) image.className = className; +- image.src = uri; ++ // OneKey patch: consume recoverable errors before the visual's final fallback listener. ++ if (avatarUri) { ++ configureWebAvatar(image, source, avatarUri); ++ } else { ++ configureWebImageRetry(image, source, uri); ++ image.src = uri; ++ } + image.alt = ''; + image.draggable = false; + image.loading = 'lazy'; +@@ -455,6 +585,247 @@ function createImage(context, source, className) { + image.style.objectFit = source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover'; + return image; + } ++ ++// OneKey patch: React Native Web paints selector images as centered CSS backgrounds. ++// The image keeps its loading/error lifecycle; only its replaced-element pixels are hidden. ++function paintSelectorImageBackground(image, frame, inset = 0) { ++ const paint = createElement(image.ownerDocument, 'span', 'ok-native-list-selector-image-background'); ++ paint.style.cssText = 'position:absolute;pointer-events:none;border-radius:inherit;background-position:center;background-repeat:no-repeat'; ++ paint.style.inset = String(inset) + 'px'; ++ paint.style.backgroundSize = image.style.objectFit === 'fill' ? '100% 100%' : image.style.objectFit === 'center' ? 'auto' : image.style.objectFit; ++ image.style.opacity = '0'; ++ const update = () => { ++ paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; ++ }; ++ image.addEventListener('load', update); ++ image.addEventListener('error', () => { ++ paint.style.backgroundImage = 'none'; ++ }); ++ frame.insertBefore(paint, image); ++ if (image.complete && image.naturalWidth > 0) update(); ++} ++ ++// OneKey patch: use source SVG paths for selector actions and wallet provider marks. ++const selectorIcons = { ++ "GlobusOutline": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361", ++ "fill": "currentColor", ++ "fillRule": "evenodd", ++ "opacity": 1.0 ++ }] ++ }, ++ "LockSolid": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3", ++ "fill": "currentColor", ++ "fillRule": "evenodd", ++ "opacity": 1.0 ++ }] ++ }, ++ "GoogleIllus": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09", ++ "fill": "#4285F4", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23", ++ "fill": "#34A853", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z", ++ "fill": "#FBBC05", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53", ++ "fill": "#EA4335", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "AppleBrand": { ++ "viewBox": "0 0 16 20", ++ "paths": [{ ++ "d": "M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "BotIllus": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z", ++ "fill": "#8897A5", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z", ++ "fill": "#3FA9F5", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", ++ "fill": "#8897A5", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", ++ "fill": "#8897A5", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", ++ "fill": "#10243E", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", ++ "fill": "#10243E", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06", ++ "fill": "#10243E", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "AllNetworksSolid": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }, { ++ "d": "M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z", ++ "fill": "currentColor", ++ "fillRule": "evenodd", ++ "opacity": 1.0 ++ }] ++ }, ++ "CrossedSmallSolid": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "AccountErrorCustom": { ++ "viewBox": "0 0 18 18", ++ "paths": [{ ++ "d": "M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5", ++ "fill": "#000", ++ "fillRule": "nonzero", ++ "opacity": 0.447 ++ }, { ++ "d": "M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5", ++ "fill": "#000", ++ "fillRule": "evenodd", ++ "opacity": 0.447 ++ }] ++ }, ++ "PlusSmallOutline": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M13 11h5v2h-5v5h-2v-5H6v-2h5V6h2z", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "DotHorOutline": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M6 14H2v-4h4zm8 0h-4v-4h4zm8 0h-4v-4h4z", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "ChevronRightSmallOutline": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M15.414 12 10 17.414 8.586 16l4-4-4-4L10 6.586z", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "DragOutline": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M11 21H7v-4h4zm6 0h-4v-4h4zm-6-7H7v-4h4zm6 0h-4v-4h4zm-6-7H7V3h4zm6 0h-4V3h4z", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1.0 ++ }] ++ }, ++ "PencilOutline": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M22.414 7.5 7.914 22H2v-5.914l14.5-14.5zM4 16.914V20h3.086l9.5-9.5L13.5 7.414zM14.914 6 18 9.086 19.586 7.5 16.5 4.414z", ++ "fill": "currentColor", ++ "fillRule": "evenodd", ++ "opacity": 1.0 ++ }] ++ }, ++ "CheckboxCheckedCustom": { ++ "viewBox": "0 0 16 16", ++ "paths": [{ ++ "d": "M12.204 5.043a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414l1.293 1.293 3.793-3.793a1 1 0 0 1 1.414 0", ++ "fill": "currentColor", ++ "fillRule": "evenodd", ++ "opacity": 1.0 ++ }] ++ }, ++ "CheckboxIndeterminateCustom": { ++ "viewBox": "0 0 16 16", ++ "paths": [{ ++ "d": "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1", ++ "fill": "currentColor", ++ "fillRule": "evenodd", ++ "opacity": 1.0 ++ }] ++ }, ++ "Circle": { ++ "viewBox": "0 0 24 24", ++ "paths": [{ ++ "d": "M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0", ++ "fill": "currentColor", ++ "fillRule": "nonzero", ++ "opacity": 1 ++ }] ++ } ++}; ++function applySelectorIcon(element, name) { ++ const icon = selectorIcons[name]; ++ if (!icon) return; ++ element.textContent = ''; ++ const svg = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); ++ svg.setAttribute('viewBox', icon.viewBox); ++ svg.setAttribute('width', '24'); ++ svg.setAttribute('height', '24'); ++ svg.setAttribute('aria-hidden', 'true'); ++ icon.paths.forEach(path => { ++ const child = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); ++ child.setAttribute('d', path.d); ++ child.setAttribute('fill', path.fill); ++ child.setAttribute('fill-rule', path.fillRule); ++ child.setAttribute('fill-opacity', String(path.opacity)); ++ svg.appendChild(child); ++ }); ++ element.appendChild(svg); ++} + function iconGlyph(name) { + const normalized = name.toLocaleLowerCase(); + if (normalized.includes('chevron')) { +@@ -484,7 +855,7 @@ function visualFromRow(row) { + if (row.type === 'metricCard') return row.visual; + return undefined; + } +-function createVisual(context, visual) { ++function createVisual(context, visual, selectorPresentation) { + if (!visual) return undefined; + if (visual.kind === 'stackedImages') { + const stack = createElement(context.document, 'span', 'ok-native-list-stacked'); +@@ -500,6 +871,7 @@ function createVisual(context, visual) { + if (visual.kind === 'icon') { + frame.style.background = visual.backgroundColor ?? 'var(--nl-strong)'; + const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback', iconGlyph(visual.name)); ++ applySelectorIcon(fallback, visual.name); + if (visual.tintColor) fallback.style.color = visual.tintColor; + frame.appendChild(fallback); + return frame; +@@ -510,6 +882,7 @@ function createVisual(context, visual) { + if (image) { + image.className = 'ok-native-list-visual-main'; + frame.appendChild(image); ++ if (selectorPresentation) paintSelectorImageBackground(image, frame); + } else { + frame.appendChild(createElement(context.document, 'span', 'ok-native-list-visual-fallback', 'fallbackText' in visual ? visual.fallbackText ?? '' : '')); + } +@@ -520,8 +893,64 @@ function createVisual(context, visual) { + const corner = createElement(context.document, 'span', 'ok-native-list-visual-corner ok-native-list-visual-fallback', iconGlyph(visual.cornerIcon.name)); + if (visual.cornerIcon.tintColor) corner.style.color = visual.cornerIcon.tintColor; + if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; ++ applySelectorIcon(corner, visual.cornerIcon.name); + frame.appendChild(corner); + } ++ // OneKey patch: image failure uses the same source-derived fallback as v1. ++ if ('fallbackIcon' in visual && visual.fallbackIcon) { ++ const icon = visual.fallbackIcon; ++ const showFallback = () => { ++ if (image) { ++ disposeWebImageRetries(image); ++ image.remove(); ++ } ++ frame.querySelector('.ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)')?.remove(); ++ const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback'); ++ applySelectorIcon(fallback, icon.name); ++ if (icon.tintColor) fallback.style.color = icon.tintColor; ++ frame.prepend(fallback); ++ }; ++ if (image) image.addEventListener('error', showFallback, { ++ once: true ++ });else showFallback(); ++ } ++ if ('borderStyle' in visual && visual.borderStyle === 'dashed') { ++ frame.style.border = '2px dashed ' + (visual.borderColor ?? 'var(--nl-disabled)'); ++ frame.style.boxSizing = 'border-box'; ++ } ++ if ('overlays' in visual) visual.overlays?.forEach(overlay => { ++ const corner = createElement(context.document, 'span', 'ok-native-list-visual-overlay', overlay.text); ++ const size = overlay.size ?? 20; ++ const isWalletText = selectorPresentation === 'walletSidebar' && !!overlay.text && !overlay.image && !overlay.name; ++ corner.style.width = isWalletText && overlay.width === undefined ? 'auto' : String(overlay.width ?? size) + 'px'; ++ corner.style.height = String(overlay.height ?? (isWalletText ? 16 : size)) + 'px'; ++ corner.style.padding = isWalletText ? '0 2px' : String(overlay.padding ?? 0) + 'px'; ++ if (isWalletText) { ++ corner.style.fontSize = '12px'; ++ corner.style.lineHeight = '16px'; ++ corner.style.fontWeight = '400'; ++ } ++ if (isWalletText || overlay.width !== undefined || overlay.height !== undefined) corner.style.borderRadius = '9999px'; ++ const offsetX = String(-(overlay.offsetX ?? overlay.offset ?? 2)) + 'px'; ++ const offsetY = String(-(overlay.offsetY ?? overlay.offset ?? 2)) + 'px'; ++ corner.style.background = overlay.backgroundColor ?? 'transparent'; ++ corner.style.color = overlay.tintColor ?? 'var(--nl-secondary)'; ++ if (overlay.position === 'topLeft') { ++ corner.style.left = offsetX; ++ corner.style.top = offsetY; ++ } else { ++ corner.style.right = offsetX; ++ corner.style.bottom = offsetY; ++ } ++ if (overlay.image) { ++ const overlayImage = createImage(context, overlay.image); ++ if (overlayImage) { ++ corner.appendChild(overlayImage); ++ if (selectorPresentation) paintSelectorImageBackground(overlayImage, corner, overlay.padding ?? 0); ++ } ++ } else if (overlay.name) applySelectorIcon(corner, overlay.name); ++ frame.appendChild(corner); ++ }); + return frame; + } + function toneColor(tone, fallback) { +@@ -567,6 +996,19 @@ function createCheckbox(context, rowKey, accessory) { + setData(element, 'checkboxFallback', accessory.state); + setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); + setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); ++ const row = context.snapshot.rows[context.itemIndex]; ++ if (row && 'presentation' in row && row.presentation === 'networkSelector') { ++ setData(element, 'selector', 'networkSelector'); ++ for (const [state, name] of [['checked', 'CheckboxCheckedCustom'], ['indeterminate', 'CheckboxIndeterminateCustom']]) { ++ const holder = createElement(context.document, 'span'); ++ applySelectorIcon(holder, name); ++ const svg = holder.firstElementChild; ++ if (svg) { ++ svg.setAttribute('data-state', state); ++ element.appendChild(svg); ++ } ++ } ++ } + if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey);else if (accessory.target?.scope === 'row') setData(element, 'selectionKey', rowKey); + return element; + } +@@ -576,6 +1018,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { + element.setAttribute('type', 'button'); + element.toggleAttribute('disabled', Boolean(disabled)); + } ++ applySelectorIcon(element, name); + if (actionKey) setData(element, 'nativeListAction', actionKey); + if (tintColor) element.style.color = tintColor; + return element; +@@ -584,6 +1027,23 @@ function markActionAnchorSource(element, source, slot) { + setData(element, 'nativeListAnchorSource', source); + if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); + } ++ ++// OneKey patch: the compact zero-count digits share the amount baseline. ++function applyValueSegments(element, segments, fontSize = 16, lineHeight = 24, weight = 500) { ++ if (!segments?.length) return; ++ element.textContent = ''; ++ element.style.fontSize = String(fontSize) + 'px'; ++ element.style.lineHeight = String(lineHeight) + 'px'; ++ element.style.fontWeight = String(weight); ++ segments.forEach(segment => { ++ const span = createElement(element.ownerDocument, 'span', undefined, segment.text); ++ if (segment.style === 'subscript') { ++ span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; ++ span.style.lineHeight = String(fontSize) + 'px'; ++ } ++ element.appendChild(span); ++ }); ++} + function createAccessory(context, rowKey, accessory, slot) { + if (accessory.kind === 'checkbox') { + const element = createCheckbox(context, rowKey, accessory); +@@ -593,6 +1053,18 @@ function createAccessory(context, rowKey, accessory, slot) { + if (accessory.kind === 'icon') { + const element = createIconAction(context, accessory.name, accessory.actionKey, accessory.disabled, accessory.tintColor); + markActionAnchorSource(element, 'trailingAccessory', slot); ++ setData(element, 'testid', accessory.testID); ++ if (accessory.hoverActionKey) setData(element, 'nativeListHoverAction', accessory.hoverActionKey); ++ if (accessory.accessibilityLabel) element.setAttribute('aria-label', accessory.accessibilityLabel); ++ const row = context.snapshot.rows[context.itemIndex]; ++ if (row && 'presentation' in row && row.presentation === 'accountSelector') { ++ setData(element, 'nativeListAnchorInset', 7); ++ // OneKey patch: the create-address button omits IconButton's one-point border. ++ if (row.height !== undefined && accessory.name === 'PlusSmallOutline') { ++ setData(element, 'nativeListAccountControl', 'createAddress'); ++ if (!accessory.tintColor) element.style.color = 'var(--nl-icon-subdued)'; ++ } ++ } + return element; + } + if (accessory.kind === 'spinner') { +@@ -608,6 +1080,7 @@ function createAccessory(context, rowKey, accessory, slot) { + switch (accessory.kind) { + case 'value': + element.textContent = accessory.text; ++ applyValueSegments(element, accessory.textSegments); + if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); + break; + case 'valuePair': +@@ -646,6 +1119,13 @@ function createAccessory(context, rowKey, accessory, slot) { + function appendAccessories(parent, context, rowKey, accessories) { + if (!accessories?.length) return; + const container = createElement(context.document, 'span', 'ok-native-list-accessories'); ++ const row = context.snapshot.rows[context.itemIndex]; ++ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'networkSelector') { ++ container.style.gap = accessories.some(accessory => accessory.kind === 'checkbox') ? '12px' : '20px'; ++ } ++ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'accountSelector' && accessories.length === 1 && accessories[0]?.kind === 'icon' && accessories[0].name === 'PlusSmallOutline') { ++ setData(container, 'nativeListAccountControl', 'createAddress'); ++ } + accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot))); + parent.appendChild(container); + } +@@ -673,9 +1153,83 @@ function createSectionHeader(context, row) { + markActionAnchorSource(titleIcon, 'leadingAction'); + body.appendChild(titleIcon); + } +- body.appendChild(createTextColumn(context, row.title, row.subtitle)); ++ // OneKey patch: section title help has its own measurable action target. ++ // body.appendChild(createTextColumn(context, row.title, row.subtitle)); ++ const column = createTextColumn(context, row.title, row.subtitle); ++ const title = column.firstElementChild; ++ title.classList.add('ok-native-list-section-title'); ++ if (row.titleActionKey) { ++ setData(title, 'nativeListAction', row.titleActionKey); ++ markActionAnchorSource(title, 'leadingAction'); ++ title.setAttribute('role', 'button'); ++ title.tabIndex = 0; ++ title.style.alignSelf = 'flex-start'; ++ title.style.maxWidth = '100%'; ++ // OneKey patch: explicit network headers reserve a separate 3-point underline area. ++ if (row.presentation !== 'networkSelector' || row.height === undefined) { ++ title.style.textDecoration = 'underline dotted'; ++ title.style.textUnderlineOffset = '6px'; ++ } ++ if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); ++ } ++ if (row.presentation === 'networkSelector' && row.height !== undefined) { ++ body.style.padding = row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; ++ body.style.backgroundColor = 'var(--nl-bg)'; ++ body.style.gap = row.checkbox ? '12px' : '8px'; ++ title.style.fontSize = row.variant === 'summary' ? '16px' : '14px'; ++ title.style.lineHeight = row.variant === 'summary' ? '24px' : '20px'; ++ title.style.fontWeight = row.variant === 'summary' || row.titleActionKey && !row.checkbox ? '500' : '600'; ++ if (row.titleActionKey) { ++ const text = createElement(context.document, 'span', 'ok-native-list-section-title-text', row.title); ++ text.style.overflow = 'hidden'; ++ text.style.textOverflow = 'ellipsis'; ++ text.style.maxWidth = '100%'; ++ const dotted = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); ++ dotted.setAttribute('height', '2'); ++ dotted.style.cssText = 'display:block;position:absolute;left:0;bottom:0;width:100%;height:2px;color:var(--nl-secondary)'; ++ const line = context.document.createElementNS('http://www.w3.org/2000/svg', 'line'); ++ for (const [key, value] of Object.entries({ ++ x1: '1', ++ y1: '1', ++ x2: '100%', ++ y2: '1', ++ stroke: 'currentColor', ++ 'stroke-width': '1.5', ++ 'stroke-dasharray': '0,4', ++ 'stroke-linecap': 'round' ++ })) line.setAttribute(key, value); ++ // OneKey patch: keep both round caps inside the original full-width viewport. ++ const lineViewport = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); ++ lineViewport.setAttribute('width', 'calc(100% - 1px)'); ++ lineViewport.setAttribute('height', '2'); ++ lineViewport.setAttribute('overflow', 'visible'); ++ lineViewport.appendChild(line); ++ dotted.appendChild(lineViewport); ++ // OneKey patch: SVG intrinsic width must not expand the title action beyond its text. ++ title.style.display = 'block'; ++ title.style.position = 'relative'; ++ title.style.width = 'fit-content'; ++ title.style.paddingBottom = '3px'; ++ text.style.display = 'block'; ++ title.replaceChildren(text, dotted); ++ } ++ } ++ body.appendChild(column); + if (row.value) { + const value = createElement(context.document, row.valueActionKey ? 'button' : 'span', row.valueActionKey ? 'ok-native-list-action-button ok-native-list-section-value' : 'ok-native-list-value ok-native-list-section-value', row.value); ++ applyValueSegments(value, row.valueSegments); ++ if (row.presentation === 'networkSelector' && row.height !== undefined) { ++ value.style.fontFamily = 'inherit'; ++ value.style.fontSize = '16px'; ++ value.style.lineHeight = '24px'; ++ value.style.fontWeight = '500'; ++ if (row.valueActionKey) { ++ value.style.color = 'var(--nl-secondary)'; ++ value.style.padding = '0'; ++ value.style.flexShrink = '0'; ++ } ++ } ++ if (row.valueActionTestID) setData(value, 'testid', row.valueActionTestID); + if (row.valueActionKey) { + value.setAttribute('type', 'button'); + setData(value, 'nativeListAction', row.valueActionKey); +@@ -696,6 +1250,7 @@ function createActionRow(context, row) { + if (row.icon) body.appendChild(createVisual(context, row.icon)); + const title = createElement(context.document, 'span', 'ok-native-list-action-title', row.title); + setData(title, 'tone', row.tone); ++ if (row.presentation === 'accountSelector' && row.icon) title.style.fontWeight = '500'; + body.appendChild(title); + if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); + appendAccessories(body, context, row.key, row.trailing); +@@ -704,6 +1259,13 @@ function createActionRow(context, row) { + function createSystemRow(context, row) { + const body = createElement(context.document, 'div', 'ok-native-list-row ok-native-list-system'); + setData(body, 'variant', row.variant); ++ if (row.variant === 'warning') { ++ body.classList.add('ok-native-list-warning'); ++ body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; ++ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-title', row.title)); ++ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-message', row.message)); ++ return body; ++ } + if (row.variant === 'loading') body.appendChild(createElement(context.document, 'span', 'ok-native-list-spinner')); + const message = row.variant === 'spacer' ? '' : row.message ?? (row.variant === 'end' ? 'End' : ''); + if (message) body.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', message)); +@@ -852,12 +1414,31 @@ function createDataRow(context, row) { + function createIdentityActivityOrMessageRow(context, row) { + const presentation = row.type === 'identity' ? row.presentation : undefined; + const body = createElement(context.document, 'div', ['ok-native-list-row', 'ok-native-list-standard', row.type === 'identity' && !presentation ? 'ok-native-list-identity-row' : '', presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', presentation === 'accountSelector' ? 'ok-native-list-account-row' : ''].filter(Boolean).join(' ')); ++ setData(body, 'nativeListSelector', row.height !== undefined ? presentation : undefined); ++ if (row.type === 'identity' && row.titleActionKey && row.titleActionOnHover) { ++ setData(body, 'nativeListHoverAction', row.titleActionKey); ++ markActionAnchorSource(body, 'leadingAction'); ++ } + if (row.type === 'identity' && row.leadingAction) { + const action = createIconAction(context, row.leadingAction.name, row.leadingAction.actionKey, row.leadingAction.disabled, row.leadingAction.tintColor); + markActionAnchorSource(action, 'leadingAction'); + body.appendChild(action); + } +- const visual = createVisual(context, visualFromRow(row)); ++ const visual = createVisual(context, visualFromRow(row), row.height !== undefined ? presentation : undefined); ++ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'fallbackIcon' in row.leading && row.leading.fallbackIcon?.name === 'LockSolid') { ++ // OneKey patch: hidden-wallet locks use WalletAvatar's full 40-point icon. ++ const icon = visual.querySelector('.ok-native-list-visual-fallback svg'); ++ if (icon) { ++ icon.style.width = '40px'; ++ icon.style.height = '40px'; ++ } ++ const fallback = visual.querySelector('.ok-native-list-visual-fallback'); ++ if (fallback) { ++ fallback.style.borderRadius = '0'; ++ fallback.style.overflow = 'visible'; ++ } ++ } ++ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'borderStyle' in row.leading && row.leading.borderStyle === 'dashed') visual.style.borderWidth = '1px'; + if (visual) body.appendChild(visual); + if (row.type === 'activity' && row.secondaryLeading) { + const secondVisual = createVisual(context, row.secondaryLeading); +@@ -866,7 +1447,46 @@ function createIdentityActivityOrMessageRow(context, row) { + if (row.type === 'message' && row.unread) body.appendChild(createElement(context.document, 'span', 'ok-native-list-unread')); + const title = row.title; + const subtitle = row.type === 'identity' ? row.subtitle : row.type === 'activity' ? row.description : row.body; +- const column = createTextColumn(context, title, subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, row.type === 'identity' ? row.badges : undefined); ++ const column = createTextColumn(context, title, subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, row.type === 'identity' && presentation !== 'walletSidebar' ? row.badges : undefined); ++ // OneKey patch: match existing search, subtitle fragments, and sidebar badges. ++ if (row.type === 'identity') { ++ const titleElement = column.firstElementChild; ++ if (row.titleMatch?.length) { ++ const firstText = titleElement.firstChild; ++ if (firstText) firstText.remove(); ++ const fragment = context.document.createDocumentFragment(); ++ let offset = 0; ++ row.titleMatch.forEach(({ ++ start, ++ end ++ }) => { ++ fragment.appendChild(context.document.createTextNode(row.title.slice(offset, start))); ++ const match = createElement(context.document, 'span', 'ok-native-list-info', row.title.slice(start, end)); ++ fragment.appendChild(match); ++ offset = end; ++ }); ++ fragment.appendChild(context.document.createTextNode(row.title.slice(offset))); ++ titleElement.prepend(fragment); ++ } ++ if (row.subtitleSegments?.length) { ++ column.querySelector('.ok-native-list-secondary')?.remove(); ++ const segments = createElement(context.document, 'span', 'ok-native-list-subtitle-segments'); ++ row.subtitleSegments.forEach(segment => { ++ if (segment.separatorBefore) segments.appendChild(createElement(context.document, 'span', 'ok-native-list-subtitle-dot')); ++ const text = createElement(context.document, 'span', 'ok-native-list-secondary', segment.text); ++ applyValueSegments(text, segment.textSegments, 14, 20, 400); ++ setData(text, 'tone', segment.tone); ++ text.style.color = segment.tone === 'disabled' ? 'var(--nl-disabled)' : segment.tone === 'caution' ? 'var(--nl-caution)' : toneColor(segment.tone, 'secondary'); ++ segments.appendChild(text); ++ }); ++ column.insertBefore(segments, titleElement.nextSibling); ++ } ++ if (presentation === 'walletSidebar' && row.badges?.length) { ++ const badges = createElement(context.document, 'span', 'ok-native-list-wallet-badges'); ++ row.badges.forEach(badge => badges.appendChild(createBadge(context, badge))); ++ column.appendChild(badges); ++ } ++ } + if (row.type === 'activity' && row.status) column.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', row.status)); + if (row.type === 'activity' && row.footerActions?.length) { + const actions = createElement(context.document, 'span', 'ok-native-list-actions'); +@@ -898,14 +1518,32 @@ function createIdentityActivityOrMessageRow(context, row) { + } + return body; + } ++ ++// OneKey patch: SizableText enables tabular digits without replacing its font family. ++function applySelectorTabularNumbers(body, row) { ++ if (!('presentation' in row) || !['accountSelector', 'networkSelector', 'walletSidebar'].includes(row.presentation ?? '')) return; ++ body.style.fontVariantNumeric = 'tabular-nums'; ++ body.querySelectorAll('span,button').forEach(text => { ++ text.style.fontVariantNumeric = 'tabular-nums'; ++ }); ++} + function createWalletGroupRow(context, row) { + const body = createElement(context.document, 'div', 'ok-native-list-wallet-group'); + [row.parent, ...row.children].forEach((member, memberIndex) => { + const memberElement = createElement(context.document, 'div', 'ok-native-list-wallet-member'); + setData(memberElement, 'nativeListGroupMemberKey', member.key); ++ setData(memberElement, 'testid', member.testID); + setData(memberElement, 'nativeListGroupParent', memberIndex === 0); + setData(memberElement, 'nativeListSelected', member.selected); +- memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); ++ // OneKey patch: grouped members have the same selector typography as standalone wallets. ++ // memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); ++ const memberBody = createIdentityActivityOrMessageRow(context, member); ++ applySelectorTabularNumbers(memberBody, member); ++ memberElement.appendChild(memberBody); ++ // OneKey patch: group children use their own measured badge height. ++ memberElement.style.flexBasis = String(member.height ?? 68 + (member.badges?.length ? 24 : 0)) + 'px'; ++ memberElement.style.height = memberElement.style.flexBasis; ++ memberElement.style.opacity = String(member.opacity ?? 1); + body.appendChild(memberElement); + }); + return body; +@@ -953,6 +1591,8 @@ export class NativeListWebEngine { + lastViewportWidth = -1; + lastViewportHeight = -1; + destroyed = false; ++ // OneKey patch: warning banners are measured after normal browser text wrapping. ++ measuredWarningHeights = new Map(); + constructor(host, snapshot, callbacks, virtualizationEnabled = true) { + this.document = host.ownerDocument; + this.snapshot = validateSnapshot(snapshot); +@@ -991,6 +1631,9 @@ export class NativeListWebEngine { + passive: true + }); + this.root.addEventListener('click', this.handleClick); ++ // OneKey patch: preserve web tooltip hover for section titles. ++ this.root.addEventListener('pointerover', this.handleTitlePointerOver); ++ this.root.addEventListener('pointerout', this.handleTitlePointerOut); + this.root.addEventListener('keydown', this.handleKeyDown); + this.viewport.addEventListener('pointerdown', this.handleReorderPointerDown, { + passive: false +@@ -1125,7 +1768,7 @@ export class NativeListWebEngine { + scrollToLocation(params, scroll) { + const index = resolveLocationIndex(this.snapshot.rows, params); + if (index === undefined) { +- const sectionCount = this.snapshot.rows.filter(row => row.type === 'sectionHeader' && row.variant !== 'summary').length; ++ const sectionCount = this.snapshot.rows.filter(row => row.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary').length; + this.emitScrollFailure(params.itemIndex, params.sectionIndex >= sectionCount ? 'section-out-of-range' : 'item-out-of-range'); + return; + } +@@ -1172,6 +1815,8 @@ export class NativeListWebEngine { + this.document.defaultView?.removeEventListener('resize', this.handleWindowResize); + this.viewport.removeEventListener('scroll', this.handleScroll); + this.root.removeEventListener('click', this.handleClick); ++ this.root.removeEventListener('pointerover', this.handleTitlePointerOver); ++ this.root.removeEventListener('pointerout', this.handleTitlePointerOut); + this.root.removeEventListener('keydown', this.handleKeyDown); + this.cancelPointerReorder(true); + this.viewport.removeEventListener('pointerdown', this.handleReorderPointerDown); +@@ -1189,6 +1834,8 @@ export class NativeListWebEngine { + this.viewport.removeEventListener('pointerup', this.handlePullEnd); + this.viewport.removeEventListener('pointercancel', this.handlePullEnd); + const host = this.root.parentElement; ++ disposeWebImageRetries(this.root); ++ this.pool.forEach(disposeWebImageRetries); + this.root.remove(); + this.hideReorderPreview(); + this.reorderPreview.remove(); +@@ -1197,6 +1844,7 @@ export class NativeListWebEngine { + this.pool.length = 0; + } + setSnapshot(snapshot, selectedKeys) { ++ this.measuredWarningHeights.clear(); + this.snapshot = snapshot; + this.rows = effectiveRows(snapshot); + this.selectedKeys = selectedKeys ?? selectionStateFromSnapshot(snapshot).selectedKeys; +@@ -1219,6 +1867,11 @@ export class NativeListWebEngine { + '--nl-primary': theme.primaryText, + '--nl-secondary': theme.secondaryText, + '--nl-disabled': theme.disabledText, ++ '--nl-caution': theme.caution ?? '#AB6400', ++ '--nl-caution-background': theme.cautionBackground, ++ '--nl-checkbox-background': theme.checkboxBackground, ++ '--nl-checkbox-border': theme.checkboxBorder, ++ '--nl-checkbox-icon': theme.checkboxIcon, + '--nl-icon': theme.icon, + '--nl-icon-subdued': theme.iconSubdued, + '--nl-separator': theme.separator, +@@ -1242,11 +1895,19 @@ export class NativeListWebEngine { + const viewportHeight = this.viewport.clientHeight; + if (this.lastViewportWidth >= 0 && (viewportWidth !== this.lastViewportWidth || viewportHeight !== this.lastViewportHeight)) { + this.invalidateActionAnchor('layout'); ++ this.measuredWarningHeights.clear(); + } + this.lastViewportWidth = viewportWidth; + this.lastViewportHeight = viewportHeight; + const previousHorizontal = this.layout.horizontal; +- this.layout = computeWebListLayout(this.snapshot, viewportWidth, viewportHeight, this.reorderCompactKey); ++ const measuredSnapshot = { ++ ...this.snapshot, ++ rows: this.snapshot.rows.map(row => row.type === 'system' && row.variant === 'warning' && row.height === undefined && this.measuredWarningHeights.has(row.key) ? { ++ ...row, ++ height: this.measuredWarningHeights.get(row.key) ++ } : row) ++ }; ++ this.layout = computeWebListLayout(measuredSnapshot, viewportWidth, viewportHeight, this.reorderCompactKey); + this.content.style.width = String(this.layout.contentWidth) + 'px'; + this.content.style.height = String(this.layout.contentHeight) + 'px'; + if (previousHorizontal !== this.layout.horizontal) { +@@ -1264,6 +1925,7 @@ export class NativeListWebEngine { + if (!desired.has(index)) { + this.invalidateActionAnchorForElement(element); + this.mounted.delete(index); ++ if (disposeWebImageRetries(element)) element.removeAttribute('data-render-signature'); + element.remove(); + this.pool.push(element); + } +@@ -1285,6 +1947,20 @@ export class NativeListWebEngine { + element.dataset.renderSignature = signature; + } + }); ++ let measuredWarningChanged = false; ++ this.mounted.forEach((element, index) => { ++ const row = this.rows[index]; ++ if (row?.type !== 'system' || row.variant !== 'warning' || row.height !== undefined) return; ++ const height = element.querySelector('.ok-native-list-warning')?.offsetHeight ?? 0; ++ if (height > 0 && height !== this.measuredWarningHeights.get(row.key)) { ++ this.measuredWarningHeights.set(row.key, height); ++ measuredWarningChanged = true; ++ } ++ }); ++ if (measuredWarningChanged) { ++ this.recomputeLayout(); ++ return; ++ } + this.updateVisibleSelection(); + this.updateVisibleState(); + } +@@ -1295,12 +1971,16 @@ export class NativeListWebEngine { + } + renderElement(element, index, row, overlay = false) { + this.invalidateActionAnchorForElement(element); ++ disposeWebImageRetries(element); + const bindingEpoch = String(++this.bindingEpochCounter); + element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' : 'ok-native-list-item'; + setData(element, 'nativeListRowKey', row.key); + setData(element, 'nativeListBindingEpoch', bindingEpoch); + setData(element, 'nativeListRowIndex', index); + setData(element, 'nativeListDisabled', Boolean(row.disabled)); ++ setData(element, 'testid', row.testID); ++ // OneKey patch: deprecation dims a row without disabling its actions. ++ element.style.opacity = String(row.opacity ?? 1); + setData(element, 'nativeListReorderable', this.isReorderable(row)); + setData(element, 'nativeListDragging', this.pointerReorder?.active && this.pointerReorder.sourceKey === row.key); + setData(element, 'separator', row.separator); +@@ -1320,11 +2000,67 @@ export class NativeListWebEngine { + selectedKeys: this.selectedKeys, + itemIndex: index + }; +- element.replaceChildren(createRowBody(context, row)); ++ const body = createRowBody(context, row); ++ applySelectorTabularNumbers(body, row); ++ // OneKey patch: explicit selector fields preserve original page geometry. ++ element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; ++ if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; ++ if (row.backgroundFullWidth && row.backgroundColor) { ++ const bleed = paddingValues(this.snapshot).horizontal; ++ body.style.position = 'relative'; ++ body.style.overflow = 'visible'; ++ body.style.boxShadow = String(-bleed) + 'px 0 ' + row.backgroundColor + ',' + String(bleed) + 'px 0 ' + row.backgroundColor; ++ } ++ if (row.type === 'identity' && row.height !== undefined) { ++ const title = body.querySelector('.ok-native-list-title'); ++ if (row.presentation === 'accountSelector') { ++ body.style.gap = '12px'; ++ body.style.borderRadius = '12px'; ++ if ('shape' in row.leading && row.leading.shape === 'rounded') { ++ const visual = body.querySelector('.ok-native-list-visual'); ++ if (visual) visual.style.borderRadius = '8px'; ++ } ++ if (title) title.style.lineHeight = '24px'; ++ } ++ if (row.presentation === 'networkSelector') { ++ body.style.borderRadius = '12px'; ++ const visual = body.querySelector('.ok-native-list-visual'); ++ if (visual) { ++ visual.style.width = '32px'; ++ visual.style.height = '32px'; ++ visual.style.flexBasis = '32px'; ++ } ++ if (row.leading.kind === 'network' && !row.leading.image && !row.leading.fallbackIcon && row.leading.fallbackText) { ++ const fallback = visual?.querySelector('.ok-native-list-visual-fallback'); ++ if (fallback) { ++ fallback.style.fontSize = '19px'; ++ fallback.style.lineHeight = '27px'; ++ fallback.style.fontWeight = '600'; ++ fallback.style.color = 'var(--nl-inverse-text)'; ++ } ++ } ++ visual?.querySelectorAll('.ok-native-list-visual-main').forEach(image => { ++ image.style.width = '32px'; ++ image.style.height = '32px'; ++ }); ++ if (title) { ++ title.style.fontSize = '16px'; ++ title.style.lineHeight = '24px'; ++ title.style.fontWeight = '500'; ++ } ++ body.querySelectorAll('.ok-native-list-accessory').forEach(value => { ++ value.style.fontSize = '16px'; ++ value.style.lineHeight = '24px'; ++ value.style.fontWeight = '500'; ++ }); ++ } ++ } ++ element.replaceChildren(body); + } + renderFooter() { + const row = this.snapshot.fixedFooter; + this.invalidateActionAnchorForElement(this.footer); ++ disposeWebImageRetries(this.footer); + this.footer.replaceChildren(); + if (!row) return; + const element = createElement(this.document, 'div', 'ok-native-list-item'); +@@ -1357,7 +2093,9 @@ export class NativeListWebEngine { + updateVisibleSelection() { + const update = (element, row) => { + if (!row) return; +- const selected = this.selectedKeys.has(row.key); ++ // OneKey patch: selector adapters mark active rows independently of checkbox selection. ++ // const selected = this.selectedKeys.has(row.key); ++ const selected = row.selected === true || this.selectedKeys.has(row.key); + setData(element, 'nativeListSelected', selected); + element.setAttribute('aria-selected', String(selected)); + element.querySelectorAll('.ok-native-list-checkbox').forEach(checkbox => { +@@ -1396,7 +2134,9 @@ export class NativeListWebEngine { + } + this.checkEndReached(last?.index ?? -1); + this.updateStickyHeader(first?.index ?? -1); +- this.updateSectionIndex(first?.index ?? -1); ++ // OneKey patch: index highlighting follows header positions at scroll boundaries. ++ // this.updateSectionIndex(first?.index ?? -1); ++ this.updateSectionIndex(); + } + updateStickyHeader(firstVisibleIndex) { + if (!this.snapshot.layout.stickyHeaders || this.layout.horizontal || firstVisibleIndex < 0) { +@@ -1407,7 +2147,7 @@ export class NativeListWebEngine { + let index = -1; + for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { + const row = this.rows[cursor]; +- if (row?.type === 'sectionHeader' && row.variant !== 'summary') { ++ if (row?.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary') { + index = cursor; + break; + } +@@ -1433,7 +2173,7 @@ export class NativeListWebEngine { + let nextIndex = -1; + for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { + const candidate = this.rows[cursor]; +- if (candidate?.type === 'sectionHeader' && candidate.variant !== 'summary') { ++ if (candidate?.type === 'sectionHeader' && candidate.sticky !== false && candidate.variant !== 'summary') { + nextIndex = cursor; + break; + } +@@ -1443,10 +2183,15 @@ export class NativeListWebEngine { + this.sticky.style.transform = 'translate3d(0,' + String(translate) + 'px,0)'; + this.updateVisibleSelection(); + } +- updateSectionIndex(firstVisibleIndex) { ++ ++ // private updateSectionIndex(firstVisibleIndex: number) { ++ updateSectionIndex() { + let activeKey; + this.snapshot.rows.forEach((row, index) => { +- if (index <= firstVisibleIndex && row.type === 'sectionHeader' && row.indexTitle) activeKey = row.key; ++ if ( ++ // OneKey patch: a spacer ending exactly at the viewport is not the active section. ++ // index <= firstVisibleIndex && ++ itemStart(this.layout.items[index], this.layout.horizontal) <= this.currentOffset() && row.type === 'sectionHeader' && row.sticky !== false && row.indexTitle) activeKey = row.key; + }); + this.indexRail.querySelectorAll('[data-section-key]').forEach(button => setData(button, 'active', button.dataset.sectionKey === activeKey)); + } +@@ -1539,7 +2284,14 @@ export class NativeListWebEngine { + const bindingEpoch = rowElement.dataset.nativeListBindingEpoch; + if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; + this.invalidateActionAnchor('rebind'); +- const rect = actionElement.getBoundingClientRect(); ++ const actualRect = actionElement.getBoundingClientRect(); ++ const inset = Number(actionElement.dataset.nativeListAnchorInset ?? 0); ++ const rect = { ++ left: actualRect.left + inset, ++ top: actualRect.top + inset, ++ width: actualRect.width - inset * 2, ++ height: actualRect.height - inset * 2 ++ }; + const token = [this.actionAnchorInstanceId, this.snapshot.generation, ++this.actionAnchorCounter, bindingEpoch].join(':'); + const slotValue = actionElement.dataset.nativeListAnchorSlot; + const direction = this.document.defaultView?.getComputedStyle(actionElement).direction === 'rtl' || actionElement.closest('[dir="rtl"]') ? 'rtl' : 'ltr'; +@@ -1577,7 +2329,9 @@ export class NativeListWebEngine { + }); + } + handleRowPress(row, rowElement, sourceElement = rowElement) { +- if (row.disabled) return; ++ // OneKey patch: missing-address rows keep their create-address accessory interactive. ++ // if (row.disabled) return; ++ if (row.disabled || row.pressDisabled) return; + if (this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && isSelectableRow(row)) { + this.activateSelection({ + scope: 'row' +@@ -1597,6 +2351,27 @@ export class NativeListWebEngine { + this.emitRowAction(row, actionKey); + } + } ++ ++ // OneKey patch: hover opens the same anchored help action as native taps. ++ handleTitlePointerOver = event => { ++ const target = event.target; ++ if (!(target instanceof Element)) return; ++ const action = target.closest('[data-native-list-hover-action]'); ++ if (!action || event.relatedTarget instanceof Node && action.contains(event.relatedTarget)) return; ++ const rowElement = action.closest('[data-native-list-row-key]'); ++ const row = this.rowAtElement(rowElement); ++ const memberKey = action.closest('[data-native-list-group-member-key]')?.dataset.nativeListGroupMemberKey; ++ const sourceRow = row?.type === 'walletGroup' ? [row.parent, ...row.children].find(member => member.key === memberKey) ?? row : row; ++ const actionKey = action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; ++ if (sourceRow && !sourceRow.disabled && actionKey) this.emitRowAction(sourceRow, actionKey, action, rowElement ?? undefined); ++ }; ++ handleTitlePointerOut = event => { ++ const target = event.target; ++ if (!(target instanceof Element)) return; ++ const action = target.closest('[data-native-list-hover-action]'); ++ if (!action || event.relatedTarget instanceof Node && action.contains(event.relatedTarget)) return; ++ if (this.actionAnchor?.actionElement === action) this.invalidateActionAnchor('pointerLeave'); ++ }; + handleClick = event => { + if (Date.now() < this.suppressClickUntil) { + event.preventDefault(); +@@ -1627,6 +2402,12 @@ export class NativeListWebEngine { + this.handleRowPress(sourceRow, rowElement ?? undefined, memberElement ?? rowElement ?? undefined); + }; + handleKeyDown = event => { ++ // OneKey patch: non-button title help supports keyboard activation. ++ if ((event.key === 'Enter' || event.key === ' ') && event.target instanceof HTMLElement && event.target.matches('[role="button"][data-native-list-action]')) { ++ event.preventDefault(); ++ event.target.click(); ++ return; ++ } + if (event.key === 'Escape') { + if (this.pointerReorder?.active) { + event.preventDefault(); +@@ -1816,7 +2597,14 @@ export class NativeListWebEngine { + const index = Number(rowElement?.dataset.nativeListRowIndex); + const row = this.rows[index]; + if (!row || !this.isReorderable(row)) return; +- if (row.type === 'walletGroup' && target.closest('[data-native-list-group-parent]')?.dataset.nativeListGroupParent !== 'true') return; ++ // OneKey patch: a child drag reorders its parent wallet group as one item. ++ // if ( ++ // row.type === 'walletGroup' && ++ // target.closest('[data-native-list-group-parent]')?.dataset ++ // .nativeListGroupParent !== 'true' ++ // ) ++ // return; ++ + const view = this.document.defaultView; + const state = { + pointerId: event.pointerId, +@@ -1832,9 +2620,8 @@ export class NativeListWebEngine { + active: false + }; + this.pointerReorder = state; +- if (state.pointerType === 'mouse') { +- this.captureReorderPointer(state); +- } else { ++ // OneKey patch: normal wallet taps retain their target until a drag is activated. ++ if (state.pointerType !== 'mouse') { + state.longPressTimer = view?.setTimeout(() => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS); + } + }; +@@ -1935,6 +2722,20 @@ export class NativeListWebEngine { + state.previewOffsetX = state.startX - rect.left; + state.previewOffsetY = Math.min(previewHeight, Math.max(0, state.startY - rect.top)); + this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); ++ // Cloned previews need their own lease when a source row is recycled during dragging. ++ const originals = previewRow.querySelectorAll('img'); ++ this.reorderPreview.querySelectorAll('img').forEach((image, index) => { ++ const original = originals.item(index); ++ const avatar = original ? webAvatarSources.get(original) : undefined; ++ if (!avatar) return; ++ const paint = image.previousElementSibling; ++ if (paint?.classList.contains('ok-native-list-selector-image-background')) { ++ image.addEventListener('load', () => { ++ paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; ++ }); ++ } ++ configureWebAvatar(image, avatar.source, avatar.uri); ++ }); + const sourceRow = state.workingRows[state.currentIndex]; + const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; + if (badgeText) { +@@ -1970,6 +2771,7 @@ export class NativeListWebEngine { + } + clearReorderPreviewVisual() { + this.reorderPreview.hidden = true; ++ disposeWebImageRetries(this.reorderPreview); + this.reorderPreview.replaceChildren(); + this.reorderPreview.style.removeProperty('transform'); + this.reorderPreview.style.removeProperty('transition'); +@@ -2235,4 +3037,3 @@ export class NativeListWebEngine { + }); + } + } +-//# sourceMappingURL=NativeListWebEngine.js.map +\ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts +index 60c6a5c..69829a5 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts +@@ -20,13 +20,43 @@ export type ImageSource = Readonly<{ + optimizeTos?: boolean; + overscan?: number; + loadingStrategy?: ImageLoadingStrategy; ++ fallbackUri?: string; ++ retryTimes?: number; + }>; + export type BadgeModel = Readonly<{ + key: string; + text: string; + tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + }>; ++export type SelectorTextSegment = Readonly<{ ++ text: string; ++ textSegments?: readonly ValueTextSegment[]; ++ tone?: TextTone | 'disabled' | 'caution'; ++ separatorBefore?: boolean; ++}>; ++export type VisualOverlay = Readonly<{ ++ position: 'topLeft' | 'bottomRight'; ++ size?: number; ++ width?: number; ++ height?: number; ++ padding?: number; ++ offset?: number; ++ offsetX?: number; ++ offsetY?: number; ++ image?: ImageSource; ++ name?: string; ++ text?: string; ++ tintColor?: string; ++ backgroundColor?: string; ++}>; + type VisualWithImage = Readonly<{ ++ fallbackIcon?: Readonly<{ ++ name: string; ++ tintColor?: string; ++ }>; ++ overlays?: readonly VisualOverlay[]; ++ borderStyle?: 'dashed'; ++ borderColor?: string; + image?: ImageSource; + fallbackText?: string; + backgroundColor?: string; +@@ -67,10 +97,15 @@ export type SelectionTarget = Readonly<{ + }> | Readonly<{ + scope: 'list'; + }>; ++export type ValueTextSegment = Readonly<{ ++ text: string; ++ style?: 'subscript'; ++}>; + export type TrailingAccessory = Readonly<{ + kind: 'value'; + text: string; + secondary?: boolean; ++ textSegments?: readonly ValueTextSegment[]; + }> | Readonly<{ + kind: 'valuePair'; + primary: string; +@@ -109,6 +144,9 @@ export type TrailingAccessory = Readonly<{ + tintColor?: string; + disabled?: boolean; + actionKey?: string; ++ testID?: string; ++ hoverActionKey?: string; ++ accessibilityLabel?: string; + }> | Readonly<{ + kind: 'spinner'; + }> | Readonly<{ +@@ -122,6 +160,13 @@ export type FooterAction = Readonly<{ + disabled?: boolean; + }>; + export type RowBase = Readonly<{ ++ height?: number; ++ heightRounding?: 'floor' | 'nearest'; ++ testID?: string; ++ opacity?: number; ++ backgroundColor?: string; ++ backgroundFullWidth?: boolean; ++ pressDisabled?: boolean; + key: string; + revision?: number; + sectionKey?: string; +@@ -136,6 +181,13 @@ export type RowBase = Readonly<{ + }>; + export type IdentityRow = RowBase & Readonly<{ + type: 'identity'; ++ titleMatch?: readonly Readonly<{ ++ start: number; ++ end: number; ++ }>[]; ++ titleActionKey?: string; ++ titleActionOnHover?: boolean; ++ subtitleSegments?: readonly SelectorTextSegment[]; + presentation?: 'walletSidebar' | 'accountSelector' | 'networkSelector'; + leading: LeadingVisual; + leadingAction?: Extract; + export type SectionHeaderRow = RowBase & Readonly<{ + type: 'sectionHeader'; ++ sticky?: boolean; ++ valueActionTestID?: string; ++ valueSegments?: readonly ValueTextSegment[]; ++ titleActionKey?: string; ++ titleActionOnHover?: boolean; + sectionKey: string; + presentation?: 'networkSelector'; + indexTitle?: string; +@@ -284,6 +341,12 @@ export type SystemRow = RowBase & (Readonly<{ + variant: 'retry'; + message: string; + actionKey: string; ++}> | Readonly<{ ++ type: 'system'; ++ variant: 'warning'; ++ title: string; ++ message: string; ++ borderColor?: string; + }> | Readonly<{ + type: 'system'; + variant: 'noMatch'; +@@ -299,6 +362,10 @@ export type SystemRow = RowBase & (Readonly<{ + }>); + export type RowModel = IdentityRow | WalletGroupRow | RailRow | ActivityRow | MessageRow | DataRow | MediaTileRow | MetricCardRow | SectionHeaderRow | ActionRow | SystemRow; + export type NativeListTheme = Readonly<{ ++ checkboxBackground?: string; ++ checkboxBorder?: string; ++ checkboxIcon?: string; ++ cautionBackground?: string; + background: string; + rowBackground: string; + rowSelectedBackground: string; +@@ -318,6 +385,7 @@ export type NativeListTheme = Readonly<{ + inverseBackground?: string; + inverseText?: string; + info?: string; ++ caution?: string; + }>; + export type SectionIndexConfig = Readonly<{ + enabled: boolean; +@@ -355,11 +423,11 @@ export type NativeListSnapshot = Readonly<{ + fixedFooter?: ActionRow | SystemRow; + theme?: NativeListTheme; + }>; +-type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator'; ++type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled' | 'accessibilityLabel'; + export type RowPatch = Readonly<{ + type: 'identity'; + key: string; +- changes: Partial>; ++ changes: Partial>; + }> | Readonly<{ + type: 'rail'; + key: string; +@@ -387,7 +455,7 @@ export type RowPatch = Readonly<{ + }> | Readonly<{ + type: 'sectionHeader'; + key: string; +- changes: Partial>; ++ changes: Partial>; + }> | Readonly<{ + type: 'action'; + key: string; +@@ -424,7 +492,7 @@ export type RowActionEvent = Readonly<{ + sectionKey?: string; + anchor?: NativeListActionAnchor; + }>; +-export type ActionAnchorInvalidationReason = 'scroll' | 'rebind' | 'snapshot' | 'layout' | 'destroy'; ++export type ActionAnchorInvalidationReason = 'pointerLeave' | 'scroll' | 'rebind' | 'snapshot' | 'layout' | 'destroy'; + export type ActionAnchorInvalidatedEvent = Readonly<{ + token: string; + reason: ActionAnchorInvalidationReason; +@@ -453,4 +521,3 @@ export type VisibleRangeChangedEvent = Readonly<{ + lastIndex: number; + }>; + export {}; +-//# sourceMappingURL=models.d.ts.map +\ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts +new file mode 100644 +index 0000000..9cd9d65 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts +@@ -0,0 +1,2 @@ ++export declare function canonicalNativeListAvatarUri(uri: string): string | undefined; ++export declare function acquireNativeListAvatar(document: Document, uri: string, resolve: (url: string) => void, reject: () => void): () => void; +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx +index e4cca82..e15f896 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx ++++ b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx +@@ -9,7 +9,7 @@ import React, { + } from 'react'; + import { View } from 'react-native'; + import type { NativeListProps, NativeListRef } from './NativeList.types'; +-import type { NativeListSnapshot } from './models'; ++import type { NativeListSnapshot, RowPatch } from './models'; + import { + normalizeIndexScroll, + normalizeKeyScroll, +@@ -69,7 +69,20 @@ export const NativeList = forwardRef( + [snapshot] + ); + const snapshotRef = useRef(validatedSnapshot); +- snapshotRef.current = validatedSnapshot; ++ const snapshotPropRef = useRef(validatedSnapshot); ++ // An imperative snapshot survives host mounting until the snapshot prop changes. ++ if (snapshotPropRef.current !== validatedSnapshot) { ++ snapshotPropRef.current = validatedSnapshot; ++ snapshotRef.current = validatedSnapshot; ++ } ++ // OneKey patch: React refs can be ready before the DOM engine is mounted. ++ const pendingPatchesRef = useRef< ++ | { ++ snapshot: NativeListSnapshot; ++ batches: Array; ++ } ++ | undefined ++ >(undefined); + const appliedSnapshotRef = useRef( + undefined + ); +@@ -125,6 +138,10 @@ export const NativeList = forwardRef( + ); + engineRef.current = engine; + appliedSnapshotRef.current = snapshotRef.current; ++ const pending = pendingPatchesRef.current; ++ pendingPatchesRef.current = undefined; ++ if (pending?.snapshot === snapshotRef.current) ++ pending.batches.forEach((patches) => engine.applyPatches(patches)); + const initial = initialScrollRef.current; + if (initial && !didApplyInitialScroll.current) { + didApplyInitialScroll.current = true; +@@ -166,6 +183,7 @@ export const NativeList = forwardRef( + useImperativeHandle(forwardedRef, () => ({ + applySnapshot(nextSnapshot) { + const next = validateSnapshot(nextSnapshot); ++ pendingPatchesRef.current = undefined; + snapshotRef.current = next; + appliedSnapshotRef.current = next; + engineRef.current?.applySnapshot(next); +@@ -173,7 +191,16 @@ export const NativeList = forwardRef( + applyPatches(patches) { + if (patches.length > 0) { + serializePatches(patches); +- engineRef.current?.applyPatches(patches); ++ // engineRef.current?.applyPatches(patches); ++ if (engineRef.current) engineRef.current.applyPatches(patches); ++ else { ++ if (pendingPatchesRef.current?.snapshot !== snapshotRef.current) ++ pendingPatchesRef.current = { ++ snapshot: snapshotRef.current, ++ batches: [], ++ }; ++ pendingPatchesRef.current.batches.push(patches); ++ } + } + }, + reconcileSelection(selectedKeys) { +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs +new file mode 100644 +index 0000000..2ceb919 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs +@@ -0,0 +1,431 @@ ++// OneKey patch: focused regression checks for the serialized selector adapter contract. ++const assert = require('node:assert/strict'); ++const fs = require('node:fs'); ++const path = require('node:path'); ++const { test } = require('node:test'); ++const ts = require('typescript'); ++const { JSDOM } = require('jsdom'); ++const packageRoot = path.resolve(__dirname, '../..'); ++const originalLoader = require.extensions['.ts']; ++require.extensions['.ts'] = (module, filename) => { ++ if (!filename.startsWith(packageRoot)) return originalLoader?.(module, filename); ++ const result = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { ++ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, ++ }); ++ module._compile(result.outputText, filename); ++}; ++const { validateSnapshot, serializePatches } = require('../validation.ts'); ++const { NativeListWebEngine, computeWebListLayout, estimateWebRowHeight } = require('../web/NativeListWebEngine.ts'); ++const identity = (key, fields = {}) => ({ type: 'identity', key, title: key, leading: { kind: 'network' }, ...fields }); ++const snapshot = (rows, fields = {}) => ({ schemaVersion: 1, generation: 1, layout: { kind: 'sectioned' }, rows, ...fields }); ++function mount(rows, props = {}) { ++ const dom = new JSDOM('
', { pretendToBeVisual: true }); ++ const view = dom.window; ++ global.Element = view.Element; ++ global.HTMLElement = view.HTMLElement; ++ global.Node = view.Node; ++ view.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 20, y: 30, left: 20, top: 30, right: 100, bottom: 54, width: 80, height: 24 }); ++ view.HTMLElement.prototype.scrollTo = function ({ top = 0, left = 0 }) { this.scrollTop = top; this.scrollLeft = left; }; ++ const actions = [], invalidated = [], selections = []; ++ const engine = new NativeListWebEngine(view.document.getElementById('host'), snapshot(rows, props), { ++ onRowAction: (event) => actions.push(event), ++ onActionAnchorInvalidated: (event) => invalidated.push(event), ++ onSelectionDelta: (event) => selections.push(event), ++ }, false); ++ return { view, engine, document: view.document, actions, invalidated, selections, close() { engine.destroy(); view.close(); } }; ++} ++test('index jumps highlight the section reached after an exact spacer boundary', async () => { ++ const page = mount([ ++ { type: 'sectionHeader', key: 'A', sectionKey: 'A', title: 'A', indexTitle: 'A', height: 36 }, ++ identity('a', { sectionKey: 'A', height: 48 }), ++ { type: 'system', variant: 'spacer', key: 'gap', height: 20 }, ++ { type: 'sectionHeader', key: 'B', sectionKey: 'B', title: 'B', indexTitle: 'B', height: 36 }, ++ identity('b', { sectionKey: 'B', height: 48 }), ++ { type: 'system', variant: 'spacer', key: 'tail-1', height: 500 }, ++ { type: 'system', variant: 'spacer', key: 'tail-2', height: 500 }, ++ ], { capabilities: { sectionIndex: { enabled: true } } }); ++ try { ++ const viewport = page.document.querySelector('.ok-native-list-viewport'); ++ Object.defineProperty(viewport, 'clientHeight', { value: 400 }); ++ Object.defineProperty(viewport, 'clientWidth', { value: 320 }); ++ const index = page.document.querySelector('[aria-label="Jump to B"]'); ++ index.click(); ++ await new Promise(resolve => page.view.requestAnimationFrame(() => page.view.requestAnimationFrame(resolve))); ++ assert.equal(page.document.querySelector('.ok-native-list-viewport').scrollTop, 104); ++ assert.equal(index.dataset.active, 'true'); ++ assert.equal(page.document.querySelector('[aria-label="Jump to A"]').dataset.active, 'false'); ++ } finally { ++ page.close(); ++ } ++}); ++test('selector explicit dimensions override presets without changing existing defaults', () => { ++ const normal = identity('n', { presentation: 'networkSelector' }); ++ assert.equal(estimateWebRowHeight(normal, snapshot([normal]), 400), 47); ++ assert.equal(estimateWebRowHeight({ ...normal, height: 48 }, snapshot([normal]), 400), 48); ++ const account = identity('a', { presentation: 'accountSelector', height: 60 }); ++ assert.equal(computeWebListLayout(snapshot([normal, account]), 400, 800).items[1].height, 60); ++}); ++test('explicit native height rounding survives serialization and rejects incomplete policies', () => { ++ const row = { type: 'sectionHeader', key: 'a', sectionKey: 'a', title: 'A', presentation: 'networkSelector', height: 36, heightRounding: 'nearest' }; ++ const accepted = validateSnapshot(JSON.parse(JSON.stringify(snapshot([row])))); ++ assert.equal(accepted.rows[0].heightRounding, 'nearest'); ++ assert.equal(estimateWebRowHeight(row, accepted, 400), 36); ++ assert.throws(() => validateSnapshot(snapshot([{ ...row, height: undefined }])), /heightRounding/); ++ assert.throws(() => validateSnapshot(snapshot([{ ...row, heightRounding: 'ceil' }])), /heightRounding/); ++ assert.doesNotThrow(() => serializePatches([{ type: 'sectionHeader', key: 'a', changes: { heightRounding: 'nearest' } }])); ++ assert.throws(() => serializePatches([{ type: 'sectionHeader', key: 'a', changes: { heightRounding: 'ceil' } }]), /heightRounding/); ++}); ++test('wallet badge heights propagate through grouped layout', () => { ++ const group = { type: 'walletGroup', key: 'g', parent: identity('g', { presentation: 'walletSidebar' }), children: [identity('c', { presentation: 'walletSidebar', badges: [{ key: 'b', text: 'Bot' }] })] }; ++ assert.equal(estimateWebRowHeight(group, snapshot([group]), 96), 172); ++}); ++test('invalid title ranges and overlays are rejected before native serialization', () => { ++ assert.throws(() => validateSnapshot(snapshot([identity('x', { title: 'abc', titleMatch: [{ start: 1, end: 4 }] })])), /titleMatch/); ++ assert.throws(() => validateSnapshot(snapshot([identity('x', { leading: { kind: 'wallet', overlays: [{ position: 'topLeft', size: 100 }] } })])), /size/); ++ assert.throws(() => validateSnapshot(snapshot([identity('x', { opacity: 2 })])), /opacity/); ++}); ++test('search matches use info color and retain unhighlighted title text', () => { ++ const page = mount([identity('x', { title: 'Ethereum', titleMatch: [{ start: 2, end: 5 }], height: 48, presentation: 'networkSelector' })]); ++ assert.equal(page.document.querySelector('.ok-native-list-title').textContent, 'Ethereum'); ++ assert.equal(page.document.querySelector('.ok-native-list-title .ok-native-list-info').textContent, 'her'); ++ assert.equal(page.document.querySelector('.ok-native-list-title').style.fontSize, '16px'); ++ page.close(); ++}); ++test('subtitle supports a leading address separator and distinct caution tone', () => { ++ const page = mount([identity('x', { subtitleSegments: [{ text: 'Create address', tone: 'caution', separatorBefore: true }] })]); ++ assert.ok(page.document.querySelector('.ok-native-list-subtitle-segments').firstChild.classList.contains('ok-native-list-subtitle-dot')); ++ assert.equal(page.document.querySelector('.ok-native-list-subtitle-segments .ok-native-list-secondary').dataset.tone, 'caution'); ++ page.close(); ++}); ++test('pressDisabled gates row clicks while preserving plus accessory actions', () => { ++ const page = mount([identity('x', { presentation: 'accountSelector', height: 60, pressDisabled: true, trailing: [{ kind: 'icon', name: 'PlusSmallOutline', actionKey: 'create', testID: 'account-manager-plus-button-icon-btn' }] })]); ++ page.document.querySelector('.ok-native-list-title').click(); ++ assert.equal(page.actions.length, 0); ++ page.document.querySelector('[data-native-list-action="create"]').click(); ++ assert.equal(page.actions[0].actionKey, 'create'); ++ assert.ok(page.document.querySelector('[data-testid="account-manager-plus-button-icon-btn"]')); ++ page.close(); ++}); ++test('section help is independent of checkbox selection and anchored to the title', () => { ++ const rows = [{ type: 'sectionHeader', key: 'h', sectionKey: 'a', title: 'Assets', titleActionKey: 'help', titleActionOnHover: true, checkbox: { kind: 'checkbox', state: 'unchecked', target: { scope: 'section', sectionKey: 'a' } } }, identity('x', { sectionKey: 'a' })]; ++ const page = mount(rows, { selection: { mode: 'multiple', selectedKeys: [] } }); ++ const title = page.document.querySelector('[data-native-list-action="help"]'); ++ title.click(); ++ assert.equal(page.actions[0].actionKey, 'help'); ++ assert.equal(page.actions[0].anchor.source, 'leadingAction'); ++ assert.equal(page.selections.length, 0); ++ title.dispatchEvent(new page.view.MouseEvent('pointerover', { bubbles: true })); ++ const token = page.actions.at(-1).anchor.token; ++ page.engine.setActionAnchorState({ token, open: true }); ++ title.dispatchEvent(new page.view.MouseEvent('pointerout', { bubbles: true })); ++ assert.deepEqual(page.invalidated.at(-1), { token, reason: 'pointerLeave' }); ++ page.close(); ++}); ++test('failed network images render the official globe SVG fallback', () => { ++ const page = mount([identity('x', { leading: { kind: 'network', image: { uri: 'https://example.invalid/missing.png', width: 32, height: 32 }, fallbackIcon: { name: 'GlobusOutline' } } })]); ++ page.document.querySelector('.ok-native-list-visual-main').dispatchEvent(new page.view.Event('error')); ++ assert.equal(page.document.querySelector('.ok-native-list-visual-main'), null); ++ assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg path')); ++ page.close(); ++}); ++function fakeImageRetryClock(view) { ++ const timers = new Map(); ++ const cleared = []; ++ let serial = 0; ++ view.setTimeout = (callback, delay) => { ++ assert.ok([0, 1000, 2000].includes(delay)); ++ timers.set(++serial, callback); ++ return serial; ++ }; ++ view.clearTimeout = id => { cleared.push(id); timers.delete(id); }; ++ return { timers, cleared, flush() { const callbacks = [...timers.values()]; timers.clear(); callbacks.forEach(callback => callback()); } }; ++} ++test('optimized image failure falls back to raw, retries raw once, then shows the globe', () => { ++ const page = mount([identity('image', { leading: { kind: 'network', image: { uri: 'https://images.test/optimized.png', fallbackUri: 'https://images.test/raw.png', retryTimes: 1, width: 32, height: 32 }, fallbackIcon: { name: 'GlobusOutline' } } })]); ++ const clock = fakeImageRetryClock(page.view); ++ const image = page.document.querySelector('.ok-native-list-visual-main'); ++ image.dispatchEvent(new page.view.Event('error')); ++ assert.equal(image.src, 'https://images.test/raw.png'); ++ assert.equal(clock.timers.size, 0); ++ image.dispatchEvent(new page.view.Event('error')); ++ assert.equal(clock.timers.size, 1); ++ image.dispatchEvent(new page.view.Event('error')); ++ assert.equal(clock.timers.size, 1); ++ assert.equal(page.document.querySelector('.ok-native-list-visual-main'), image); ++ clock.flush(); ++ assert.equal(image.src, 'https://images.test/raw.png'); ++ image.dispatchEvent(new page.view.Event('error')); ++ assert.equal(page.document.querySelector('.ok-native-list-visual-main'), null); ++ assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg')); ++ page.close(); ++}); ++test('raw image retry works without an optimized source and successful loads stop pending retries', () => { ++ const page = mount([identity('image', { leading: { kind: 'network', image: { uri: 'https://images.test/raw.png', retryTimes: 1, width: 32, height: 32 } } })]); ++ const clock = fakeImageRetryClock(page.view); ++ const image = page.document.querySelector('.ok-native-list-visual-main'); ++ image.dispatchEvent(new page.view.Event('error')); ++ assert.equal(clock.timers.size, 1); ++ image.dispatchEvent(new page.view.Event('load')); ++ assert.equal(clock.timers.size, 0); ++ assert.equal(clock.cleared.length, 1); ++ page.close(); ++}); ++test('row rebinding and list destruction cancel image retries without reviving detached images', () => { ++ const row = uri => identity('image', { leading: { kind: 'network', image: { uri, retryTimes: 1, width: 32, height: 32 } } }); ++ const page = mount([row('https://images.test/old.png')]); ++ const clock = fakeImageRetryClock(page.view); ++ const old = page.document.querySelector('.ok-native-list-visual-main'); ++ old.dispatchEvent(new page.view.Event('error')); ++ const staleCallback = [...clock.timers.values()][0]; ++ page.engine.applySnapshot(snapshot([row('https://images.test/new.png')], { generation: 2 })); ++ assert.equal(clock.timers.size, 0); ++ staleCallback(); ++ assert.equal(old.src, 'https://images.test/old.png'); ++ const current = page.document.querySelector('.ok-native-list-visual-main'); ++ assert.equal(current.src, 'https://images.test/new.png'); ++ current.dispatchEvent(new page.view.Event('error')); ++ assert.equal(clock.timers.size, 1); ++ page.engine.destroy(); ++ assert.equal(clock.timers.size, 0); ++ page.view.close(); ++}); ++test('provider overlays preserve official colors and both corner positions', () => { ++ const page = mount([identity('x', { leading: { kind: 'wallet', overlays: [{ position: 'topLeft', name: 'GoogleIllus' }, { position: 'bottomRight', text: '3' }] } })]); ++ const corners = page.document.querySelectorAll('.ok-native-list-visual-overlay'); ++ assert.equal(corners.length, 2); ++ assert.equal(corners[0].querySelector('path').getAttribute('fill'), '#4285F4'); ++ assert.equal(corners[1].textContent, '3'); ++ page.close(); ++}); ++test('wallet child taps retain child identity and original automation IDs', () => { ++ const group = { type: 'walletGroup', key: 'g', parent: identity('g', { presentation: 'walletSidebar' }), children: [identity('c', { presentation: 'walletSidebar' })] }; ++ const page = mount([group, identity('x', { testID: 'original-network-id', backgroundColor: '#123456' })]); ++ page.document.querySelector('[data-native-list-group-member-key="c"] .ok-native-list-title').click(); ++ assert.equal(page.actions[0].rowKey, 'c'); ++ assert.ok(page.document.querySelector('[data-testid="original-network-id"]')); ++ page.close(); ++}); ++test('very small amounts preserve compact digits inside independent subtitle and value fragments', () => { ++ const runs = [{ text: '0.0' }, { text: '7', style: 'subscript' }, { text: '123' }]; ++ const page = mount([identity('x', { subtitleSegments: [{ text: '0.00000000123 BTC', textSegments: runs }], trailing: [{ kind: 'value', text: '0.00000000123', textSegments: runs }] })]); ++ const subtitle = page.document.querySelector('.ok-native-list-subtitle-segments .ok-native-list-secondary'); ++ const value = page.document.querySelector('.ok-native-list-accessory'); ++ assert.equal(subtitle.textContent, '0.07123'); ++ assert.equal(subtitle.children[1].style.fontSize, '9px'); ++ assert.equal(value.children[1].style.fontSize, '10px'); ++ page.close(); ++}); ++test('deprecated wallet warnings remain normal scroll content with source title and description', () => { ++ const page = mount([{ type: 'system', variant: 'warning', key: 'warning', title: 'Upgrade required', message: 'This wallet needs an upgrade before creating more accounts.', backgroundColor: '#ffcc00', backgroundFullWidth: true, borderColor: '#cc9900' }, identity('x')], { layout: { kind: 'linear', contentPaddingHorizontal: 8 } }); ++ const warning = page.document.querySelector('.ok-native-list-warning'); ++ assert.ok(warning.closest('.ok-native-list-content')); ++ assert.equal(warning.querySelector('.ok-native-list-warning-title').textContent, 'Upgrade required'); ++ assert.equal(warning.querySelector('.ok-native-list-warning-message').textContent, 'This wallet needs an upgrade before creating more accounts.'); ++ assert.equal(warning.parentElement.style.contain, 'layout style'); ++ page.close(); ++}); ++test('late image failures cannot replace the latest row after rapid snapshot rebinding', () => { ++ const makeRow = (index) => identity('x', { title: 'Account ' + index, revision: index, leading: { kind: 'network', image: { uri: 'https://example.invalid/' + index + '.png', width: 32, height: 32 }, fallbackIcon: { name: 'GlobusOutline' } } }); ++ const page = mount([makeRow(0)]); ++ for (let index = 1; index <= 50; index += 1) { ++ const oldImage = page.document.querySelector('.ok-native-list-visual-main'); ++ page.engine.applySnapshot(snapshot([makeRow(index)], { generation: index + 1 })); ++ oldImage.dispatchEvent(new page.view.Event('error')); ++ assert.equal(page.document.querySelector('.ok-native-list-title').textContent, 'Account ' + index); ++ assert.ok(page.document.querySelector('.ok-native-list-visual-main').src.endsWith('/' + index + '.png')); ++ } ++ page.close(); ++}); ++test('non-sticky asset help does not become a sticky title before alphabet sections', async () => { ++ const page = mount([{ type: 'sectionHeader', key: 'help', sectionKey: 'assets', title: 'Asset help', height: 47, sticky: false }, identity('asset', { height: 80 }), { type: 'sectionHeader', key: 'a', sectionKey: 'a', title: 'A', height: 36 }, identity('alphabet', { height: 800 })], { layout: { kind: 'sectioned', stickyHeaders: true } }); ++ const viewport = page.document.querySelector('.ok-native-list-viewport'); ++ viewport.scrollTop = 60; ++ viewport.dispatchEvent(new page.view.Event('scroll')); ++ await new Promise((resolve) => setTimeout(resolve, 25)); ++ assert.equal(page.document.querySelector('.ok-native-list-sticky').hidden, true); ++ viewport.scrollTop = 170; ++ viewport.dispatchEvent(new page.view.Event('scroll')); ++ await new Promise((resolve) => setTimeout(resolve, 25)); ++ assert.equal(page.document.querySelector('.ok-native-list-sticky').textContent, 'A'); ++ page.close(); ++}); ++test('explicit active account and wallet states update without checkbox selection', () => { ++ for (const presentation of ['accountSelector', 'walletSidebar']) { ++ const row = identity('selected', { presentation, selected: true, height: 60 }); ++ const page = mount([row]); ++ const item = page.document.querySelector('[data-native-list-row-key="selected"]'); ++ assert.equal(item.dataset.nativeListSelected, 'true'); ++ page.engine.applySnapshot(snapshot([{ ...row, selected: false }], { generation: 2 })); ++ assert.equal(item.dataset.nativeListSelected, 'false'); ++ page.engine.applySnapshot(snapshot([row], { generation: 3 })); ++ assert.equal(item.dataset.nativeListSelected, 'true'); ++ page.close(); ++ } ++}); ++test('account corner badge preserves its outer ring and inner image dimensions', () => { ++ const row = identity('account', { presentation: 'accountSelector', height: 60, leading: { kind: 'account', shape: 'rounded', overlays: [{ position: 'bottomRight', size: 20, padding: 2, offset: 4, name: 'AllNetworksSolid' }] } }); ++ const page = mount([row]); ++ const overlay = page.document.querySelector('.ok-native-list-visual-overlay'); ++ assert.equal(overlay.style.width, '20px'); ++ assert.equal(overlay.style.padding, '2px'); ++ assert.equal(overlay.style.right, '-4px'); ++ assert.equal(page.document.querySelector('.ok-native-list-account-row').style.borderRadius, '12px'); ++ assert.equal(page.document.querySelector('.ok-native-list-visual').style.borderRadius, '8px'); ++ assert.throws(() => validateSnapshot(snapshot([{ ...row, leading: { ...row.leading, overlays: [{ ...row.leading.overlays[0], padding: 10 }] } }])), /padding/); ++ page.close(); ++}); ++test('network header action anchor includes its own unclipped source SVG underline', () => { ++ const page = mount([{ type: 'sectionHeader', key: 'header', sectionKey: 'summary', presentation: 'networkSelector', height: 71, variant: 'summary', title: '15 networks selected', titleActionKey: 'help', value: 'Deselect all', valueActionKey: 'toggle' }]); ++ const title = page.document.querySelector('[data-native-list-action="help"]'); ++ assert.equal(title.querySelector('.ok-native-list-section-title-text').textContent, '15 networks selected'); ++ assert.equal(title.querySelector('svg line').getAttribute('stroke-dasharray'), '0,4'); ++ assert.equal(title.style.textDecoration, ''); ++ assert.equal(title.querySelector('svg').style.position, 'absolute'); ++ assert.equal(title.style.paddingBottom, '3px'); ++ assert.equal(page.document.querySelector('.ok-native-list-section').style.padding, '24px 12px 20px'); ++ assert.equal(page.document.querySelector('[data-native-list-action="toggle"]').style.fontSize, '16px'); ++ page.close(); ++}); ++test('selector text enables tabular digits while generic rows retain their existing font features', () => { ++ const generic = identity('generic'); ++ const account = identity('account', { presentation: 'accountSelector', title: 'Account 12', subtitleSegments: [{ text: '$5.70' }] }); ++ const network = identity('network', { presentation: 'networkSelector', trailing: [{ kind: 'value', text: '$5.70' }] }); ++ const wallet = identity('wallet', { presentation: 'walletSidebar', title: 'Wallet 12' }); ++ const group = { type: 'walletGroup', key: 'group', parent: { ...wallet, key: 'group' }, children: [{ ...wallet, key: 'child' }] }; ++ const header = { type: 'sectionHeader', key: 'header', sectionKey: 'summary', presentation: 'networkSelector', variant: 'summary', title: '12 networks selected', value: 'Deselect all', valueActionKey: 'toggle' }; ++ const page = mount([generic, account, network, group, header]); ++ const genericBody = page.document.querySelector('[data-native-list-row-key="generic"]>.ok-native-list-row'); ++ assert.equal(genericBody.style.fontVariantNumeric, ''); ++ for (const body of page.document.querySelectorAll('.ok-native-list-account-row,.ok-native-list-network-row,.ok-native-list-wallet-row,.ok-native-list-section')) { ++ assert.equal(body.style.fontVariantNumeric, 'tabular-nums'); ++ for (const text of body.querySelectorAll('span,button')) assert.equal(text.style.fontVariantNumeric, 'tabular-nums'); ++ } ++ page.close(); ++}); ++test('wallet hover anchors the complete child row without consuming normal selection taps', () => { ++ const child = identity('child', { presentation: 'walletSidebar', height: 68, titleActionKey: 'wallet.help', titleActionOnHover: true }); ++ const group = { type: 'walletGroup', key: 'group', parent: identity('group', { presentation: 'walletSidebar', height: 68 }), children: [child] }; ++ const page = mount([group]); ++ assert.equal(estimateWebRowHeight(group, snapshot([group]), 96), 150); ++ const body = page.document.querySelector('[data-native-list-group-member-key="child"] .ok-native-list-wallet-row'); ++ body.querySelector('.ok-native-list-visual').dispatchEvent(new page.view.MouseEvent('pointerover', { bubbles: true })); ++ assert.equal(page.actions.at(-1).rowKey, 'child'); ++ assert.equal(page.actions.at(-1).actionKey, 'wallet.help'); ++ assert.deepEqual(page.actions.at(-1).anchor.windowRect, { x: 20, y: 30, width: 80, height: 24 }); ++ const token = page.actions.at(-1).anchor.token; ++ page.engine.setActionAnchorState({ token, open: true }); ++ body.dispatchEvent(new page.view.MouseEvent('pointerout', { bubbles: true })); ++ assert.deepEqual(page.invalidated.at(-1), { token, reason: 'pointerLeave' }); ++ body.click(); ++ assert.equal(page.actions.at(-1).rowKey, 'child'); ++ assert.equal(page.actions.at(-1).actionKey, 'press'); ++ page.close(); ++}); ++test('account menu preserves its automation ID and returns the 24-point layout slot', () => { ++ const page = mount([identity('account', { presentation: 'accountSelector', height: 60, trailing: [{ kind: 'icon', name: 'DotHorOutline', actionKey: 'menu', testID: 'account-edit' }] })]); ++ const button = page.document.querySelector('[data-testid="account-edit"]'); ++ button.getBoundingClientRect = () => ({ x: 909, y: 312, left: 909, top: 312, right: 947, bottom: 350, width: 38, height: 38 }); ++ button.click(); ++ assert.deepEqual(page.actions.at(-1).anchor.windowRect, { x: 916, y: 319, width: 24, height: 24 }); ++ page.close(); ++}); ++test('network checkbox state changes retain the official checked and indeterminate glyphs', () => { ++ const row = identity('network', { presentation: 'networkSelector', height: 48, trailing: [{ kind: 'checkbox', state: 'unchecked' }] }); ++ const page = mount([row], { theme: { checkboxBackground: '#fdfdfd', checkboxBorder: '#abcdef', checkboxIcon: '#151515' } }); ++ const checkbox = page.document.querySelector('.ok-native-list-checkbox'); ++ assert.equal(checkbox.dataset.selector, 'networkSelector'); ++ assert.equal(checkbox.querySelectorAll('svg').length, 2); ++ assert.equal(checkbox.querySelector('svg[data-state="checked"]').getAttribute('viewBox'), '0 0 16 16'); ++ assert.equal(checkbox.dataset.state, 'unchecked'); ++ page.engine.applySnapshot(snapshot([row], { generation: 2, selection: { mode: 'multiple', selectedKeys: ['network'] } })); ++ assert.equal(page.document.querySelector('.ok-native-list-checkbox').dataset.state, 'checked'); ++ page.close(); ++}); ++test('reorderable wallet taps do not capture the pointer until a drag crosses the threshold', () => { ++ const row = identity('wallet', { presentation: 'walletSidebar', height: 68, draggable: true }); ++ const page = mount([row], { capabilities: { reorderable: true } }); ++ const viewport = page.document.querySelector('.ok-native-list-viewport'); ++ const body = page.document.querySelector('.ok-native-list-wallet-row'); ++ const captures = []; ++ viewport.setPointerCapture = id => captures.push(id); ++ const pointer = (type, x, y) => { ++ const event = new page.view.MouseEvent(type, { bubbles: true, clientX: x, clientY: y }); ++ Object.defineProperties(event, { pointerId: { value: 1 }, pointerType: { value: 'mouse' }, isPrimary: { value: true } }); ++ body.dispatchEvent(event); ++ }; ++ pointer('pointerdown', 30, 40); ++ assert.deepEqual(captures, []); ++ pointer('pointerup', 30, 40); ++ body.click(); ++ assert.equal(page.actions.at(-1).actionKey, 'press'); ++ pointer('pointerdown', 30, 40); ++ pointer('pointermove', 50, 60); ++ assert.deepEqual(captures, [1]); ++ pointer('pointercancel', 50, 60); ++ page.close(); ++}); ++ ++test('accessory help uses a separate hover action without replacing the edit click', () => { ++ const page = mount([identity('custom', { presentation: 'networkSelector', height: 48, trailing: [{ kind: 'icon', name: 'PencilOutline', actionKey: 'edit', hoverActionKey: 'edit.help', accessibilityLabel: 'Edit' }] })]); ++ const button = page.document.querySelector('[data-native-list-action="edit"]'); ++ assert.equal(button.getAttribute('aria-label'), 'Edit'); ++ button.dispatchEvent(new page.view.MouseEvent('pointerover', { bubbles: true })); ++ assert.equal(page.actions.at(-1).actionKey, 'edit.help'); ++ assert.equal(page.actions.at(-1).anchor.source, 'trailingAccessory'); ++ button.click(); ++ assert.equal(page.actions.at(-1).actionKey, 'edit'); ++ page.close(); ++}); ++test('wallet overlays preserve rectangular firmware badges and naturally sized numeric labels', () => { ++ const wallet = identity('wallet', { presentation: 'walletSidebar', height: 68, leading: { kind: 'wallet', overlays: [{ position: 'topLeft', width: 18, height: 16, offsetX: 0, offsetY: 4, padding: 1, image: { uri: 'https://images.test/btc.png', width: 14, height: 14, contentFit: 'contain' } }, { position: 'bottomRight', text: '12', height: 16, offsetX: 1, offsetY: 2 }] } }); ++ const page = mount([wallet]); ++ const [firmware, number] = page.document.querySelectorAll('.ok-native-list-visual-overlay'); ++ assert.equal(firmware.style.width, '18px'); ++ assert.equal(firmware.style.height, '16px'); ++ assert.equal(firmware.style.left, '0px'); ++ assert.equal(firmware.style.top, '-4px'); ++ assert.equal(number.style.width, 'auto'); ++ assert.equal(number.style.padding, '0px 2px'); ++ assert.equal(number.style.fontSize, '12px'); ++ assert.equal(number.style.fontWeight, '400'); ++ assert.equal(number.style.right, '-1px'); ++ page.close(); ++}); ++test('hidden-wallet lock fills its avatar while the add-hidden plus keeps its original size', () => { ++ const lock = identity('group', { presentation: 'walletSidebar', height: 68, testID: 'wallet-group', leading: { kind: 'wallet', fallbackIcon: { name: 'LockSolid' } } }); ++ const plus = identity('plus', { presentation: 'walletSidebar', height: 68, leading: { kind: 'wallet', borderStyle: 'dashed', fallbackIcon: { name: 'PlusSmallOutline' } } }); ++ const page = mount([{ type: 'walletGroup', key: 'group', parent: lock, children: [plus] }]); ++ const parent = page.document.querySelector('[data-testid="wallet-group"]'); ++ assert.equal(parent.querySelector('svg').style.width, '40px'); ++ const child = page.document.querySelector('[data-native-list-group-member-key="plus"]'); ++ assert.equal(child.querySelector('svg').getAttribute('width'), '24'); ++ assert.equal(child.querySelector('.ok-native-list-visual').style.borderWidth, '1px'); ++ page.close(); ++}); ++test('account add actions retain ListItem medium text while empty-search text remains regular', () => { ++ const page = mount([{ type: 'action', key: 'add', actionKey: 'add', title: 'Add account', height: 48, presentation: 'accountSelector', icon: { kind: 'icon', name: 'PlusSmallOutline' } }, { type: 'action', key: 'empty', actionKey: 'empty', title: 'No account', height: 60, presentation: 'accountSelector', tone: 'primary' }]); ++ assert.equal(page.document.querySelector('[data-native-list-row-key="add"] .ok-native-list-action-title').style.fontWeight, '500'); ++ assert.equal(page.document.querySelector('[data-native-list-row-key="empty"] .ok-native-list-action-title').style.fontWeight, ''); ++ page.close(); ++}); ++test('selector background pixels follow successful image sources and disappear after terminal failure or rebinding', () => { ++ const row = uri => identity('image', { height: 48, presentation: 'networkSelector', leading: { kind: 'network', image: { uri, fallbackUri: 'https://images.test/raw.png', width: 32, height: 32, contentFit: 'cover' }, fallbackIcon: { name: 'GlobusOutline' } } }); ++ const page = mount([row('https://images.test/optimized.png')]); ++ const image = page.document.querySelector('img'); ++ const paint = page.document.querySelector('.ok-native-list-selector-image-background'); ++ assert.equal(paint.style.backgroundImage, ''); ++ image.dispatchEvent(new page.view.Event('error')); ++ assert.equal(image.src, 'https://images.test/raw.png'); ++ image.dispatchEvent(new page.view.Event('load')); ++ assert.ok(paint.style.backgroundImage.includes('https://images.test/raw.png')); ++ page.engine.applySnapshot(snapshot([row('https://images.test/new.png')], { generation: 2 })); ++ assert.equal(paint.isConnected, false); ++ assert.equal(page.document.querySelector('.ok-native-list-selector-image-background').style.backgroundImage, ''); ++ const nextImage = page.document.querySelector('img'); ++ nextImage.dispatchEvent(new page.view.Event('error')); ++ nextImage.dispatchEvent(new page.view.Event('error')); ++ assert.equal(page.document.querySelector('img'), null); ++ assert.equal(page.document.querySelector('.ok-native-list-selector-image-background').style.backgroundImage, 'none'); ++ assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg')); ++ page.close(); ++}); +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/models.ts b/node_modules/@onekeyfe/react-native-native-list/src/models.ts +index 82ca2dd..5fed01c 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/src/models.ts ++++ b/node_modules/@onekeyfe/react-native-native-list/src/models.ts +@@ -22,6 +22,9 @@ export type ImageSource = Readonly<{ + optimizeTos?: boolean; + overscan?: number; + loadingStrategy?: ImageLoadingStrategy; ++ // OneKey patch: fallbackUri is Web-only; retryTimes opts all platforms into terminal retries. ++ fallbackUri?: string; ++ retryTimes?: number; + }>; + + export type BadgeModel = Readonly<{ +@@ -30,7 +33,34 @@ export type BadgeModel = Readonly<{ + tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + }>; + ++// OneKey patch: keep selector decoration and text data serializable. ++export type SelectorTextSegment = Readonly<{ ++ text: string; ++ textSegments?: readonly ValueTextSegment[]; ++ tone?: TextTone | 'disabled' | 'caution'; ++ separatorBefore?: boolean; ++}>; ++export type VisualOverlay = Readonly<{ ++ position: 'topLeft' | 'bottomRight'; ++ size?: number; ++ width?: number; ++ height?: number; ++ padding?: number; ++ offset?: number; ++ offsetX?: number; ++ offsetY?: number; ++ image?: ImageSource; ++ name?: string; ++ text?: string; ++ tintColor?: string; ++ backgroundColor?: string; ++}>; ++ + type VisualWithImage = Readonly<{ ++ fallbackIcon?: Readonly<{ name: string; tintColor?: string }>; ++ overlays?: readonly VisualOverlay[]; ++ borderStyle?: 'dashed'; ++ borderColor?: string; + image?: ImageSource; + fallbackText?: string; + backgroundColor?: string; +@@ -70,8 +100,11 @@ export type SelectionTarget = + | Readonly<{ scope: 'section'; sectionKey: string }> + | Readonly<{ scope: 'list' }>; + ++// OneKey patch: preserve compact very-small-balance digit runs. ++export type ValueTextSegment = Readonly<{ text: string; style?: 'subscript' }>; ++ + export type TrailingAccessory = +- | Readonly<{ kind: 'value'; text: string; secondary?: boolean }> ++ | Readonly<{ kind: 'value'; text: string; secondary?: boolean; textSegments?: readonly ValueTextSegment[] }> + | Readonly<{ + kind: 'valuePair'; + primary: string; +@@ -108,6 +141,9 @@ export type TrailingAccessory = + tintColor?: string; + disabled?: boolean; + actionKey?: string; ++ testID?: string; ++ hoverActionKey?: string; ++ accessibilityLabel?: string; + }> + | Readonly<{ kind: 'spinner' }> + | Readonly<{ kind: 'progress'; value: number }>; +@@ -120,6 +156,15 @@ export type FooterAction = Readonly<{ + }>; + + export type RowBase = Readonly<{ ++ // OneKey patch: preserve measured selector geometry without changing defaults. ++ height?: number; ++ // OneKey patch: retain the source Android list's measured fractional-DP rounding. ++ heightRounding?: 'floor' | 'nearest'; ++ testID?: string; ++ opacity?: number; ++ backgroundColor?: string; ++ backgroundFullWidth?: boolean; ++ pressDisabled?: boolean; + key: string; + revision?: number; + sectionKey?: string; +@@ -136,6 +181,11 @@ export type RowBase = Readonly<{ + export type IdentityRow = RowBase & + Readonly<{ + type: 'identity'; ++ // OneKey patch: UTF-16 ranges match Fuse indices and native attributed strings. ++ titleMatch?: readonly Readonly<{ start: number; end: number }>[]; ++ titleActionKey?: string; ++ titleActionOnHover?: boolean; ++ subtitleSegments?: readonly SelectorTextSegment[]; + presentation?: 'walletSidebar' | 'accountSelector' | 'networkSelector'; + leading: LeadingVisual; + leadingAction?: Extract; +@@ -257,6 +307,12 @@ export type MetricCardRow = RowBase & + export type SectionHeaderRow = RowBase & + Readonly<{ + type: 'sectionHeader'; ++ // OneKey patch: title help stays independent of section selection. ++ sticky?: boolean; ++ valueActionTestID?: string; ++ valueSegments?: readonly ValueTextSegment[]; ++ titleActionKey?: string; ++ titleActionOnHover?: boolean; + sectionKey: string; + presentation?: 'networkSelector'; + indexTitle?: string; +@@ -291,6 +347,7 @@ export type SystemRow = RowBase & + message: string; + actionKey: string; + }> ++ | Readonly<{ type: 'system'; variant: 'warning'; title: string; message: string; borderColor?: string }> + | Readonly<{ type: 'system'; variant: 'noMatch'; message: string }> + | Readonly<{ type: 'system'; variant: 'end'; message?: string }> + | Readonly<{ type: 'system'; variant: 'spacer'; height: number }> +@@ -310,6 +367,11 @@ export type RowModel = + | SystemRow; + + export type NativeListTheme = Readonly<{ ++ // OneKey patch: explicit selector controls use the original semantic theme tokens. ++ checkboxBackground?: string; ++ checkboxBorder?: string; ++ checkboxIcon?: string; ++ cautionBackground?: string; + background: string; + rowBackground: string; + rowSelectedBackground: string; +@@ -329,6 +391,8 @@ export type NativeListTheme = Readonly<{ + inverseBackground?: string; + inverseText?: string; + info?: string; ++ // OneKey patch: match the existing account warning address tone. ++ caution?: string; + }>; + + export type SectionIndexConfig = Readonly<{ +@@ -369,7 +433,11 @@ export type NativeListSnapshot = Readonly<{ + theme?: NativeListTheme; + }>; + +-type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator'; ++// OneKey patch: include selector-only mutable presentation fields. ++// type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator'; ++// OneKey patch: balance patches also refresh the existing row's spoken content. ++// type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled'; ++type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled' | 'accessibilityLabel'; + + export type RowPatch = + | Readonly<{ +@@ -387,6 +455,10 @@ export type RowPatch = + | 'trailing' + | 'leading' + | 'leadingAction' ++ | 'titleMatch' ++ | 'titleActionKey' ++ | 'titleActionOnHover' ++ | 'subtitleSegments' + > + >; + }> +@@ -501,6 +573,8 @@ export type RowPatch = + | 'subtitle' + | 'value' + | 'valueActionKey' ++ | 'titleActionKey' ++ | 'titleActionOnHover' + | 'titleIcon' + | 'valueIcon' + | 'checkbox' +@@ -565,6 +639,8 @@ export type RowActionEvent = Readonly<{ + }>; + + export type ActionAnchorInvalidationReason = ++ // OneKey patch: close web title tooltips when their native list target is left. ++ | 'pointerLeave' + | 'scroll' + | 'rebind' + | 'snapshot' +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/validation.ts b/node_modules/@onekeyfe/react-native-native-list/src/validation.ts +index a64a0ec..10e9c48 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/src/validation.ts ++++ b/node_modules/@onekeyfe/react-native-native-list/src/validation.ts +@@ -214,6 +214,26 @@ function assertLeadingVisual( + assertImage(visual.image, `${path}.image`); + assertVisualShape(visual.shape, `${path}.shape`); + assertText(visual.cornerIcon?.name, `${path}.cornerIcon.name`); ++ // OneKey patch: validate optional selector overlays at the JSON boundary. ++ assertText(visual.fallbackIcon?.name, `${path}.fallbackIcon.name`); ++ if ((visual.overlays?.length ?? 0) > 2) fail(`${path}.overlays`, 'supports at most two overlays'); ++ visual.overlays?.forEach((overlay, index) => { ++ if (!['topLeft', 'bottomRight'].includes(overlay.position)) fail(`${path}.overlays[${index}].position`, 'must be topLeft or bottomRight'); ++ if (overlay.size !== undefined && (overlay.size <= 0 || overlay.size > 40)) fail(`${path}.overlays[${index}].size`, 'must be within 1...40'); ++ if (overlay.padding !== undefined && (!Number.isFinite(overlay.padding) || overlay.padding < 0 || overlay.padding * 2 >= (overlay.size ?? 20))) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); ++ if (overlay.offset !== undefined && (!Number.isFinite(overlay.offset) || overlay.offset < 0 || overlay.offset > 20)) fail(`${path}.overlays[${index}].offset`, 'must be within 0...20'); ++ for (const field of ['width', 'height'] as const) { ++ const value = overlay[field]; ++ if (value !== undefined && (!Number.isFinite(value) || value <= 0 || value > 40)) fail(`${path}.overlays[${index}].${field}`, 'must be within 1...40'); ++ } ++ for (const field of ['offsetX', 'offsetY'] as const) { ++ const value = overlay[field]; ++ if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 20)) fail(`${path}.overlays[${index}].${field}`, 'must be within 0...20'); ++ } ++ if (overlay.padding !== undefined && overlay.padding * 2 >= Math.min(overlay.width ?? overlay.size ?? 20, overlay.height ?? overlay.size ?? 20)) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); ++ assertImage(overlay.image, `${path}.overlays[${index}].image`); ++ assertText(overlay.text, `${path}.overlays[${index}].text`); ++ }); + if (visual.kind === 'token') { + assertImage(visual.networkImage, `${path}.networkImage`); + } +@@ -288,6 +308,10 @@ function assertRow( + path = `rows[${index}]` + ): void { + assertKey(row.key, `${path}.key`); ++ // OneKey patch: explicit dimensions and opacity cannot corrupt list layout. ++ if (row.height !== undefined && (row.height < 0 || row.height > 4096)) fail(`${path}.height`, 'must be within 0...4096'); ++ if (row.heightRounding !== undefined && (row.height === undefined || !['floor', 'nearest'].includes(row.heightRounding))) fail(`${path}.heightRounding`, 'requires an explicit height and must be floor or nearest'); ++ if (row.opacity !== undefined && (row.opacity < 0 || row.opacity > 1)) fail(`${path}.opacity`, 'must be within 0...1'); + if (row.groupId && !row.groupPosition) { + fail(`${path}.groupPosition`, 'is required when groupId is present'); + } +@@ -314,6 +338,16 @@ function assertRow( + } + assertText(row.title, `${path}.title`); + assertText(row.subtitle, `${path}.subtitle`); ++ // OneKey patch: selector text segments truncate independently. ++ row.subtitleSegments?.forEach((segment, segmentIndex) => { ++ assertText(segment.text, `${path}.subtitleSegments[${segmentIndex}].text`); ++ if (segment.tone !== undefined && !['primary', 'secondary', 'disabled', 'caution', 'positive', 'negative'].includes(segment.tone)) fail(`${path}.subtitleSegments[${segmentIndex}].tone`, 'invalid selector text tone'); ++ }); ++ let previousMatchEnd = 0; ++ row.titleMatch?.forEach((match) => { ++ if (!Number.isInteger(match.start) || !Number.isInteger(match.end) || match.start < previousMatchEnd || match.end <= match.start || match.end > row.title.length) fail(`${path}.titleMatch`, 'must contain ordered, non-overlapping UTF-16 ranges inside title'); ++ previousMatchEnd = match.end; ++ }); + assertText(row.tertiary, `${path}.tertiary`); + if ( + row.tertiaryTone !== undefined && +@@ -470,13 +504,15 @@ function assertRow( + break; + case 'system': + if ( +- !['loading', 'retry', 'noMatch', 'end', 'spacer'].includes(row.variant) ++ // OneKey patch: deprecated-wallet warnings retain the original scrolling semantics. ++ !['loading', 'retry', 'noMatch', 'end', 'spacer', 'warning'].includes(row.variant) + ) { + fail( + `${path}.variant`, +- 'must be loading, retry, noMatch, end, or spacer' ++ 'must be loading, retry, noMatch, end, spacer, or warning' + ); + } ++ if (row.variant === 'warning') assertText(row.title, `${path}.title`); + if (row.variant !== 'spacer') { + assertText(row.message, `${path}.message`); + } +@@ -649,6 +685,12 @@ export function validateSnapshot( + + function assertPatchChanges(patch: RowPatch, index: number): void { + const path = `patches[${index}].changes`; ++ // OneKey patch: partial balance updates retain a valid, current accessibility label. ++ if ('accessibilityLabel' in patch.changes) { ++ assertText(patch.changes.accessibilityLabel, `${path}.accessibilityLabel`); ++ } ++ // OneKey patch: partial updates may refer to an existing height but still require a valid policy. ++ if ('heightRounding' in patch.changes && patch.changes.heightRounding !== undefined && !['floor', 'nearest'].includes(patch.changes.heightRounding)) fail(`${path}.heightRounding`, 'must be floor or nearest'); + if ( + patch.changes.revision !== undefined && + (!Number.isSafeInteger(patch.changes.revision) || +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js +new file mode 100644 +index 0000000..fff4081 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js +@@ -0,0 +1,258 @@ ++// OneKey patch: avatar generation and PNG bytes stay in this external worker. ++// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64 ++// Permission is hereby granted, free of charge, to any person obtaining a copy ++// of this software and associated documentation files (the "Software"), to deal ++// in the Software without restriction, including without limitation the rights ++// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell ++// copies of the Software, and to permit persons to whom the Software is ++// furnished to do so, subject to the following conditions: ++// The above copyright notice and this permission notice shall be included in ++// all copies or substantial portions of the Software. ++// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR ++// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, ++// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE ++// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER ++// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, ++// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN ++// THE SOFTWARE. ++ ++const PREFIX = 'onekey-avatar://blockie/v1/'; ++const DATABASE = 'onekey-native-list-avatar-v1'; ++const MAX_DISK_BYTES = 32 * 1024 * 1024; ++const MAX_DISK_ENTRIES = 2048; ++const MAX_CONCURRENT_LOADS = 2; ++const requests = new Map(); ++const images = new Map(); ++const jobs = new Map(); ++const queue = []; ++let activeLoads = 0; ++let databasePromise; ++ ++function openDatabase() { ++ if (!databasePromise) { ++ databasePromise = new Promise((resolve, reject) => { ++ const request = indexedDB.open(DATABASE, 1); ++ request.onupgradeneeded = () => { ++ const store = request.result.createObjectStore('images', { keyPath: 'uri' }); ++ store.createIndex('accessed', 'accessed'); ++ request.result.createObjectStore('metadata'); ++ }; ++ let blocked = false; ++ request.onsuccess = () => { ++ const database = request.result; ++ if (blocked) { database.close(); return; } ++ database.onversionchange = () => { database.close(); databasePromise = undefined; }; ++ resolve(database); ++ }; ++ request.onerror = () => reject(request.error); ++ request.onblocked = () => { ++ blocked = true; ++ reject(new Error('Avatar cache database is blocked')); ++ }; ++ }).catch(() => undefined); ++ } ++ return databasePromise; ++} ++ ++async function readDisk(uri) { ++ const database = await openDatabase(); ++ if (!database) return undefined; ++ return new Promise((resolve) => { ++ try { ++ const transaction = database.transaction('images', 'readwrite'); ++ const store = transaction.objectStore('images'); ++ const request = store.get(uri); ++ let blob; ++ request.onsuccess = () => { ++ const record = request.result; ++ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) { ++ blob = record.blob; ++ store.put({ ...record, accessed: Date.now() }); ++ } ++ }; ++ transaction.oncomplete = () => resolve(blob); ++ transaction.onerror = transaction.onabort = () => resolve(undefined); ++ } catch { resolve(undefined); } ++ }); ++} ++ ++async function writeDisk(uri, blob) { ++ const database = await openDatabase(); ++ if (!database || blob.size > MAX_DISK_BYTES) return; ++ await new Promise((resolve) => { ++ try { ++ const transaction = database.transaction(['images', 'metadata'], 'readwrite'); ++ const store = transaction.objectStore('images'); ++ const metadata = transaction.objectStore('metadata'); ++ const previous = store.get(uri); ++ previous.onsuccess = () => { ++ const counter = metadata.get('size'); ++ counter.onsuccess = () => { ++ const size = counter.result || { bytes: 0, count: 0 }; ++ size.bytes += blob.size - (previous.result?.blob?.size || 0); ++ size.count += previous.result ? 0 : 1; ++ store.put({ uri, blob, accessed: Date.now() }); ++ const save = () => metadata.put(size, 'size'); ++ if (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES) { save(); return; } ++ const cursor = store.index('accessed').openCursor(); ++ cursor.onsuccess = () => { ++ const item = cursor.result; ++ if (!item || (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES)) { save(); return; } ++ if (item.value.uri !== uri) { ++ size.bytes -= item.value.blob.size; ++ size.count -= 1; ++ item.delete(); ++ } ++ item.continue(); ++ }; ++ }; ++ }; ++ transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); ++ } catch { resolve(); } ++ }); ++} ++ ++// PRNG and HSL conversion adapted from ethereum-blockies-base64 1.0.2 (MIT), MyCrypto. ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/main.js ++// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/hsl2rgb.js ++// Preserve signed shifts, color order, and RGB rounding to match V1's decoded pixels. ++async function generateBlob(seed) { ++ const state = [0, 0, 0, 0]; ++ for (let i = 0; i < seed.length; i += 1) { ++ state[i % 4] = (state[i % 4] << 5) - state[i % 4] + seed.charCodeAt(i); ++ } ++ const rand = () => { ++ const t = state[0] ^ (state[0] << 11); ++ state[0] = state[1]; ++ state[1] = state[2]; ++ state[2] = state[3]; ++ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8); ++ return (state[3] >>> 0) / ((1 << 31) >>> 0); ++ }; ++ const hue = (p, q, value) => { ++ let t = value; ++ if (t < 0) t += 1; ++ if (t > 1) t -= 1; ++ if (t < 1 / 6) return p + (q - p) * 6 * t; ++ if (t < 1 / 2) return q; ++ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; ++ return p; ++ }; ++ const color = () => { ++ const h = Math.floor(rand() * 360) / 360; ++ const s = (rand() * 60 + 40) / 100; ++ const l = ((rand() + rand() + rand() + rand()) * 25) / 100; ++ const q = l < 0.5 ? l * (1 + s) : l + s - l * s; ++ const p = 2 * l - q; ++ const rgb = s === 0 ? [l, l, l] : [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)]; ++ return `rgb(${rgb.map((value) => Math.round(value * 255)).join(',')})`; ++ }; ++ const foreground = color(); ++ const background = color(); ++ const spot = color(); ++ const canvas = new OffscreenCanvas(128, 128); ++ const context = canvas.getContext('2d'); ++ if (!context) throw new Error('Avatar canvas is unavailable'); ++ context.fillStyle = background; ++ context.fillRect(0, 0, 128, 128); ++ for (let row = 0; row < 8; row += 1) { ++ for (let column = 0; column < 4; column += 1) { ++ const value = Math.floor(rand() * 2.3); ++ if (value === 0) continue; ++ context.fillStyle = value === 1 ? foreground : spot; ++ context.fillRect(column * 16, row * 16, 16, 16); ++ context.fillRect((7 - column) * 16, row * 16, 16, 16); ++ } ++ } ++ return canvas.convertToBlob({ type: 'image/png' }); ++} ++ ++function release(id) { ++ const uri = requests.get(id); ++ requests.delete(id); ++ const image = images.get(uri); ++ image?.references.delete(id); ++ if (image && image.references.size === 0) { ++ URL.revokeObjectURL(image.url); ++ images.delete(uri); ++ } ++ const job = jobs.get(uri); ++ job?.ids.delete(id); ++ if (job && job.ids.size === 0 && !job.active) { ++ jobs.delete(uri); ++ const index = queue.indexOf(job); ++ if (index !== -1) queue.splice(index, 1); ++ } ++} ++ ++async function runJob(job) { ++ let blob = await readDisk(job.uri); ++ if (blob) { ++ try { ++ const bitmap = await createImageBitmap(blob); ++ const valid = bitmap.width === 128 && bitmap.height === 128; ++ bitmap.close(); ++ if (!valid) blob = undefined; ++ } catch { blob = undefined; } ++ } ++ if (!blob && job.ids.size) { ++ blob = await generateBlob(job.seed); ++ await writeDisk(job.uri, blob); ++ } ++ if (!blob || !job.ids.size) return; ++ const image = { url: URL.createObjectURL(blob), references: new Set() }; ++ images.set(job.uri, image); ++ job.ids.forEach((id) => { ++ if (requests.get(id) !== job.uri) return; ++ image.references.add(id); ++ postMessage({ type: 'resolved', id, url: image.url }); ++ }); ++ if (!image.references.size) { URL.revokeObjectURL(image.url); images.delete(job.uri); } ++} ++ ++function pump() { ++ while (activeLoads < MAX_CONCURRENT_LOADS && queue.length) { ++ const job = queue.shift(); ++ if (jobs.get(job.uri) !== job || !job.ids.size) continue; ++ job.active = true; ++ activeLoads += 1; ++ runJob(job).catch(() => { ++ job.ids.forEach((id) => { ++ if (requests.get(id) === job.uri) postMessage({ type: 'error', id }); ++ }); ++ }).finally(() => { ++ if (jobs.get(job.uri) === job) jobs.delete(job.uri); ++ activeLoads -= 1; ++ pump(); ++ }); ++ } ++} ++ ++onmessage = ({ data }) => { ++ if (!data || !Number.isSafeInteger(data.id)) return; ++ if (data.type === 'release') { release(data.id); return; } ++ if (data.type !== 'acquire') return; ++ release(data.id); ++ try { ++ if (typeof data.uri !== 'string' || !data.uri.startsWith(PREFIX)) throw new Error('Invalid avatar URI'); ++ const seed = decodeURIComponent(data.uri.slice(PREFIX.length)).toLowerCase(); ++ if (!seed) throw new Error('Empty avatar seed'); ++ const uri = PREFIX + encodeURIComponent(seed); ++ requests.set(data.id, uri); ++ const image = images.get(uri); ++ if (image) { ++ image.references.add(data.id); ++ postMessage({ type: 'resolved', id: data.id, url: image.url }); ++ return; ++ } ++ let job = jobs.get(uri); ++ if (!job) { ++ job = { uri, seed, ids: new Set(), active: false }; ++ jobs.set(uri, job); ++ queue.push(job); ++ } ++ job.ids.add(data.id); ++ pump(); ++ } catch { postMessage({ type: 'error', id: data.id }); } ++}; +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts +new file mode 100644 +index 0000000..d7fceab +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts +@@ -0,0 +1,138 @@ ++// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. ++const AVATAR_PREFIX = 'onekey-avatar://blockie/v1/'; ++const MAX_RETAINED_AVATARS = 128; ++ ++type AvatarEntry = { ++ id: number; ++ uri: string; ++ url?: string; ++ references: number; ++ listeners: Set<{ resolve: (url: string) => void; reject: () => void }>; ++}; ++ ++type AvatarResponse = ++ | { type: 'resolved'; id: number; url: string } ++ | { type: 'error'; id: number }; ++ ++export function canonicalNativeListAvatarUri(uri: string): string | undefined { ++ if (!uri.startsWith(AVATAR_PREFIX)) return undefined; ++ try { ++ const seed = decodeURIComponent(uri.slice(AVATAR_PREFIX.length)); ++ return seed ? AVATAR_PREFIX + encodeURIComponent(seed.toLowerCase()) : undefined; ++ } catch { ++ return undefined; ++ } ++} ++ ++class NativeListWebAvatarCache { ++ private readonly entries = new Map(); ++ private readonly requests = new Map(); ++ private nextId = 0; ++ private worker: Worker | undefined; ++ ++ acquire(uri: string, resolve: (url: string) => void, reject: () => void): () => void { ++ let entry = this.entries.get(uri); ++ const isNew = !entry; ++ if (!entry) { ++ entry = { id: ++this.nextId, uri, references: 0, listeners: new Set() }; ++ this.entries.set(uri, entry); ++ this.requests.set(entry.id, entry); ++ } ++ const current = entry; ++ this.entries.delete(uri); ++ this.entries.set(uri, current); ++ current.references += 1; ++ const listener = { resolve, reject }; ++ if (current.url) resolve(current.url); ++ else current.listeners.add(listener); ++ if (isNew) { ++ try { ++ if (!this.worker) { ++ // Deliberately avoid *.worker.js and its inline Blob-worker loader. ++ this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { ++ name: 'onekey-native-list-avatar', ++ }); ++ this.worker.addEventListener('message', this.handleMessage); ++ this.worker.addEventListener('error', this.handleFailure); ++ this.worker.addEventListener('messageerror', this.handleFailure); ++ } ++ this.worker.postMessage({ type: 'acquire', id: current.id, uri }); ++ } catch { ++ queueMicrotask(this.handleFailure); ++ } ++ } ++ this.trim(); ++ let disposed = false; ++ return () => { ++ if (disposed) return; ++ disposed = true; ++ current.listeners.delete(listener); ++ current.references = Math.max(0, current.references - 1); ++ if (current.references === 0 && !current.url && this.entries.get(uri) === current) { ++ this.entries.delete(uri); ++ this.requests.delete(current.id); ++ this.worker?.postMessage({ type: 'release', id: current.id }); ++ } ++ this.trim(); ++ }; ++ } ++ ++ private readonly handleMessage = (event: MessageEvent) => { ++ const response = event.data; ++ if (!response || !Number.isSafeInteger(response.id)) return; ++ const entry = this.requests.get(response.id); ++ if (!entry) { ++ this.worker?.postMessage({ type: 'release', id: response.id }); ++ return; ++ } ++ if (response.type === 'resolved' && typeof response.url === 'string' && response.url.startsWith('blob:')) { ++ entry.url = response.url; ++ entry.listeners.forEach((listener) => listener.resolve(response.url)); ++ } else { ++ this.entries.delete(entry.uri); ++ this.requests.delete(entry.id); ++ this.worker?.postMessage({ type: 'release', id: entry.id }); ++ entry.listeners.forEach((listener) => listener.reject()); ++ } ++ entry.listeners.clear(); ++ this.trim(); ++ }; ++ ++ private readonly handleFailure = () => { ++ this.worker?.terminate(); ++ this.worker = undefined; ++ this.requests.forEach((entry) => { ++ entry.listeners.forEach((listener) => listener.reject()); ++ entry.listeners.clear(); ++ }); ++ this.requests.clear(); ++ this.entries.clear(); ++ }; ++ ++ private trim() { ++ for (const [uri, entry] of this.entries) { ++ if (this.entries.size <= MAX_RETAINED_AVATARS) break; ++ if (entry.references === 0) { ++ this.entries.delete(uri); ++ this.requests.delete(entry.id); ++ this.worker?.postMessage({ type: 'release', id: entry.id }); ++ } ++ } ++ } ++} ++ ++const documentCaches = new WeakMap(); ++ ++export function acquireNativeListAvatar( ++ document: Document, ++ uri: string, ++ resolve: (url: string) => void, ++ reject: () => void ++): () => void { ++ let cache = documentCaches.get(document); ++ if (!cache) { ++ cache = new NativeListWebAvatarCache(); ++ documentCaches.set(document, cache); ++ } ++ return cache.acquire(uri, resolve, reject); ++} +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts +index 11d57d1..1ff0801 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts ++++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts +@@ -37,6 +37,7 @@ import { + type NormalizedPositionScroll, + } from '../scrolling'; + import { applyRowPatches, validateSnapshot } from '../validation'; ++import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from './NativeListWebAvatarCache'; + + const SECTION_INDEX_GUTTER = 44; + const DEFAULT_VIEWPORT_WIDTH = 320; +@@ -354,11 +355,23 @@ export function estimateWebRowHeight( + snapshot: NativeListSnapshot, + availableWidth: number + ): number { +- if (row.type === 'system' && row.variant === 'spacer') return row.height; ++ // OneKey patch: explicit selector height takes precedence over presets. ++ // if (row.type === 'system' && row.variant === 'spacer') return row.height; ++ if (row.height !== undefined) return row.height; ++ if (row.type === 'system' && row.variant === 'warning') { ++ const width = Math.max(1, availableWidth - 24); ++ const lines = (text: string) => Math.max(1, Math.ceil(Array.from(text).reduce((length, char) => length + (char.charCodeAt(0) > 255 ? 14 : 7), 0) / width)); ++ return 32 + 20 * (lines(row.title) + lines(row.message)); ++ } + if (row.type === 'walletGroup') +- return (row.children.length + 1) * 68 + row.children.length * 12; ++ // OneKey patch: wallet badges participate in the outer group height. ++ // return (row.children.length + 1) * 68 + row.children.length * 12; ++ return [row.parent, ...row.children].reduce((height, member) => height + estimateWebRowHeight(member, snapshot, availableWidth), 0) + row.children.length * 12 + (row.parent.height !== undefined ? 2 : 0); ++ // OneKey patch: reserve the source badge line below wallet names. ++ // if (row.type === 'identity' && row.presentation === 'walletSidebar') ++ // return 68; + if (row.type === 'identity' && row.presentation === 'walletSidebar') +- return 68; ++ return 68 + (row.badges?.length ? 24 : 0); + if (row.type === 'identity' && row.presentation === 'networkSelector') + return 47; + if (row.type === 'identity' && row.presentation === 'accountSelector') +@@ -699,7 +712,9 @@ export function webRowRenderSignature(row: RowModel): string { + return JSON.stringify(rowWithoutSelectionState(row)); + } + ++// OneKey patch: selector names must shrink before the sidebar clips their contents. + export const WEB_LIST_CSS = ` ++[data-native-list-selector="walletSidebar"] .ok-native-list-title{max-width:100%;min-width:0} + .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} + .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} + .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} +@@ -754,7 +769,27 @@ export const WEB_LIST_CSS = ` + .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} + .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} + .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} ++.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} ++.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} + @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner{animation:none}} ++/* OneKey patch: selector controls follow their original semantic colors and geometry. */ ++.ok-native-list-checkbox[data-selector="networkSelector"]{padding:0;border-radius:4px;border-color:var(--nl-checkbox-border,var(--nl-separator));background:var(--nl-checkbox-icon,var(--nl-inverse-text))} ++.ok-native-list-checkbox[data-selector="networkSelector"]::after{display:none} ++.ok-native-list-checkbox[data-selector="networkSelector"]>svg{display:none;width:16px;height:16px;color:var(--nl-checkbox-icon,var(--nl-inverse-text));flex-shrink:0} ++.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]{border-color:transparent;background:var(--nl-checkbox-background,var(--nl-primary))} ++.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"]>svg[data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]>svg[data-state="indeterminate"]{display:block} ++/* OneKey patch: source WalletListItem uses four-point padding on every side. */ ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"]{border-radius:12px;padding:4px} ++/* OneKey patch: selected group members use the same primary title color as standalone wallets. */ ++.ok-native-list-wallet-member[data-native-list-selected="true"]>.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-title{color:var(--nl-primary)} ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges{height:18px} ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge{font-size:11px;line-height:14px;font-weight:400;height:18px;padding:2px 6px;border-radius:4px;background:var(--nl-subdued);color:var(--nl-secondary)} ++.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge[data-tone="warning"]{background:var(--nl-caution-background);color:var(--nl-caution)} ++.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>.ok-native-list-icon-button{box-sizing:border-box;flex:0 0 38px;width:38px;height:38px;margin:-7px;padding:7px} ++/* OneKey patch: AccountSelectorAccountListItem fixes the borderless Plus slot at top18/right20. */ ++.ok-native-list-account-row[data-native-list-selector="accountSelector"]>.ok-native-list-accessories[data-native-list-account-control="createAddress"]{position:absolute;top:18px;right:12px} ++.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>[data-native-list-account-control="createAddress"]{flex-basis:36px;width:36px;height:36px;padding:6px;border-radius:8px} ++.ok-native-list-account-action-row{padding-left:12px;padding-right:12px}.ok-native-list-account-action-row .ok-native-list-action-title{font-size:16px;line-height:24px;font-weight:400}.ok-native-list-account-action-row .ok-native-list-action-title[data-tone="primary"]{color:var(--nl-primary)} + `; + + function createElement( +@@ -788,16 +823,114 @@ function safeImageUri(uri: string): string | undefined { + return undefined; + } + ++// OneKey patch: retries belong to an image binding and must not outlive recycled rows. ++const webImageRetryCleanup = new WeakMap void>(); ++const webAvatarCleanup = new WeakMap void>(); ++const webAvatarSources = new WeakMap(); ++function disposeWebImageRetries(element: HTMLElement): boolean { ++ let disposed = false; ++ const images = element.matches('img') ? [element as HTMLImageElement] : element.querySelectorAll('img'); ++ images.forEach((image) => { ++ const avatarCleanup = webAvatarCleanup.get(image); ++ const cleanup = webImageRetryCleanup.get(image); ++ if (!cleanup && !avatarCleanup) return; ++ avatarCleanup?.(); ++ webAvatarCleanup.delete(image); ++ webAvatarSources.delete(image); ++ cleanup?.(); ++ webImageRetryCleanup.delete(image); ++ disposed = true; ++ }); ++ return disposed; ++} ++ ++function configureWebImageRetry( ++ image: HTMLImageElement, ++ source: ImageSource, ++ initialUri: string ++) { ++ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; ++ const retryLimit = Number.isFinite(source.retryTimes) ++ ? Math.max(0, Math.floor(source.retryTimes ?? 0)) ++ : 0; ++ if (!fallbackUri && retryLimit === 0) return; ++ const view = image.ownerDocument.defaultView; ++ let currentUri = initialUri; ++ let usedFallback = false; ++ let retryCount = 0; ++ let retryTimer: number | undefined; ++ let disposed = false; ++ const clearRetry = () => { ++ if (retryTimer !== undefined) view?.clearTimeout(retryTimer); ++ retryTimer = undefined; ++ }; ++ const handleError = (event: Event) => { ++ if (disposed || !image.isConnected || retryTimer !== undefined) { ++ event.stopImmediatePropagation(); ++ return; ++ } ++ if (fallbackUri && !usedFallback && fallbackUri !== currentUri) { ++ event.stopImmediatePropagation(); ++ usedFallback = true; ++ currentUri = fallbackUri; ++ image.src = currentUri; ++ return; ++ } ++ if (retryCount >= retryLimit || !view) return; ++ event.stopImmediatePropagation(); ++ retryCount += 1; ++ retryTimer = view.setTimeout(() => { ++ retryTimer = undefined; ++ if (disposed || !image.isConnected) return; ++ image.removeAttribute('src'); ++ image.src = currentUri; ++ }, Math.floor(Math.random() * 3) * 1000); ++ }; ++ image.addEventListener('error', handleError); ++ image.addEventListener('load', clearRetry); ++ webImageRetryCleanup.set(image, () => { ++ disposed = true; ++ clearRetry(); ++ image.removeEventListener('error', handleError); ++ image.removeEventListener('load', clearRetry); ++ }); ++} ++ ++function configureWebAvatar(image: HTMLImageElement, source: ImageSource, uri: string) { ++ const dispose = acquireNativeListAvatar(image.ownerDocument, uri, (resolvedUri) => { ++ configureWebImageRetry(image, source, resolvedUri); ++ image.src = resolvedUri; ++ }, () => { ++ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; ++ if (fallbackUri) { ++ configureWebImageRetry(image, source, fallbackUri); ++ image.src = fallbackUri; ++ return; ++ } ++ const ImageEvent = image.ownerDocument.defaultView?.Event; ++ if (ImageEvent) image.dispatchEvent(new ImageEvent('error')); ++ }); ++ webAvatarSources.set(image, { uri, source }); ++ webAvatarCleanup.set(image, dispose); ++} ++ + function createImage( + context: RenderContext, + source: ImageSource, + className?: string + ): HTMLImageElement | undefined { +- const uri = safeImageUri(source.uri); ++ const avatarUri = canonicalNativeListAvatarUri(source.uri); ++ const uri = avatarUri ?? safeImageUri(source.uri); + if (!uri) return undefined; + const image = context.document.createElement('img'); + if (className) image.className = className; +- image.src = uri; ++ // OneKey patch: consume recoverable errors before the visual's final fallback listener. ++ if (avatarUri) { ++ configureWebAvatar(image, source, avatarUri); ++ } else { ++ configureWebImageRetry(image, source, uri); ++ image.src = uri; ++ } + image.alt = ''; + image.draggable = false; + image.loading = 'lazy'; +@@ -807,6 +940,43 @@ function createImage( + return image; + } + ++// OneKey patch: React Native Web paints selector images as centered CSS backgrounds. ++// The image keeps its loading/error lifecycle; only its replaced-element pixels are hidden. ++function paintSelectorImageBackground(image: HTMLImageElement, frame: HTMLElement, inset = 0) { ++ const paint = createElement(image.ownerDocument, 'span', 'ok-native-list-selector-image-background'); ++ paint.style.cssText = 'position:absolute;pointer-events:none;border-radius:inherit;background-position:center;background-repeat:no-repeat'; ++ paint.style.inset = String(inset) + 'px'; ++ paint.style.backgroundSize = image.style.objectFit === 'fill' ? '100% 100%' : image.style.objectFit === 'center' ? 'auto' : image.style.objectFit; ++ image.style.opacity = '0'; ++ const update = () => { paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; }; ++ image.addEventListener('load', update); ++ image.addEventListener('error', () => { paint.style.backgroundImage = 'none'; }); ++ frame.insertBefore(paint, image); ++ if (image.complete && image.naturalWidth > 0) update(); ++} ++ ++// OneKey patch: use source SVG paths for selector actions and wallet provider marks. ++const selectorIcons: Readonly[] }>>> = {"GlobusOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"LockSolid":{"viewBox":"0 0 24 24","paths":[{"d":"M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"GoogleIllus":{"viewBox":"0 0 24 24","paths":[{"d":"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09","fill":"#4285F4","fillRule":"nonzero","opacity":1.0},{"d":"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23","fill":"#34A853","fillRule":"nonzero","opacity":1.0},{"d":"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z","fill":"#FBBC05","fillRule":"nonzero","opacity":1.0},{"d":"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53","fill":"#EA4335","fillRule":"nonzero","opacity":1.0}]},"AppleBrand":{"viewBox":"0 0 16 20","paths":[{"d":"M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"BotIllus":{"viewBox":"0 0 24 24","paths":[{"d":"M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z","fill":"#8897A5","fillRule":"nonzero","opacity":1.0},{"d":"M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z","fill":"#3FA9F5","fillRule":"nonzero","opacity":1.0},{"d":"M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z","fill":"#8897A5","fillRule":"nonzero","opacity":1.0},{"d":"M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z","fill":"#8897A5","fillRule":"nonzero","opacity":1.0},{"d":"M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0","fill":"#10243E","fillRule":"nonzero","opacity":1.0},{"d":"M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0","fill":"#10243E","fillRule":"nonzero","opacity":1.0},{"d":"M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06","fill":"#10243E","fillRule":"nonzero","opacity":1.0}]},"AllNetworksSolid":{"viewBox":"0 0 24 24","paths":[{"d":"M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67","fill":"currentColor","fillRule":"nonzero","opacity":1.0},{"d":"M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"CrossedSmallSolid":{"viewBox":"0 0 24 24","paths":[{"d":"M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"AccountErrorCustom":{"viewBox":"0 0 18 18","paths":[{"d":"M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5","fill":"#000","fillRule":"nonzero","opacity":0.447},{"d":"M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5","fill":"#000","fillRule":"evenodd","opacity":0.447}]},"PlusSmallOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M13 11h5v2h-5v5h-2v-5H6v-2h5V6h2z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"DotHorOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M6 14H2v-4h4zm8 0h-4v-4h4zm8 0h-4v-4h4z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"ChevronRightSmallOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M15.414 12 10 17.414 8.586 16l4-4-4-4L10 6.586z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"DragOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M11 21H7v-4h4zm6 0h-4v-4h4zm-6-7H7v-4h4zm6 0h-4v-4h4zm-6-7H7V3h4zm6 0h-4V3h4z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"PencilOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M22.414 7.5 7.914 22H2v-5.914l14.5-14.5zM4 16.914V20h3.086l9.5-9.5L13.5 7.414zM14.914 6 18 9.086 19.586 7.5 16.5 4.414z","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"CheckboxCheckedCustom":{"viewBox":"0 0 16 16","paths":[{"d":"M12.204 5.043a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414l1.293 1.293 3.793-3.793a1 1 0 0 1 1.414 0","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"CheckboxIndeterminateCustom":{"viewBox":"0 0 16 16","paths":[{"d":"M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"Circle":{"viewBox":"0 0 24 24","paths":[{"d":"M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0","fill":"currentColor","fillRule":"nonzero","opacity":1}]}}; ++function applySelectorIcon(element: HTMLElement, name: string) { ++ const icon = selectorIcons[name]; ++ if (!icon) return; ++ element.textContent = ''; ++ const svg = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); ++ svg.setAttribute('viewBox', icon.viewBox); ++ svg.setAttribute('width', '24'); ++ svg.setAttribute('height', '24'); ++ svg.setAttribute('aria-hidden', 'true'); ++ icon.paths.forEach((path) => { ++ const child = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); ++ child.setAttribute('d', path.d); ++ child.setAttribute('fill', path.fill); ++ child.setAttribute('fill-rule', path.fillRule); ++ child.setAttribute('fill-opacity', String(path.opacity)); ++ svg.appendChild(child); ++ }); ++ element.appendChild(svg); ++} ++ + function iconGlyph(name: string): string { + const normalized = name.toLocaleLowerCase(); + if (normalized.includes('chevron')) { +@@ -841,7 +1011,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { + + function createVisual( + context: RenderContext, +- visual: LeadingVisual | undefined ++ visual: LeadingVisual | undefined, ++ selectorPresentation?: string + ): HTMLElement | undefined { + if (!visual) return undefined; + if (visual.kind === 'stackedImages') { +@@ -873,6 +1044,7 @@ function createVisual( + 'ok-native-list-visual-fallback', + iconGlyph(visual.name) + ); ++ applySelectorIcon(fallback, visual.name); + if (visual.tintColor) fallback.style.color = visual.tintColor; + frame.appendChild(fallback); + return frame; +@@ -885,6 +1057,7 @@ function createVisual( + if (image) { + image.className = 'ok-native-list-visual-main'; + frame.appendChild(image); ++ if (selectorPresentation) paintSelectorImageBackground(image, frame); + } else { + frame.appendChild( + createElement( +@@ -913,8 +1086,51 @@ function createVisual( + corner.style.color = visual.cornerIcon.tintColor; + if (visual.cornerIcon.backgroundColor) + corner.style.background = visual.cornerIcon.backgroundColor; ++ applySelectorIcon(corner, visual.cornerIcon.name); + frame.appendChild(corner); + } ++ // OneKey patch: image failure uses the same source-derived fallback as v1. ++ if ('fallbackIcon' in visual && visual.fallbackIcon) { ++ const icon = visual.fallbackIcon; ++ const showFallback = () => { ++ if (image) { disposeWebImageRetries(image); image.remove(); } ++ frame.querySelector('.ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)')?.remove(); ++ const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback'); ++ applySelectorIcon(fallback, icon.name); ++ if (icon.tintColor) fallback.style.color = icon.tintColor; ++ frame.prepend(fallback); ++ }; ++ if (image) image.addEventListener('error', showFallback, { once: true }); ++ else showFallback(); ++ } ++ if ('borderStyle' in visual && visual.borderStyle === 'dashed') { ++ frame.style.border = '2px dashed ' + (visual.borderColor ?? 'var(--nl-disabled)'); ++ frame.style.boxSizing = 'border-box'; ++ } ++ if ('overlays' in visual) visual.overlays?.forEach((overlay) => { ++ const corner = createElement(context.document, 'span', 'ok-native-list-visual-overlay', overlay.text); ++ const size = overlay.size ?? 20; ++ const isWalletText = selectorPresentation === 'walletSidebar' && !!overlay.text && !overlay.image && !overlay.name; ++ corner.style.width = isWalletText && overlay.width === undefined ? 'auto' : String(overlay.width ?? size) + 'px'; ++ corner.style.height = String(overlay.height ?? (isWalletText ? 16 : size)) + 'px'; ++ corner.style.padding = isWalletText ? '0 2px' : String(overlay.padding ?? 0) + 'px'; ++ if (isWalletText) { corner.style.fontSize = '12px'; corner.style.lineHeight = '16px'; corner.style.fontWeight = '400'; } ++ if (isWalletText || overlay.width !== undefined || overlay.height !== undefined) corner.style.borderRadius = '9999px'; ++ const offsetX = String(-(overlay.offsetX ?? overlay.offset ?? 2)) + 'px'; ++ const offsetY = String(-(overlay.offsetY ?? overlay.offset ?? 2)) + 'px'; ++ corner.style.background = overlay.backgroundColor ?? 'transparent'; ++ corner.style.color = overlay.tintColor ?? 'var(--nl-secondary)'; ++ if (overlay.position === 'topLeft') { corner.style.left = offsetX; corner.style.top = offsetY; } ++ else { corner.style.right = offsetX; corner.style.bottom = offsetY; } ++ if (overlay.image) { ++ const overlayImage = createImage(context, overlay.image); ++ if (overlayImage) { ++ corner.appendChild(overlayImage); ++ if (selectorPresentation) paintSelectorImageBackground(overlayImage, corner, overlay.padding ?? 0); ++ } ++ } else if (overlay.name) applySelectorIcon(corner, overlay.name); ++ frame.appendChild(corner); ++ }); + return frame; + } + +@@ -1022,6 +1238,16 @@ function createCheckbox( + setData(element, 'checkboxFallback', accessory.state); + setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); + setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); ++ const row = context.snapshot.rows[context.itemIndex]; ++ if (row && 'presentation' in row && row.presentation === 'networkSelector') { ++ setData(element, 'selector', 'networkSelector'); ++ for (const [state, name] of [['checked', 'CheckboxCheckedCustom'], ['indeterminate', 'CheckboxIndeterminateCustom']]) { ++ const holder = createElement(context.document, 'span'); ++ applySelectorIcon(holder, name); ++ const svg = holder.firstElementChild; ++ if (svg) { svg.setAttribute('data-state', state); element.appendChild(svg); } ++ } ++ } + if (accessory.target?.scope === 'section') + setData(element, 'selectionKey', accessory.target.sectionKey); + else if (accessory.target?.scope === 'row') +@@ -1046,6 +1272,7 @@ function createIconAction( + element.setAttribute('type', 'button'); + element.toggleAttribute('disabled', Boolean(disabled)); + } ++ applySelectorIcon(element, name); + if (actionKey) setData(element, 'nativeListAction', actionKey); + if (tintColor) element.style.color = tintColor; + return element; +@@ -1060,6 +1287,20 @@ function markActionAnchorSource( + if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); + } + ++// OneKey patch: the compact zero-count digits share the amount baseline. ++function applyValueSegments(element: HTMLElement, segments: readonly Readonly<{ text: string; style?: 'subscript' }>[] | undefined, fontSize = 16, lineHeight = 24, weight = 500) { ++ if (!segments?.length) return; ++ element.textContent = ''; ++ element.style.fontSize = String(fontSize) + 'px'; ++ element.style.lineHeight = String(lineHeight) + 'px'; ++ element.style.fontWeight = String(weight); ++ segments.forEach((segment) => { ++ const span = createElement(element.ownerDocument, 'span', undefined, segment.text); ++ if (segment.style === 'subscript') { span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; span.style.lineHeight = String(fontSize) + 'px'; } ++ element.appendChild(span); ++ }); ++} ++ + function createAccessory( + context: RenderContext, + rowKey: string, +@@ -1080,6 +1321,18 @@ function createAccessory( + accessory.tintColor + ); + markActionAnchorSource(element, 'trailingAccessory', slot); ++ setData(element, 'testid', accessory.testID); ++ if (accessory.hoverActionKey) setData(element, 'nativeListHoverAction', accessory.hoverActionKey); ++ if (accessory.accessibilityLabel) element.setAttribute('aria-label', accessory.accessibilityLabel); ++ const row = context.snapshot.rows[context.itemIndex]; ++ if (row && 'presentation' in row && row.presentation === 'accountSelector') { ++ setData(element, 'nativeListAnchorInset', 7); ++ // OneKey patch: the create-address button omits IconButton's one-point border. ++ if (row.height !== undefined && accessory.name === 'PlusSmallOutline') { ++ setData(element, 'nativeListAccountControl', 'createAddress'); ++ if (!accessory.tintColor) element.style.color = 'var(--nl-icon-subdued)'; ++ } ++ } + return element; + } + if (accessory.kind === 'spinner') { +@@ -1099,6 +1352,7 @@ function createAccessory( + switch (accessory.kind) { + case 'value': + element.textContent = accessory.text; ++ applyValueSegments(element, accessory.textSegments); + if (accessory.secondary) + element.classList.add('ok-native-list-accessory-secondary'); + break; +@@ -1157,6 +1411,13 @@ function appendAccessories( + 'span', + 'ok-native-list-accessories' + ); ++ const row = context.snapshot.rows[context.itemIndex]; ++ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'networkSelector') { ++ container.style.gap = accessories.some(accessory => accessory.kind === 'checkbox') ? '12px' : '20px'; ++ } ++ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'accountSelector' && accessories.length === 1 && accessories[0]?.kind === 'icon' && accessories[0].name === 'PlusSmallOutline') { ++ setData(container, 'nativeListAccountControl', 'createAddress'); ++ } + accessories.forEach((accessory, slot) => + container.appendChild(createAccessory(context, rowKey, accessory, slot)) + ); +@@ -1234,7 +1495,59 @@ function createSectionHeader( + markActionAnchorSource(titleIcon, 'leadingAction'); + body.appendChild(titleIcon); + } +- body.appendChild(createTextColumn(context, row.title, row.subtitle)); ++ // OneKey patch: section title help has its own measurable action target. ++ // body.appendChild(createTextColumn(context, row.title, row.subtitle)); ++ const column = createTextColumn(context, row.title, row.subtitle); ++ const title = column.firstElementChild as HTMLElement; ++ title.classList.add('ok-native-list-section-title'); ++ if (row.titleActionKey) { ++ setData(title, 'nativeListAction', row.titleActionKey); ++ markActionAnchorSource(title, 'leadingAction'); ++ title.setAttribute('role', 'button'); ++ title.tabIndex = 0; ++ title.style.alignSelf = 'flex-start'; ++ title.style.maxWidth = '100%'; ++ // OneKey patch: explicit network headers reserve a separate 3-point underline area. ++ if (row.presentation !== 'networkSelector' || row.height === undefined) { ++ title.style.textDecoration = 'underline dotted'; ++ title.style.textUnderlineOffset = '6px'; ++ } ++ if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); ++ } ++ if (row.presentation === 'networkSelector' && row.height !== undefined) { ++ body.style.padding = row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; ++ body.style.backgroundColor = 'var(--nl-bg)'; ++ body.style.gap = row.checkbox ? '12px' : '8px'; ++ title.style.fontSize = row.variant === 'summary' ? '16px' : '14px'; ++ title.style.lineHeight = row.variant === 'summary' ? '24px' : '20px'; ++ title.style.fontWeight = row.variant === 'summary' || (row.titleActionKey && !row.checkbox) ? '500' : '600'; ++ if (row.titleActionKey) { ++ const text = createElement(context.document, 'span', 'ok-native-list-section-title-text', row.title); ++ text.style.overflow = 'hidden'; ++ text.style.textOverflow = 'ellipsis'; ++ text.style.maxWidth = '100%'; ++ const dotted = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); ++ dotted.setAttribute('height', '2'); ++ dotted.style.cssText = 'display:block;position:absolute;left:0;bottom:0;width:100%;height:2px;color:var(--nl-secondary)'; ++ const line = context.document.createElementNS('http://www.w3.org/2000/svg', 'line'); ++ for (const [key, value] of Object.entries({ x1: '1', y1: '1', x2: '100%', y2: '1', stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-dasharray': '0,4', 'stroke-linecap': 'round' })) line.setAttribute(key, value); ++ // OneKey patch: keep both round caps inside the original full-width viewport. ++ const lineViewport = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); ++ lineViewport.setAttribute('width', 'calc(100% - 1px)'); ++ lineViewport.setAttribute('height', '2'); ++ lineViewport.setAttribute('overflow', 'visible'); ++ lineViewport.appendChild(line); ++ dotted.appendChild(lineViewport); ++ // OneKey patch: SVG intrinsic width must not expand the title action beyond its text. ++ title.style.display = 'block'; ++ title.style.position = 'relative'; ++ title.style.width = 'fit-content'; ++ title.style.paddingBottom = '3px'; ++ text.style.display = 'block'; ++ title.replaceChildren(text, dotted); ++ } ++ } ++ body.appendChild(column); + if (row.value) { + const value = createElement( + context.document, +@@ -1244,6 +1557,15 @@ function createSectionHeader( + : 'ok-native-list-value ok-native-list-section-value', + row.value + ); ++ applyValueSegments(value, row.valueSegments); ++ if (row.presentation === 'networkSelector' && row.height !== undefined) { ++ value.style.fontFamily = 'inherit'; ++ value.style.fontSize = '16px'; ++ value.style.lineHeight = '24px'; ++ value.style.fontWeight = '500'; ++ if (row.valueActionKey) { value.style.color = 'var(--nl-secondary)'; value.style.padding = '0'; value.style.flexShrink = '0'; } ++ } ++ if (row.valueActionTestID) setData(value, 'testid', row.valueActionTestID); + if (row.valueActionKey) { + value.setAttribute('type', 'button'); + setData(value, 'nativeListAction', row.valueActionKey); +@@ -1292,6 +1614,7 @@ function createActionRow( + row.title + ); + setData(title, 'tone', row.tone); ++ if (row.presentation === 'accountSelector' && row.icon) title.style.fontWeight = '500'; + body.appendChild(title); + if (row.checkbox) + body.appendChild(createCheckbox(context, row.key, row.checkbox)); +@@ -1309,6 +1632,13 @@ function createSystemRow( + 'ok-native-list-row ok-native-list-system' + ); + setData(body, 'variant', row.variant); ++ if (row.variant === 'warning') { ++ body.classList.add('ok-native-list-warning'); ++ body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; ++ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-title', row.title)); ++ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-message', row.message)); ++ return body; ++ } + if (row.variant === 'loading') + body.appendChild( + createElement(context.document, 'span', 'ok-native-list-spinner') +@@ -1703,6 +2033,11 @@ function createIdentityActivityOrMessageRow( + .filter(Boolean) + .join(' ') + ); ++ setData(body, 'nativeListSelector', row.height !== undefined ? presentation : undefined); ++ if (row.type === 'identity' && row.titleActionKey && row.titleActionOnHover) { ++ setData(body, 'nativeListHoverAction', row.titleActionKey); ++ markActionAnchorSource(body, 'leadingAction'); ++ } + if (row.type === 'identity' && row.leadingAction) { + const action = createIconAction( + context, +@@ -1714,7 +2049,15 @@ function createIdentityActivityOrMessageRow( + markActionAnchorSource(action, 'leadingAction'); + body.appendChild(action); + } +- const visual = createVisual(context, visualFromRow(row)); ++ const visual = createVisual(context, visualFromRow(row), row.height !== undefined ? presentation : undefined); ++ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'fallbackIcon' in row.leading && row.leading.fallbackIcon?.name === 'LockSolid') { ++ // OneKey patch: hidden-wallet locks use WalletAvatar's full 40-point icon. ++ const icon = visual.querySelector('.ok-native-list-visual-fallback svg'); ++ if (icon) { icon.style.width = '40px'; icon.style.height = '40px'; } ++ const fallback = visual.querySelector('.ok-native-list-visual-fallback'); ++ if (fallback) { fallback.style.borderRadius = '0'; fallback.style.overflow = 'visible'; } ++ } ++ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'borderStyle' in row.leading && row.leading.borderStyle === 'dashed') visual.style.borderWidth = '1px'; + if (visual) body.appendChild(visual); + if (row.type === 'activity' && row.secondaryLeading) { + const secondVisual = createVisual(context, row.secondaryLeading); +@@ -1737,8 +2080,44 @@ function createIdentityActivityOrMessageRow( + subtitle, + row.type === 'identity' ? row.tertiary : undefined, + row.type === 'identity' ? row.tertiaryTone : undefined, +- row.type === 'identity' ? row.badges : undefined ++ row.type === 'identity' && presentation !== 'walletSidebar' ? row.badges : undefined + ); ++ // OneKey patch: match existing search, subtitle fragments, and sidebar badges. ++ if (row.type === 'identity') { ++ const titleElement = column.firstElementChild as HTMLElement; ++ if (row.titleMatch?.length) { ++ const firstText = titleElement.firstChild; ++ if (firstText) firstText.remove(); ++ const fragment = context.document.createDocumentFragment(); ++ let offset = 0; ++ row.titleMatch.forEach(({ start, end }) => { ++ fragment.appendChild(context.document.createTextNode(row.title.slice(offset, start))); ++ const match = createElement(context.document, 'span', 'ok-native-list-info', row.title.slice(start, end)); ++ fragment.appendChild(match); ++ offset = end; ++ }); ++ fragment.appendChild(context.document.createTextNode(row.title.slice(offset))); ++ titleElement.prepend(fragment); ++ } ++ if (row.subtitleSegments?.length) { ++ column.querySelector('.ok-native-list-secondary')?.remove(); ++ const segments = createElement(context.document, 'span', 'ok-native-list-subtitle-segments'); ++ row.subtitleSegments.forEach((segment) => { ++ if (segment.separatorBefore) segments.appendChild(createElement(context.document, 'span', 'ok-native-list-subtitle-dot')); ++ const text = createElement(context.document, 'span', 'ok-native-list-secondary', segment.text); ++ applyValueSegments(text, segment.textSegments, 14, 20, 400); ++ setData(text, 'tone', segment.tone); ++ text.style.color = segment.tone === 'disabled' ? 'var(--nl-disabled)' : segment.tone === 'caution' ? 'var(--nl-caution)' : toneColor(segment.tone, 'secondary'); ++ segments.appendChild(text); ++ }); ++ column.insertBefore(segments, titleElement.nextSibling); ++ } ++ if (presentation === 'walletSidebar' && row.badges?.length) { ++ const badges = createElement(context.document, 'span', 'ok-native-list-wallet-badges'); ++ row.badges.forEach((badge) => badges.appendChild(createBadge(context, badge))); ++ column.appendChild(badges); ++ } ++ } + if (row.type === 'activity' && row.status) + column.appendChild( + createElement( +@@ -1814,6 +2193,13 @@ function createIdentityActivityOrMessageRow( + return body; + } + ++// OneKey patch: SizableText enables tabular digits without replacing its font family. ++function applySelectorTabularNumbers(body: HTMLElement, row: RowModel) { ++ if (!('presentation' in row) || !['accountSelector', 'networkSelector', 'walletSidebar'].includes(row.presentation ?? '')) return; ++ body.style.fontVariantNumeric = 'tabular-nums'; ++ body.querySelectorAll('span,button').forEach((text) => { text.style.fontVariantNumeric = 'tabular-nums'; }); ++} ++ + function createWalletGroupRow( + context: RenderContext, + row: Extract +@@ -1830,11 +2216,18 @@ function createWalletGroupRow( + 'ok-native-list-wallet-member' + ); + setData(memberElement, 'nativeListGroupMemberKey', member.key); ++ setData(memberElement, 'testid', member.testID); + setData(memberElement, 'nativeListGroupParent', memberIndex === 0); + setData(memberElement, 'nativeListSelected', member.selected); +- memberElement.appendChild( +- createIdentityActivityOrMessageRow(context, member) +- ); ++ // OneKey patch: grouped members have the same selector typography as standalone wallets. ++ // memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); ++ const memberBody = createIdentityActivityOrMessageRow(context, member); ++ applySelectorTabularNumbers(memberBody, member); ++ memberElement.appendChild(memberBody); ++ // OneKey patch: group children use their own measured badge height. ++ memberElement.style.flexBasis = String(member.height ?? (68 + (member.badges?.length ? 24 : 0))) + 'px'; ++ memberElement.style.height = memberElement.style.flexBasis; ++ memberElement.style.opacity = String(member.opacity ?? 1); + body.appendChild(memberElement); + }); + return body; +@@ -1919,6 +2312,8 @@ export class NativeListWebEngine { + private lastViewportWidth = -1; + private lastViewportHeight = -1; + private destroyed = false; ++ // OneKey patch: warning banners are measured after normal browser text wrapping. ++ private measuredWarningHeights = new Map(); + + constructor( + host: HTMLElement, +@@ -2004,6 +2399,9 @@ export class NativeListWebEngine { + passive: true, + }); + this.root.addEventListener('click', this.handleClick); ++ // OneKey patch: preserve web tooltip hover for section titles. ++ this.root.addEventListener('pointerover', this.handleTitlePointerOver); ++ this.root.addEventListener('pointerout', this.handleTitlePointerOut); + this.root.addEventListener('keydown', this.handleKeyDown); + this.viewport.addEventListener( + 'pointerdown', +@@ -2160,7 +2558,7 @@ export class NativeListWebEngine { + const index = resolveLocationIndex(this.snapshot.rows, params); + if (index === undefined) { + const sectionCount = this.snapshot.rows.filter( +- (row) => row.type === 'sectionHeader' && row.variant !== 'summary' ++ (row) => row.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary' + ).length; + this.emitScrollFailure( + params.itemIndex, +@@ -2219,6 +2617,8 @@ export class NativeListWebEngine { + ); + this.viewport.removeEventListener('scroll', this.handleScroll); + this.root.removeEventListener('click', this.handleClick); ++ this.root.removeEventListener('pointerover', this.handleTitlePointerOver); ++ this.root.removeEventListener('pointerout', this.handleTitlePointerOut); + this.root.removeEventListener('keydown', this.handleKeyDown); + this.cancelPointerReorder(true); + this.viewport.removeEventListener( +@@ -2251,6 +2651,8 @@ export class NativeListWebEngine { + this.viewport.removeEventListener('pointerup', this.handlePullEnd); + this.viewport.removeEventListener('pointercancel', this.handlePullEnd); + const host = this.root.parentElement; ++ disposeWebImageRetries(this.root); ++ this.pool.forEach(disposeWebImageRetries); + this.root.remove(); + this.hideReorderPreview(); + this.reorderPreview.remove(); +@@ -2263,6 +2665,7 @@ export class NativeListWebEngine { + snapshot: NativeListSnapshot, + selectedKeys?: ReadonlySet + ) { ++ this.measuredWarningHeights.clear(); + this.snapshot = snapshot; + this.rows = effectiveRows(snapshot); + this.selectedKeys = +@@ -2288,6 +2691,11 @@ export class NativeListWebEngine { + '--nl-primary': theme.primaryText, + '--nl-secondary': theme.secondaryText, + '--nl-disabled': theme.disabledText, ++ '--nl-caution': theme.caution ?? '#AB6400', ++ '--nl-caution-background': theme.cautionBackground, ++ '--nl-checkbox-background': theme.checkboxBackground, ++ '--nl-checkbox-border': theme.checkboxBorder, ++ '--nl-checkbox-icon': theme.checkboxIcon, + '--nl-icon': theme.icon, + '--nl-icon-subdued': theme.iconSubdued, + '--nl-separator': theme.separator, +@@ -2316,12 +2724,17 @@ export class NativeListWebEngine { + viewportHeight !== this.lastViewportHeight) + ) { + this.invalidateActionAnchor('layout'); ++ this.measuredWarningHeights.clear(); + } + this.lastViewportWidth = viewportWidth; + this.lastViewportHeight = viewportHeight; + const previousHorizontal = this.layout.horizontal; ++ const measuredSnapshot: NativeListSnapshot = { ++ ...this.snapshot, ++ rows: this.snapshot.rows.map((row) => row.type === 'system' && row.variant === 'warning' && row.height === undefined && this.measuredWarningHeights.has(row.key) ? { ...row, height: this.measuredWarningHeights.get(row.key) } : row), ++ }; + this.layout = computeWebListLayout( +- this.snapshot, ++ measuredSnapshot, + viewportWidth, + viewportHeight, + this.reorderCompactKey +@@ -2350,6 +2763,7 @@ export class NativeListWebEngine { + if (!desired.has(index)) { + this.invalidateActionAnchorForElement(element); + this.mounted.delete(index); ++ if (disposeWebImageRetries(element)) element.removeAttribute('data-render-signature'); + element.remove(); + this.pool.push(element); + } +@@ -2377,6 +2791,17 @@ export class NativeListWebEngine { + element.dataset.renderSignature = signature; + } + }); ++ let measuredWarningChanged = false; ++ this.mounted.forEach((element, index) => { ++ const row = this.rows[index]; ++ if (row?.type !== 'system' || row.variant !== 'warning' || row.height !== undefined) return; ++ const height = element.querySelector('.ok-native-list-warning')?.offsetHeight ?? 0; ++ if (height > 0 && height !== this.measuredWarningHeights.get(row.key)) { ++ this.measuredWarningHeights.set(row.key, height); ++ measuredWarningChanged = true; ++ } ++ }); ++ if (measuredWarningChanged) { this.recomputeLayout(); return; } + this.updateVisibleSelection(); + this.updateVisibleState(); + } +@@ -2395,6 +2820,7 @@ export class NativeListWebEngine { + overlay = false + ) { + this.invalidateActionAnchorForElement(element); ++ disposeWebImageRetries(element); + const bindingEpoch = String(++this.bindingEpochCounter); + element.className = overlay + ? 'ok-native-list-item ok-native-list-sticky' +@@ -2403,6 +2829,9 @@ export class NativeListWebEngine { + setData(element, 'nativeListBindingEpoch', bindingEpoch); + setData(element, 'nativeListRowIndex', index); + setData(element, 'nativeListDisabled', Boolean(row.disabled)); ++ setData(element, 'testid', row.testID); ++ // OneKey patch: deprecation dims a row without disabling its actions. ++ element.style.opacity = String(row.opacity ?? 1); + setData(element, 'nativeListReorderable', this.isReorderable(row)); + setData( + element, +@@ -2435,12 +2864,48 @@ export class NativeListWebEngine { + selectedKeys: this.selectedKeys, + itemIndex: index, + }; +- element.replaceChildren(createRowBody(context, row)); ++ const body = createRowBody(context, row); ++ applySelectorTabularNumbers(body, row); ++ // OneKey patch: explicit selector fields preserve original page geometry. ++ element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; ++ if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; ++ if (row.backgroundFullWidth && row.backgroundColor) { ++ const bleed = paddingValues(this.snapshot).horizontal; ++ body.style.position = 'relative'; ++ body.style.overflow = 'visible'; ++ body.style.boxShadow = String(-bleed) + 'px 0 ' + row.backgroundColor + ',' + String(bleed) + 'px 0 ' + row.backgroundColor; ++ } ++ if (row.type === 'identity' && row.height !== undefined) { ++ const title = body.querySelector('.ok-native-list-title'); ++ if (row.presentation === 'accountSelector') { ++ body.style.gap = '12px'; ++ body.style.borderRadius = '12px'; ++ if ('shape' in row.leading && row.leading.shape === 'rounded') { ++ const visual = body.querySelector('.ok-native-list-visual'); ++ if (visual) visual.style.borderRadius = '8px'; ++ } ++ if (title) title.style.lineHeight = '24px'; ++ } ++ if (row.presentation === 'networkSelector') { ++ body.style.borderRadius = '12px'; ++ const visual = body.querySelector('.ok-native-list-visual'); ++ if (visual) { visual.style.width = '32px'; visual.style.height = '32px'; visual.style.flexBasis = '32px'; } ++ if (row.leading.kind === 'network' && !row.leading.image && !row.leading.fallbackIcon && row.leading.fallbackText) { ++ const fallback = visual?.querySelector('.ok-native-list-visual-fallback'); ++ if (fallback) { fallback.style.fontSize = '19px'; fallback.style.lineHeight = '27px'; fallback.style.fontWeight = '600'; fallback.style.color = 'var(--nl-inverse-text)'; } ++ } ++ visual?.querySelectorAll('.ok-native-list-visual-main').forEach((image) => { image.style.width = '32px'; image.style.height = '32px'; }); ++ if (title) { title.style.fontSize = '16px'; title.style.lineHeight = '24px'; title.style.fontWeight = '500'; } ++ body.querySelectorAll('.ok-native-list-accessory').forEach((value) => { value.style.fontSize = '16px'; value.style.lineHeight = '24px'; value.style.fontWeight = '500'; }); ++ } ++ } ++ element.replaceChildren(body); + } + + private renderFooter() { + const row = this.snapshot.fixedFooter; + this.invalidateActionAnchorForElement(this.footer); ++ disposeWebImageRetries(this.footer); + this.footer.replaceChildren(); + if (!row) return; + const element = createElement(this.document, 'div', 'ok-native-list-item'); +@@ -2487,7 +2952,9 @@ export class NativeListWebEngine { + private updateVisibleSelection() { + const update = (element: HTMLElement, row: RowModel | undefined) => { + if (!row) return; +- const selected = this.selectedKeys.has(row.key); ++ // OneKey patch: selector adapters mark active rows independently of checkbox selection. ++ // const selected = this.selectedKeys.has(row.key); ++ const selected = row.selected === true || this.selectedKeys.has(row.key); + setData(element, 'nativeListSelected', selected); + element.setAttribute('aria-selected', String(selected)); + element +@@ -2548,7 +3015,9 @@ export class NativeListWebEngine { + } + this.checkEndReached(last?.index ?? -1); + this.updateStickyHeader(first?.index ?? -1); +- this.updateSectionIndex(first?.index ?? -1); ++ // OneKey patch: index highlighting follows header positions at scroll boundaries. ++ // this.updateSectionIndex(first?.index ?? -1); ++ this.updateSectionIndex(); + } + + private updateStickyHeader(firstVisibleIndex: number) { +@@ -2564,7 +3033,7 @@ export class NativeListWebEngine { + let index = -1; + for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { + const row = this.rows[cursor]; +- if (row?.type === 'sectionHeader' && row.variant !== 'summary') { ++ if (row?.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary') { + index = cursor; + break; + } +@@ -2594,7 +3063,7 @@ export class NativeListWebEngine { + for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { + const candidate = this.rows[cursor]; + if ( +- candidate?.type === 'sectionHeader' && ++ candidate?.type === 'sectionHeader' && candidate.sticky !== false && + candidate.variant !== 'summary' + ) { + nextIndex = cursor; +@@ -2610,12 +3079,15 @@ export class NativeListWebEngine { + this.updateVisibleSelection(); + } + +- private updateSectionIndex(firstVisibleIndex: number) { ++ // private updateSectionIndex(firstVisibleIndex: number) { ++ private updateSectionIndex() { + let activeKey: string | undefined; + this.snapshot.rows.forEach((row, index) => { + if ( +- index <= firstVisibleIndex && +- row.type === 'sectionHeader' && ++ // OneKey patch: a spacer ending exactly at the viewport is not the active section. ++ // index <= firstVisibleIndex && ++ itemStart(this.layout.items[index], this.layout.horizontal) <= this.currentOffset() && ++ row.type === 'sectionHeader' && row.sticky !== false && + row.indexTitle + ) + activeKey = row.key; +@@ -2793,7 +3265,9 @@ export class NativeListWebEngine { + if (!source || !bindingEpoch || !rowElement.contains(actionElement)) + return undefined; + this.invalidateActionAnchor('rebind'); +- const rect = actionElement.getBoundingClientRect(); ++ const actualRect = actionElement.getBoundingClientRect(); ++ const inset = Number(actionElement.dataset.nativeListAnchorInset ?? 0); ++ const rect = { left: actualRect.left + inset, top: actualRect.top + inset, width: actualRect.width - inset * 2, height: actualRect.height - inset * 2 }; + const token = [ + this.actionAnchorInstanceId, + this.snapshot.generation, +@@ -2866,7 +3340,9 @@ export class NativeListWebEngine { + rowElement?: HTMLElement, + sourceElement = rowElement + ) { +- if (row.disabled) return; ++ // OneKey patch: missing-address rows keep their create-address accessory interactive. ++ // if (row.disabled) return; ++ if (row.disabled || row.pressDisabled) return; + if ( + this.snapshot.selection?.rowPressToggles && + this.snapshot.selection.mode !== 'none' && +@@ -2894,6 +3370,28 @@ export class NativeListWebEngine { + } + } + ++ // OneKey patch: hover opens the same anchored help action as native taps. ++ private handleTitlePointerOver = (event: PointerEvent) => { ++ const target = event.target; ++ if (!(target instanceof Element)) return; ++ const action = target.closest('[data-native-list-hover-action]'); ++ if (!action || (event.relatedTarget instanceof Node && action.contains(event.relatedTarget))) return; ++ const rowElement = action.closest('[data-native-list-row-key]'); ++ const row = this.rowAtElement(rowElement); ++ const memberKey = action.closest('[data-native-list-group-member-key]')?.dataset.nativeListGroupMemberKey; ++ const sourceRow = row?.type === 'walletGroup' ? [row.parent, ...row.children].find(member => member.key === memberKey) ?? row : row; ++ const actionKey = action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; ++ if (sourceRow && !sourceRow.disabled && actionKey) this.emitRowAction(sourceRow, actionKey, action, rowElement ?? undefined); ++ }; ++ ++ private handleTitlePointerOut = (event: PointerEvent) => { ++ const target = event.target; ++ if (!(target instanceof Element)) return; ++ const action = target.closest('[data-native-list-hover-action]'); ++ if (!action || (event.relatedTarget instanceof Node && action.contains(event.relatedTarget))) return; ++ if (this.actionAnchor?.actionElement === action) this.invalidateActionAnchor('pointerLeave'); ++ }; ++ + private handleClick = (event: Event) => { + if (Date.now() < this.suppressClickUntil) { + event.preventDefault(); +@@ -2944,6 +3442,12 @@ export class NativeListWebEngine { + }; + + private handleKeyDown = (event: KeyboardEvent) => { ++ // OneKey patch: non-button title help supports keyboard activation. ++ if ((event.key === 'Enter' || event.key === ' ') && event.target instanceof HTMLElement && event.target.matches('[role="button"][data-native-list-action]')) { ++ event.preventDefault(); ++ event.target.click(); ++ return; ++ } + if (event.key === 'Escape') { + if (this.pointerReorder?.active) { + event.preventDefault(); +@@ -3203,12 +3707,13 @@ export class NativeListWebEngine { + const index = Number(rowElement?.dataset.nativeListRowIndex); + const row = this.rows[index]; + if (!row || !this.isReorderable(row)) return; +- if ( +- row.type === 'walletGroup' && +- target.closest('[data-native-list-group-parent]')?.dataset +- .nativeListGroupParent !== 'true' +- ) +- return; ++ // OneKey patch: a child drag reorders its parent wallet group as one item. ++ // if ( ++ // row.type === 'walletGroup' && ++ // target.closest('[data-native-list-group-parent]')?.dataset ++ // .nativeListGroupParent !== 'true' ++ // ) ++ // return; + + const view = this.document.defaultView; + const state: PointerReorderState = { +@@ -3225,9 +3730,8 @@ export class NativeListWebEngine { + active: false, + }; + this.pointerReorder = state; +- if (state.pointerType === 'mouse') { +- this.captureReorderPointer(state); +- } else { ++ // OneKey patch: normal wallet taps retain their target until a drag is activated. ++ if (state.pointerType !== 'mouse') { + state.longPressTimer = view?.setTimeout( + () => this.activatePointerReorder(state), + REORDER_TOUCH_LONG_PRESS_MS +@@ -3363,6 +3867,18 @@ export class NativeListWebEngine { + Math.max(0, state.startY - rect.top) + ); + this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); ++ // Cloned previews need their own lease when a source row is recycled during dragging. ++ const originals = previewRow.querySelectorAll('img'); ++ this.reorderPreview.querySelectorAll('img').forEach((image, index) => { ++ const original = originals.item(index); ++ const avatar = original ? webAvatarSources.get(original) : undefined; ++ if (!avatar) return; ++ const paint = image.previousElementSibling; ++ if (paint?.classList.contains('ok-native-list-selector-image-background')) { ++ image.addEventListener('load', () => { (paint as HTMLElement).style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; }); ++ } ++ configureWebAvatar(image, avatar.source, avatar.uri); ++ }); + const sourceRow = state.workingRows[state.currentIndex]; + const badgeText = sourceRow + ? webWalletGroupReorderBadge(sourceRow) +@@ -3430,6 +3946,7 @@ export class NativeListWebEngine { + + private clearReorderPreviewVisual() { + this.reorderPreview.hidden = true; ++ disposeWebImageRetries(this.reorderPreview); + this.reorderPreview.replaceChildren(); + this.reorderPreview.style.removeProperty('transform'); + this.reorderPreview.style.removeProperty('transition'); diff --git a/yarn.lock b/yarn.lock index 67022b7b0612..637db088b06e 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-app-update@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/76f4f464d9cdb91843b516febf7587276b10540231961995944597f563aacd98f140f6882091858cebbf415656f2880c66b69b08ef827b30f19ea3659036c78d + checksum: 10/c3bc2fb6a24daf1bc3bb3f3dac8000771141a56182a47b3b1488b635abb48771fa30df4ec8c3b3d422e22b7ff2b381d4d173e70a8a838dba009b648e0f11f481 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-auto-size-input@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/e2ed7f16a060134b2fa4e224c1d6b6002f8517af62aad902d87b04b25b5ae9536c128e82e43fb9f4b317105426b7ae81a63beb5f854093ba42e299299b6b9efa + checksum: 10/247cbba500acab650d86096a4ae0dfe6a519aca795ec36823eb8669dfd4eda88b38e957ea41954da81cc284daae910d4de4ad3b9b82499799eb6a30a2a8b5f8f 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-background-thread@npm:3.0.105" peerDependencies: "@onekeyfe/react-native-bundle-update": "*" react: "*" react-native: "*" - checksum: 10/4fa3502dd0b1660617a49932475d5eae213974937dc629b57c84619096f59304d1d6140dde4589b9f6a6bc7b46e8452746802fa179dde49242ee5e14fcd9e7c2 + checksum: 10/f8f7e270aa6d30c7311991932c93f5d1b47df177a1254a66a5439685b69cda4931ca79e45fc8ae839f4b2cc66fcccca9328d5c96acbf94e195ac1edb7e31ef2c 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-bundle-crypto@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/bcbc525d3078708230eff785b8816337deaf2346741ebab66272b4b69bbb0bf51c983de8147c57d4dc54449289cd527500879070f0793fef02efd8e79a5b57fa + checksum: 10/a2793a98567bc79ef8f87e0a7ce3305adf5601fd6fc98037af53aec6dad11724aa41e027cb87bfb99310edcd5690d3e1a918492d539844cbaf5ce4222213c9eb 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-bundle-update@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/5659dbca37491c084a63fac27c8200185f7a8360506e69f2fd720b8b1c4d653dc45ad83dcdbb3e28fe12abaa7f33a515f57e396db57173ed6ae6f3643b4142d5 + checksum: 10/626b113ca270562f1ff48af7759f6da68cfdfa9cb16a34b2c64172505ef4ee2a4b006dd9b8558f88497c35d7b448356c5f20396e969d99689b3332a0b5dbf8fd 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-chart-webview@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/89574990c61dcc0a7244eac21c3f30e5f9b03000e4bb8ad940df527eb1c7da7f69e75f39c0c0d4fa5c208d11ef384f8b1052f77d3cee73bb051faf7c7978b9b0 + checksum: 10/3d639b3208cd1c4f58d6d0d6b3b6a61d70109d33ebaf92ba3d4af728bba9b87dd8e017952f6d19604612232c5247d31ede45e11ab2a9cf8a48b423d3866d5166 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/7f812076175218d05f03075d032bc97cda2c66985746bedba6d7f1633e539691584d2966bc0f1a230e003de85d119600cacf6ae5e4f0ddac27bcf64b109f0c34 + checksum: 10/ed06707449f79897f5db7a127f38cd1839ad4f87267fcc8db95c163caa5c7026d4d31102f87391d4636f992fe4fdcb5224c075e7876765d542d8bbda396038aa 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-cloud-kit-module@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/c43b94bad135681d7b53fae2604a9cfc87748b9e51c023da6d4f0a93ce8795acecdde94ae8eb66864b83c30317e0cea7a36b0385b0538017af6b32562980f0a2 + checksum: 10/ad82d3b6997d4fe81dc4de13067922d9b07eb8def9e41cada8898181014322432edbfc7d2345ff8b0fa0cdfc38e9d3cd311d6bc1217f0fa7f1826e8afbfc1945 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-device-utils@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/737f8e52d7afaab5b0597665780d7122ec3bd19f824cd3deead17902f756dd945aafde631c4bd418873894d735ea2fb80773f8a5c50a6c5c8eff262fb5d4d898 + checksum: 10/a03b85ed2d0b8de24ba9f34d2782af90fc6de5e3de86ce9fef2d106bd2e1e2fa6029604fced22a39f4689dc400e5e04d75dc34e97215502e2888c38c974fe2d8 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-image@npm:3.0.105" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.104 + "@onekeyfe/react-native-skeleton": 3.0.105 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/cdf9d1b31d066f10e90589560471753871056036bf4233f4a023edfc73634a20543eeaea13cd4a16f0a057d395b49e1aabfba5e8603c793b25411355a9279c42 + checksum: 10/1281cc68c220c62c2f4183b34ea08cc7d4249b189550616f86435b76914061ac674560ea1dcf9f9f0418c979f01edc9254216026867a30b6a3a1491e84f98810 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-keychain-module@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/b6142dcff43e249a27eca77dc1307c2963a82acb68a610f8b5d4084086ec48095f89b4cdb232ce1134236fc4eaa94c93148d51132039837d93e467e2db7fdf39 + checksum: 10/7d7f3fdff5e5c1fcf35058473eb43be6a6a2813c7a9b2e4c355d6485a147f5b47cff6838aca5b1f462d95d138331f8d429f4334ab9c407acace916a608537587 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-lite-card@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/6b05b30b2ee3d106a6babbac0e572f7da3e6255d2980d7b729da224ff84f144de6aa287cc51ec971c7cc95ec75b8b4fa582229e7894d3453404d68c9abd53fee + checksum: 10/236be331825686b24652dd80418b14faaf3ce2e4f4fa23d92029b391db1b55fbaf064c59c46108d45598a7a2a7cf151d386363e104f4f902083ba9bf85058327 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-native-list@npm:3.0.105" peerDependencies: + "@onekeyfe/react-native-image": 3.0.105 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/635c4debf42b5df484c5dd2e0598d2b46162d550c2de65ab01578367ef1f96f20e5a47dfdd62d43f9b4614ef4218178da521675f18c7411ddeda69764fbe6aab + checksum: 10/8def0a0ebb9a40834402a14b7433e22ced738941603311365fa4680c6871d88c05580b22117aa8cb518cfaa41cb001424e607bfd858b65d91e06098d7b6f2049 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-native-logger@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/9e6323a07d916d0addc1d8036f0bdd590c61665b243ad2f39cd7ce133868c6bee637b6f9c8eaa65c1daa3cd27b34bb8dfce6e7e46b9ce92d9054c41428b8bf72 + react-native-nitro-modules: 0.37.0 + checksum: 10/bb4897ead06d5f168cd4981f785823776d2c104a86d6fdc40bd956f12c26228638b989a6aa6c1d47818dd9a15cb5a661609574d56d3973d02562c653bf9c4fa9 + languageName: node + linkType: hard + +"@onekeyfe/react-native-network-throttle@npm:3.0.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-network-throttle@npm:3.0.105" + peerDependencies: + react: "*" + react-native: "*" + checksum: 10/74f70428641f44060f350fa0c47252e08d65ac1af690d7cf40db59707901b3a54addf63b48d46eeb234e534f328d68f76ea57ee670f19215bdd2b174dc6e2166 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-perf-memory@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/969711ad49932f6955c037130e9b44299a3268c2e12a90cd3967f72174586ed1fb7032734d860511b2efdb682233f7af1fb487e08491da9f40111aa84e83e2c1 + checksum: 10/678eb386997d09dbe957a7857de4baed75926e44f214aa3c93c66d6f8a9a9019861c4e9d7e82a7f20f3f32c54b39e0ad5cb41c54d2435793eb11eff362b3a79d 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-perf-stats@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/9e1e754c079612f4a4beae748ffcb07c38d97e7b4c5bba5b2653ea88136bc9215182d036403526f3ef61e8cbe40c047acbdc8f3926f1601da2beee35f78c63a5 + checksum: 10/dab2e9c580dd25ee9c70c3aaa01ac3f87952696978fc886140181ebb7f451c1d9bb6bf10ed573229a4e3ddc7b592df5e0ae52d899b5f8e1e42e30c2a49268244 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-perp-depth-bar@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/83a7638a3da7b9f3181a4c16b681114e6f54ac24af4add7a3d4a2d2ca5411fd150d256637cafbe35c4e426197a5016f9b13c1fc559dd1a887d2cf11310e8fbef + checksum: 10/5aead61e3cba1b940857b59ad9356d8279a0e92948debc371fab9dd28586ff242e493d6208bb1139cc067c2e2cb7a8d79dc1f805c63659757ceac19fccffd4a6 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/fa60689e09f02b590e7be1614d3b0ea2e9ae29d1f7795f919e44a6b7b24b94c1d372d9a0c8e8015489d54517453d553bad1d066373603359330aee2b709b1887 + checksum: 10/3a6e15efa761661c5eb251c49be2aea78a09a93eff49ad1d4580707915bda457259aeecaf3d5776f141d735af74223874b46be71c46512b2aeb345ff69fa0db8 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-scroll-guard@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/68c1846f6be82e6a1a89dadb716a25fe32af795fe8c9a94d9ebf247cb55e4b99d88090980b57722d62e01c0ccd4efa013293add7e2f3c18b1bd403f95d405781 + checksum: 10/07f8f2036d1a68c6b58e4d98ef2731d254c1502cdf0927550e1ffbe3a14b7df71139e289eef78f3d14424b15c88cf928e5dbd0a0a992ef11495bb3d030a11f6e 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-segment-slider@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/d64b5267a2ec7e600e4ad1d56c0763587f03944c051fa5148b799daf1533dc57bf54aed071f9373c64bae0a936e30a9e4fa97fef8f714bd7a0ccdb16496db9fd + checksum: 10/1df29a9bfadd1d1c9a5a068737bd8848ad1584cc731e04f8ec1c1d974188dd77a2999f8d434efc4c27751a313c38f9bc022cbb4edd1c0090d99d90a2355b0e33 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-skeleton@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/5656ce50745d51da8c9b7e5c3734fbd5857c69563b19551556bfbe1caa4b10ca5412e4c562e1d16c8a80c90ca8962e2e36d420504e913b0694107303a785aec1 + checksum: 10/9e33d5b4d8e659b5bea5b13eb6db3c14bd0d57de71520917e32da158c303c3a0d4affe23de365dac892bd39a6b11f4b3e5ba0cd9cabb1e87985058263007b249 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/b5047439101dc2ee538db8e64a6ccace39e22bd495ea4fbb911ab1d57f8c09a8a74ce03e675d1d24bb08d05d7a8db5938c4f1b7c1dd3b1e2ea46374bbeb23c92 + checksum: 10/bf467bc866def5ac05359a129ef2af2442bb7ca09b1c557b714e0ded2becc5eb5c8386e24fad0ea8db844f3a6784c8a55aa9a2c35c93aea78c8c38e196bbd0ae 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-splash-screen@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/3b93625afedfc1e5b9fbf5eeea5b08a0c128a1d4b1e97eed907ff0d80986a58355cfb701a90cf182949b0ec6b7e237d4f2b7acd3bf1bf556cf27b0b0c76c635d + checksum: 10/83e6c52f2c551c1edab54bf2b87496384457c79a97da45677b7de48ca3de72c8f80254455abdda718c600c66568393fab087fac87b4078b5f5e158227848235b 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-split-bundle-loader@npm:3.0.105" peerDependencies: "@onekeyfe/react-native-bundle-update": "*" react: "*" react-native: "*" - checksum: 10/f7256d57f4232b7c27b721d253e3b1ead82fedf85b9a696443f852202bd2da49ec3fa79b5412562f4a86432acc7c0f19e3c52fe9ffe63fe01580d0a53c64f80d + checksum: 10/5d5a5f79a307b0ed35c3040509b29385e51f7935b1713fd8bbfa4f80a74d3b65070282fd92e07105e91701ad34ce3e849b97ad6a575252dac17108bd18a33cc7 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-tab-view@npm:3.0.105" 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/c68887693618f012cef6141571a8dafccdef22e0cfb1dfa01cb45246828ac36b41dbfcbf37500a06e50a573e7991818dddd4fa3500496c686dbfbfd00812b647 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-text-input@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/4bf679ec8ea8716f993eca8cd6cbdf0729e70fa26da694a637ea43fcb414af358946160d9fe872db868edf14c0d57ed6bb8d57a350869d76659a0257167f32af + checksum: 10/413d4817e571a2ca64850e89be4ac3c6101f50574a867e88bd5f4cdd0a7cae223957c0cdb1568e83ba1f2589b3548b5efa34b8de65d44100b52bf5aa7685690f 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.105" 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.105" + "@onekeyfe/react-native-segment-slider": "npm:3.0.105" + "@onekeyfe/react-native-tab-view": "npm:3.0.105" "@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.105" "@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.105" + "@onekeyfe/react-native-auto-size-input": "npm:3.0.105" + "@onekeyfe/react-native-background-thread": "npm:3.0.105" "@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.105" + "@onekeyfe/react-native-bundle-update": "npm:3.0.105" + "@onekeyfe/react-native-chart-webview": "npm:3.0.105" + "@onekeyfe/react-native-check-biometric-auth-changed": "npm:3.0.105" + "@onekeyfe/react-native-cloud-kit-module": "npm:3.0.105" + "@onekeyfe/react-native-device-utils": "npm:3.0.105" + "@onekeyfe/react-native-image": "npm:3.0.105" + "@onekeyfe/react-native-keychain-module": "npm:3.0.105" + "@onekeyfe/react-native-lite-card": "npm:3.0.105" + "@onekeyfe/react-native-native-list": "npm:3.0.105" + "@onekeyfe/react-native-native-logger": "npm:3.0.105" + "@onekeyfe/react-native-network-throttle": "npm:3.0.105" + "@onekeyfe/react-native-perf-memory": "npm:3.0.105" + "@onekeyfe/react-native-perf-stats": "npm:3.0.105" + "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.105" + "@onekeyfe/react-native-range-downloader": "npm:3.0.105" + "@onekeyfe/react-native-scroll-guard": "npm:3.0.105" + "@onekeyfe/react-native-segment-slider": "npm:3.0.105" + "@onekeyfe/react-native-skeleton": "npm:3.0.105" + "@onekeyfe/react-native-sni-connect": "npm:3.0.105" + "@onekeyfe/react-native-splash-screen": "npm:3.0.105" + "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.105" + "@onekeyfe/react-native-tab-view": "npm:3.0.105" + "@onekeyfe/react-native-text-input": "npm:3.0.105" "@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.105" "@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.105" 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.105" 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.105" + react-native-fast-pbkdf2: "npm:@onekeyfe/react-native-pbkdf2@3.0.105" 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.105" 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.105" 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.105" 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.105" 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.105" 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.105" 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-async-storage@npm:3.0.105" dependencies: merge-options: "npm:^3.0.4" peerDependencies: react: "*" react-native: "*" - checksum: 10/6a22e01c5159d3f56725b7f9aea975b740e36658197b0db40cb4781299a688ab7e7f6aa7f86a67ae715e87d1777db02d1983c0280df1658ffebe15d373e9679c + checksum: 10/4dc0cc77ba52b26964a8cc0fa1143c1da9c7b5f09169fa25ca3ad6166ec39cbcdbaeac75f92606df593915ee394a3b88f435bc77af7a3a2eb9e86fe8ccbfca1b 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-aes-crypto@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/2f0d5c1d996b4c441dafdb1fc9760d750dce9197a0d59f4a4db2b9b14f3aaf5bf629fedcad9aa4de75ca7e71e58a2bccb5c95ef591a4adb8bf1ac55d4aac6b45 + checksum: 10/bee3109318e98acd383bb7dece589a16625b42e656dfec53682db0d320b67da4c078546c5cdd34be15079c9c3960f3e4bb1e049d5d753bdc37abc99773d833a7 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-cloud-fs@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/36c8e3cb81cf044bd898e0fd59c9b5b7526b8808f4fbec5a5b22900eec705d88deaf68c2cd1ef9ad1ab0a56bd23ce71922df25ce2b3cd093fb4fb0c7449f8932 + checksum: 10/e0a72ffa16902f296e1d7ef730d1f2e87049cd694e163a5af52080d49175dcf1fd5eb8dec78634d8fb33613dde2b22fa9ef8776d0ed2213097a34ebecad1d055 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-dns-lookup@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/5b4e07763f43fc8eb61fcde0836cca14a050af99a94f2fd0681bf5b014d680518ed88744e0efb151e13a274cf5cfb8e92873a0270c8e0f77a58ef1c194b9008a + checksum: 10/a51b3331a4f9b11e8f5b5de5f08e1cc35ad7943434ae1d463a5c57a43184dfd9c82a5ba05b1c026ff8b977342ba5b8b4ef4ce8664a7db68a0b5a76b592f48862 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-pbkdf2@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/74db54611543d8393244381f7bb2e0cd7f9236fbddfc0fd2f42d80835f8b52160f613c5f1dc7f1fa547a2e22ddfb013386f11a1f1c240a22ede803e137c2183c + checksum: 10/a4e433111bd29f5f54da2f4280fcca788d3ad9c1ae227b132b801f69d76c8fa207e7ac6b29638358389efd78b5e395a6064e26c52f22b6d8d03d376376265865 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-get-random-values@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/0b1ad719dca72e2cb12bbd2c2293c05e013285a4baa8a0ef44685129c4898cceac4dc86eaac7b37ecbd2a783ad4bf1c2c310a6a17cfdc122c269098a95cb7f70 + checksum: 10/28cd8cecc75289096e9e6a5e44bdc71b1bf41a9773034f4426d92ab810a2182d89b0f48cf2af268dc6529557c35e08c70076ccd418abb8a25f0bca0c5b3b4b12 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-network-info@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/41bd006fd25de971d38b94c1a006208cfdc73ec83f6f018e3f8a3445ecec39b384b7373a98772505d2794588edf973cce586089256165467d9688a158877fa69 + checksum: 10/6b90f7ef3520b86de2897d7c30854d91d1128c811bb887677e4a5c1b6b63db19841715ef8fbb33302a4bf675aa20823a98b5ab4a55126ec1b6212d6219333c54 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-pager-view@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/1a6318ed159163ae14b350eddf99bcc25b3e57e9b28be02574a52e5354397fadecb648af6fd42e81a2bfdf2c8ce4126659de185831272c8161d6d202d2104be6 + checksum: 10/babc944b0c9b1d510c7f85455259b0b7d781803eefced5d0642361c21bbf5e95bbab92265d6797adbae778ff89cfefe4c4cf4cfc9e1a6f2b8ce46b8e6f4a07b3 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-ping@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/f1daa354eab2a5fe9c02e838db6d42d6d91c9c8d8b86b856c37a3a4bffbe6e07e6226f587e33d1cae4e39534a05ac6adbc3bdb093a9ccfa28ebc2b9dbb55753d + checksum: 10/d5062b3b9bae8cd5b6538256eabd6ae685f14775aa71c79efeb185b4e25e5a6bf6e807c49e9b9a752db95c9ed5ee425af68e1e8daf4fc544b216d41b9f7a675d 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-tcp-socket@npm:3.0.105" peerDependencies: react: "*" react-native: "*" - checksum: 10/408a1fa64aa46725d3b7e565330a54ef08f3da15001807c35370e1d050dfb0cd7c3bcf7c25e07caff27e2ba6cceaa4d6aec360c6266def560cac8b869cdf40f0 + checksum: 10/706af0a03d86e65507ae0e34391164d5b75cd21ded8e5d4d71c267ab4a50dced8c05668106754ae335a9ea29950ecdf86955fdbdd3ca52b086470aab8c8a81cb 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.105": + version: 3.0.105 + resolution: "@onekeyfe/react-native-zip-archive@npm:3.0.105" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/e7381dbdc7650e852148c1e2ace610a5987c3aaa104b82c5db0d80be5b5438bd059992aa58e9f0778ae6d6add8578de5a6c92a4deea71ce597015120bc274aa8 + checksum: 10/95dc0eadc4423e1853bc675b42613278fe85f0e2bb55f54400802cea1b91d174dc6b8a3946c151d9b6fa9c20602cae23e0eb67beb03cd8a557710d966698255b languageName: node linkType: hard From 52fdb1bbee3681a5c9176d1aa9535905870edb40 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 16:19:43 +0800 Subject: [PATCH 03/18] perf: stabilize native selector updates and caches --- .../bundle-registry/module-id-registry.json | 26 + docs/native-list-selector-performance.md | 24 +- .../WalletDetails/WalletDetailsV2.tsx | 35 +- .../accountSelectorAccountRowsV2.test.tsx | 167 ++ .../accountSelectorAccountRowsV2.ts | 221 +- .../accountSelectorValueRowsV2.test.ts | 136 ++ .../accountSelectorValueRowsV2.ts | 117 + .../useAccountSelectorValuesLoaderV2.test.ts | 259 ++ .../useAccountSelectorValuesLoaderV2.ts | 212 ++ .../NetworksSectionListV2.test.tsx | 220 +- .../NetworksSectionListV2.tsx | 211 +- .../UnifiedNetworkSelectorV2.tsx | 31 +- .../storage/nativeSWRCachePersistence.test.ts | 32 + packages/shared/src/utils/swrCacheLimits.ts | 5 + .../shared/src/utils/swrCacheUtils.test.ts | 107 + packages/shared/src/utils/swrCacheUtils.ts | 95 +- ...yfe+react-native-native-list+3.0.105.patch | 2093 ++++++++++++++--- 17 files changed, 3432 insertions(+), 559 deletions(-) create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.test.tsx create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.test.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueRowsV2.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.test.ts create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/useAccountSelectorValuesLoaderV2.ts diff --git a/apps/mobile/bundle-registry/module-id-registry.json b/apps/mobile/bundle-registry/module-id-registry.json index 16feb558cb18..26c1944b4916 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, @@ -20491,23 +20499,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, @@ -20864,6 +20882,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, diff --git a/docs/native-list-selector-performance.md b/docs/native-list-selector-performance.md index 03addfdbb020..4f54e19e5431 100644 --- a/docs/native-list-selector-performance.md +++ b/docs/native-list-selector-performance.md @@ -10,12 +10,34 @@ The URI avatar/cache change passes the listed pixel and persistence checks. Stea - 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, with at most 128 idle URLs retained; mounted image leases are protected from eviction. +- 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. + ## Measurement conditions | Target | Environment | Scrolling sample | diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx index c2b62f0dff9b..4dd9e49d7d1e 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/WalletDetailsV2.tsx @@ -60,8 +60,8 @@ import { useAccountSelectorNativeListThemeV2 } from '../accountSelectorNativeLis import { type IAccountSelectorRowRecordV2, - buildAccountSelectorRowPatchesV2, useAccountSelectorAccountRowsV2, + useAccountSelectorNativeSnapshotV2, } from './accountSelectorAccountRowsV2'; import { AccountSelectorCreateAddressActionV2, @@ -69,7 +69,7 @@ import { } from './AccountSelectorActionV2'; import { EmptyView } from './EmptyView'; import { useAddAccount } from './hooks/useAddAccount'; -import { useAccountSelectorValuesLoader } from './useAccountSelectorValuesLoader'; +import { useAccountSelectorValuesLoaderV2 } from './useAccountSelectorValuesLoaderV2'; import { WalletDetailsHeader } from './WalletDetailsHeader'; import { AccountSearchBar } from './WalletDetailsHeader/AccountSearchBar'; @@ -321,7 +321,7 @@ function WalletDetailsViewV2({ num }: IWalletDetailsProps) { }, [sectionDataOriginal, searchText, accountAddressMap, addressMapLoading]); // Load account values asynchronously in batches via atoms, scoped by selector num - useAccountSelectorValuesLoader({ + useAccountSelectorValuesLoaderV2({ num, accountsForValuesQuery: listDataResult?.accountsForValuesQuery, linkedNetworkId, @@ -498,30 +498,11 @@ function WalletDetailsViewV2({ num }: IWalletDetailsProps) { generation, theme, ]); - // Keep the native snapshot prop stable while balance batches update row fields. - const [nativeSnapshot, setNativeSnapshot] = useState(snapshot); - const appliedSnapshotRef = useRef< - { base: NativeListSnapshot; latest: NativeListSnapshot } | undefined - >(undefined); - if ( - buildAccountSelectorRowPatchesV2(nativeSnapshot, snapshot) === undefined - ) { - setNativeSnapshot(snapshot); - } - useEffect(() => { - const list = listRef.current; - if (!list) { - appliedSnapshotRef.current = undefined; - return; - } - const previous = - appliedSnapshotRef.current?.base === nativeSnapshot - ? appliedSnapshotRef.current.latest - : nativeSnapshot; - const patches = buildAccountSelectorRowPatchesV2(previous, snapshot); - if (patches?.length) list.applyPatches(patches); - appliedSnapshotRef.current = { base: nativeSnapshot, latest: snapshot }; - }, [nativeSnapshot, snapshot, listHeight]); + const nativeSnapshot = useAccountSelectorNativeSnapshotV2({ + snapshot, + listRef, + listHeight, + }); const selectedKey = isOthersUniversal ? selectedAccount.othersWalletAccountId : selectedAccount.indexedAccountId; 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 index 416f8364165a..a172ffd1ad9e 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorAccountRowsV2.ts @@ -1,4 +1,5 @@ -import { useMemo } from 'react'; +import type { RefObject } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { isEqual } from 'lodash'; import { useIntl } from 'react-intl'; @@ -24,10 +25,6 @@ import { useSettingsPersistAtom, useSettingsValuePersistAtom, } from '@onekeyhq/kit-bg/src/states/jotai/atoms'; -import type { - IAccountSelectorDeFiItem, - IAccountSelectorValueItem, -} 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'; @@ -38,10 +35,11 @@ import type { IServerNetwork } from '@onekeyhq/shared/types'; import { AccountManagerTestIDs } from '../../../testIDs'; import { accountSelectorAccountVisualV2 } from '../accountSelectorNativeListV2'; -import { formatAccountSelectorValueV2 } from './accountSelectorValueV2'; +import { createAccountSelectorValueRowsV2 } from './accountSelectorValueRowsV2'; import type { IdentityRow, + NativeListRef, NativeListSnapshot, NativeListTheme, RowPatch, @@ -291,87 +289,46 @@ export function useAccountSelectorAccountRowsV2({ editable, ], ); - // Each cache belongs to the formatting context; weak keys retain only live rows. - const getValueRow = useMemo(() => { - const cache = new WeakMap< - IdentityRow, - { - accountValue?: IAccountSelectorValueItem; - overview?: IAccountSelectorDeFiItem; - row: IdentityRow; - } - >(); - return ( - row: IdentityRow, - record: IAccountSelectorRowRecordV2, - accountValue?: IAccountSelectorValueItem, - overview?: IAccountSelectorDeFiItem, - ): IdentityRow => { - if ( - platformEnv.isWebDappMode || - platformEnv.isE2E || - record.shouldShowCreateAddressButton - ) { - return row; - } - const cached = cache.get(row); - if ( - cached && - isEqual(cached.accountValue, accountValue) && - isEqual(cached.overview, overview) - ) { - return cached.row; - } - const value = formatAccountSelectorValueV2({ - accountValue, - activeAccountValue, - overview, - walletId: wallet?.id ?? '', - linkedAccountId: - record.indexedAccount?.associateAccount?.id ?? record.item.id, - linkedNetworkId: record.avatarNetworkId ?? network?.id, - mergeDeriveAssetsEnabled, - enabledNetworksCompatibleWithWalletId, - networkInfoMap, - currencyMap, - targetCurrency: currencyInfo.id, - hideValue: !!settingsValue.hideValue, - }); - const subtitleSegments = [value, ...(row.subtitleSegments ?? [])]; - const valueRow: IdentityRow = { - ...row, - subtitleSegments, - accessibilityLabel: [ - row.title, - ...subtitleSegments.map((segment) => segment.text), - ].join(', '), - }; - cache.set(row, { accountValue, overview, row: valueRow }); - return valueRow; - }; - }, [ - activeAccountValue, - wallet?.id, - network?.id, - mergeDeriveAssetsEnabled, - enabledNetworksCompatibleWithWalletId, - networkInfoMap, - currencyMap, - currencyInfo.id, - settingsValue.hideValue, - ]); - // Balance batches must not rebuild avatars or other static row presentation. + // 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( () => - staticRows.map((row, index) => - getValueRow( - row, - records[index], - valuesMap[num]?.[row.key], - deFiMap[num]?.[row.key], - ), - ), - [staticRows, records, getValueRow, valuesMap, num, deFiMap], + 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 }; } @@ -390,6 +347,7 @@ export function buildAccountSelectorRowPatchesV2( previous: NativeListSnapshot, next: NativeListSnapshot, ): RowPatch[] | undefined { + if (previous === next) return []; const { rows: previousRows, ...previousMetadata } = previous; const { rows: nextRows, ...nextMetadata } = next; if ( @@ -398,40 +356,81 @@ export function buildAccountSelectorRowPatchesV2( ) { 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.key !== previousRow.key || row.type !== previousRow.type) { - return undefined; - } - if (row.type !== 'identity' || previousRow.type !== 'identity') { - if (!isEqual(row, previousRow)) return undefined; - } else if (row !== previousRow) { - 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; + 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); } - 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'], - }); + 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/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/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx index 7ecd5a145709..ef944ca3f330 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.test.tsx @@ -2,7 +2,7 @@ * @jest-environment jsdom */ import type { ComponentProps, ReactNode } from 'react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { act, render } from '@testing-library/react'; @@ -21,6 +21,32 @@ 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, @@ -36,9 +62,7 @@ jest.mock('@onekeyhq/components', () => ({ SearchBar: () => null, })); jest.mock('react-intl', () => ({ - useIntl: () => ({ - formatMessage: ({ id }: { id: string }) => id, - }), + useIntl: () => mockIntl, })); jest.mock('@onekeyhq/shared/src/platformEnv', () => ({ __esModule: true, @@ -54,24 +78,44 @@ jest.mock('@onekeyhq/shared/src/utils/networkUtils', () => ({ }) => Boolean(enabledNetworks[networkId]), })); jest.mock('@onekeyhq/kit/src/hooks/useAllNetwork', () => ({ - useEnabledNetworksCompatibleWithWalletIdInAllNetworks: () => ({ - enabledNetworksWithoutAccount: [{ networkId: 'a' }], - run: mockRun, - }), + 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: () => ({ - sections: [{ data: mockSearch ? [mockNetworks[1]] : mockNetworks }], - }), + 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: () => '0', + getNetworkValueV2: (params: Parameters[0]) => + mockGetNetworkValue(params), getNetworkTitleMatchV2: () => undefined, - useNetworkListPresentationV2: () => ({ - nativeTheme: { rowBackground: '#ffffff' }, - formatCurrencyValue: (value: string) => ({ text: value }), - getNetworkLeading: () => ({ kind: 'network', fallbackText: 'N' }), - }), + useNetworkListPresentationV2: () => mockPresentation, })); jest.mock('./useNetworkTooltipV2', () => ({ useNetworkTooltipV2: () => ({}), @@ -81,32 +125,55 @@ type IContextValueV2 = ComponentProps< typeof AllNetworksManagerContext.Provider >['value']; -function HarnessV2() { +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: { mainNetworks: mockNetworks, frequentlyUsedNetworks: [] }, + networks: networkCollection, networksState: state, setNetworksState: setState, - enabledNetworks: mockNetworks.filter( - (network) => state.enabledNetworks[network.id], - ), + enabledNetworks, searchKey: mockSearch, setSearchKey: jest.fn(), isCreatingEnabledAddresses: false, setIsCreatingEnabledAddresses: jest.fn(), - isCreatingMissingAddresses: false, + isCreatingMissingAddresses, setIsCreatingMissingAddresses: jest.fn(), missingAddressCount: 0, setMissingAddressCount: mockMissingCount, - accountNetworkValues: {}, - accountDeFiOverview: {}, + accountNetworkValues: values, + accountDeFiOverview: mockDeFiOverview, }), - [state], + [ + state, + networkCollection, + enabledNetworks, + values, + isCreatingMissingAddresses, + ], ); return ( @@ -125,6 +192,7 @@ describe('portfolio NativeList selection adapter V2', () => { beforeEach(() => { jest.clearAllMocks(); mockSearch = ''; + mockMissingNetworks = [{ networkId: 'a' }]; mockState = { enabledNetworks: { a: true }, disabledNetworks: {} }; }); @@ -171,13 +239,111 @@ describe('portfolio NativeList selection adapter V2', () => { }); }); + 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).toHaveBeenCalled(); + 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 index a8075ac9ebcd..1b9de8a55d3f 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/NetworksSectionListV2.tsx @@ -24,6 +24,7 @@ import { useNetworkTooltipV2 } from './useNetworkTooltipV2'; import type { CheckboxState, + IdentityRow, NativeListRef, NativeListSnapshot, RowActionEvent, @@ -71,7 +72,7 @@ export default function NetworksSectionListV2() { accountDeFiOverview, }); - const { enabledNetworksWithoutAccount, run } = + const { enabledNetworksWithoutAccount } = useEnabledNetworksCompatibleWithWalletIdInAllNetworks({ walletId: walletId ?? '', indexedAccountId, @@ -81,13 +82,6 @@ export default function NetworksSectionListV2() { useEffect(() => { setMissingAddressCount(enabledNetworksWithoutAccount.length); }, [enabledNetworksWithoutAccount.length, setMissingAddressCount]); - const enabledNetworkIds = useMemo( - () => enabledNetworks.map((network) => network.id).join(','), - [enabledNetworks], - ); - useEffect(() => { - void run(); - }, [enabledNetworkIds, run]); const selectedIds = useMemo( () => @@ -110,9 +104,60 @@ export default function NetworksSectionListV2() { [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 (!sections.length) return result; + if (!sectionRows.length) return result; if (!searchKey.trim()) { result.push({ type: 'sectionHeader', @@ -146,105 +191,81 @@ export default function NetworksSectionListV2() { heightRounding: 'nearest', }); } - sections.forEach((section, index) => { - const sectionKey = section.totalValue - ? 'portfolio-assets' - : `portfolio-section-${section.title ?? 'search'}`; - if (index) { - result.push({ - type: 'system', - variant: 'spacer', - key: `${sectionKey}-spacer`, - sectionKey, - height: 20, - heightRounding: 'nearest', - }); - } - if (section.title) { - if (section.totalValue) { - const formattedValue = formatCurrencyValue(section.totalValue); - 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 { + sectionRows.forEach( + ({ section, sectionKey, formattedValue, rows: networkRows }, index) => { + if (index) { result.push({ - type: 'sectionHeader', - presentation: 'networkSelector', - key: `${sectionKey}-header`, + type: 'system', + variant: 'spacer', + key: `${sectionKey}-spacer`, sectionKey, - title: section.title, - height: 36, - indexTitle: section.title.length === 1 ? section.title : undefined, + height: 20, heightRounding: 'nearest', }); } - } - section.data.forEach((network) => { - 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) }); + 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', + }); + } } - trailing.push({ - kind: 'checkbox', - state: selectedIds.has(network.id) ? 'checked' : 'unchecked', - target: { scope: 'row' }, - }); - result.push({ - 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, + networkRows.forEach((row) => { + result.push({ + ...row, + trailing: [ + ...(row.trailing ?? []), + { + kind: 'checkbox', + state: selectedIds.has(row.key) ? 'checked' : 'unchecked', + target: { scope: 'row' }, + }, + ], + }); }); - }); - }); + }, + ); return result; }, [ - accountDeFiOverview, - accountNetworkValues, enabledNetworks.length, - formatCurrencyValue, - getNetworkLeading, intl, searchKey, sectionBackground, - sections, + sectionRows, selectedIds, ]); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx index 089a7dca5321..c6dec261d6ef 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx @@ -144,9 +144,6 @@ function UnifiedNetworkSelectorV2() { const [originalEnabledNetworks, setOriginalEnabledNetworks] = useState< IServerNetworkMatch[] >([]); - const [enabledNetworks, setEnabledNetworks] = useState( - [], - ); const [missingAddressCount, setMissingAddressCount] = useState(0); @@ -255,23 +252,25 @@ function UnifiedNetworkSelectorV2() { setNetworksState(networkMeta.allNetworksState); }, [networkMeta]); - // Derive the enabled subset from networks + state. Lives after the - // `networks` useMemo to keep declaration order clean. + // 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(() => { - const result = networks.mainNetworks.filter((network) => - isEnabledNetworksInAllNetworks({ - networkId: network.id, - enabledNetworks: networksState.enabledNetworks, - disabledNetworks: networksState.disabledNetworks, - isTestnet: network.isTestnet, - }), - ); - setEnabledNetworks(result); if (!enabledNetworksInit.current && networks.allNetworks.length > 0) { - setOriginalEnabledNetworks(result); + setOriginalEnabledNetworks(enabledNetworks); enabledNetworksInit.current = true; } - }, [networksState, networks.mainNetworks, networks.allNetworks]); + }, [enabledNetworks, networks.allNetworks.length]); const compatibleNetworks = networkMeta?.compatibleNetworks; 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 31b5c03de082..9c0ae5f7982b 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(); } @@ -698,6 +785,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/patches/@onekeyfe+react-native-native-list+3.0.105.patch b/patches/@onekeyfe+react-native-native-list+3.0.105.patch index 5133a6b116d1..f4c8df04e767 100644 --- a/patches/@onekeyfe+react-native-native-list+3.0.105.patch +++ b/patches/@onekeyfe+react-native-native-list+3.0.105.patch @@ -76,7 +76,7 @@ index d8300e2..a129837 100644 val isSelectable: Boolean get() = !json.optBoolean("disabled", false) && type in SELECTABLE_TYPES diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt -index db47e5e..efed94f 100644 +index db47e5e..4ef24aa 100644 --- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -29,6 +29,12 @@ import android.widget.LinearLayout @@ -333,7 +333,7 @@ index db47e5e..efed94f 100644 when (item.type) { "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) -@@ -609,7 +738,16 @@ internal class NativeListRowView( +@@ -609,7 +738,30 @@ internal class NativeListRowView( "system" -> bindSystem(item, theme) } applySize(item) @@ -347,10 +347,32 @@ index db47e5e..efed94f 100644 + } applyListOrientation(item, listOrientation) + applySelectorTypography(item) ++ } ++ ++ // OneKey patch: keep a small idle member pool after reuse or a direct rebind. ++ // The compact proxy/expansion keeps every member until it leaves that state. ++ private fun trimWalletGroupRows(required: Int) { ++ val retained = maxOf(8, required) ++ while (walletGroupRows.size > retained) { ++ val row = walletGroupRows.removeAt(walletGroupRows.lastIndex) ++ (row.parent as? ViewGroup)?.removeView(row) ++ row.dispose() ++ row.onRowPress = null ++ row.onAction = null ++ row.onBindingInvalidated = null ++ } } fun recycle() { -@@ -636,6 +774,8 @@ internal class NativeListRowView( +@@ -625,6 +777,7 @@ internal class NativeListRowView( + row.alpha = 1f + row.recycle() + } ++ if (!reorderActive && walletGroupExpandAnimator?.isRunning != true) trimWalletGroupRows(0) + } + + fun bindSelection( +@@ -636,6 +789,8 @@ internal class NativeListRowView( checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, ) { if (boundKey != item.key) return @@ -359,7 +381,7 @@ index db47e5e..efed94f 100644 if (item.type == "walletGroup") { val members = buildList { add(item.json.getJSONObject("parent")) -@@ -669,7 +809,18 @@ internal class NativeListRowView( +@@ -669,7 +824,18 @@ internal class NativeListRowView( ), ) } @@ -379,7 +401,7 @@ index db47e5e..efed94f 100644 } fun bindStableSummary(item: NativeListItem) { -@@ -684,10 +835,14 @@ internal class NativeListRowView( +@@ -684,10 +850,14 @@ internal class NativeListRowView( contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) title.text = item.json.optString("title") trailingViews[0].text = item.json.optString("value") @@ -394,7 +416,7 @@ index db47e5e..efed94f 100644 leadingImages.forEach(OneKeyImageReusableView::dispose) secondaryImage.dispose() mediaNetworkImage.dispose() -@@ -699,7 +854,8 @@ internal class NativeListRowView( +@@ -699,7 +869,8 @@ internal class NativeListRowView( sourceView: View, source: String, slot: Int? = null, @@ -404,7 +426,7 @@ index db47e5e..efed94f 100644 private fun emitAction( item: NativeListItem, -@@ -712,13 +868,65 @@ internal class NativeListRowView( +@@ -712,13 +883,65 @@ internal class NativeListRowView( onAction?.invoke(item, actionKey, target, actionOrigin(sourceView, source, slot)) } @@ -470,7 +492,19 @@ index db47e5e..efed94f 100644 walletGroupRows.forEach { it.invalidateCurrentBinding() } walletGroupExpandAnimator?.removeAllListeners() walletGroupExpandAnimator?.cancel() -@@ -801,6 +1009,7 @@ internal class NativeListRowView( +@@ -736,6 +959,11 @@ internal class NativeListRowView( + activityContentRow.removeAllViews() + (actionLine.parent as? ViewGroup)?.removeView(actionLine) + removeAllViews() ++ // OneKey patch: the old animator is cancelled and old children are detached. ++ // Do not trim currently needed members when re-binding an expanded group. ++ val nextItem = tag as? NativeListItem ++ val required = if (nextItem?.type == "walletGroup") (nextItem.json.optJSONArray("children")?.length() ?: 0) + 1 else 0 ++ trimWalletGroupRows(required) + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + minimumHeight = 0 +@@ -801,6 +1029,7 @@ internal class NativeListRowView( trailingColumn.layoutParams = wrap() trailingViews.forEach { it.visibility = GONE @@ -478,7 +512,7 @@ index db47e5e..efed94f 100644 it.gravity = Gravity.END it.maxLines = 1 it.layoutParams = wrap() -@@ -879,7 +1088,8 @@ internal class NativeListRowView( +@@ -879,7 +1108,8 @@ internal class NativeListRowView( mainColumn.removeView(skeletonSecondary) setOnClickListener { view -> (view.tag as? NativeListItem)?.let { item -> @@ -488,7 +522,7 @@ index db47e5e..efed94f 100644 } } } -@@ -1003,9 +1213,12 @@ internal class NativeListRowView( +@@ -1003,9 +1233,12 @@ internal class NativeListRowView( members.add(children.getJSONObject(index)) } } @@ -502,7 +536,7 @@ index db47e5e..efed94f 100644 walletGroupDragBadgeBackgroundPaint.color = color( theme, "inverseBackground", -@@ -1048,8 +1261,11 @@ internal class NativeListRowView( +@@ -1048,8 +1281,11 @@ internal class NativeListRowView( null, memberJson.optBoolean("selected", false), checkboxState, @@ -515,7 +549,7 @@ index db47e5e..efed94f 100644 if (index > 0) topMargin = dp(12) } addView(memberRow) -@@ -1082,8 +1298,11 @@ internal class NativeListRowView( +@@ -1082,8 +1318,11 @@ internal class NativeListRowView( titleLine.gravity = Gravity.CENTER titleLine.packsChildrenAtStart = false titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) @@ -528,7 +562,7 @@ index db47e5e..efed94f 100644 showText(title, item.json.optString("title"), 1) title.setTextColor( color( -@@ -1095,13 +1314,41 @@ internal class NativeListRowView( +@@ -1095,13 +1334,41 @@ internal class NativeListRowView( addView( mainColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { @@ -572,7 +606,7 @@ index db47e5e..efed94f 100644 } val leading = item.json.optJSONObject("leading") item.json.optJSONObject("leadingAction")?.let { action -> -@@ -1136,6 +1383,13 @@ internal class NativeListRowView( +@@ -1136,6 +1403,13 @@ internal class NativeListRowView( item.json.optString("presentation") == "accountSelector" ) 32 else 40, ) @@ -586,7 +620,7 @@ index db47e5e..efed94f 100644 addView(mainColumn, weighted()) titleLine.packsChildrenAtStart = true title.ellipsize = TextUtils.TruncateAt.END -@@ -1144,6 +1398,43 @@ internal class NativeListRowView( +@@ -1144,6 +1418,43 @@ internal class NativeListRowView( title.typeface = NativeListFonts.regular(context) } showText(subtitle, item.json.optString("subtitle"), item.json.optInt("subtitleLines", 2)) @@ -630,7 +664,7 @@ index db47e5e..efed94f 100644 showText(tertiary, item.json.optString("tertiary"), 1) tertiary.setTextColor( color( -@@ -1164,6 +1455,10 @@ internal class NativeListRowView( +@@ -1164,6 +1475,10 @@ internal class NativeListRowView( } addView(trailingColumn, wrap()) val accessories = item.json.optJSONArray("trailing") @@ -641,7 +675,7 @@ index db47e5e..efed94f 100644 if (accessories.hasAccessory("checkbox") && accessories.hasAccessory("value")) { trailingColumn.orientation = HORIZONTAL trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL -@@ -1847,10 +2142,15 @@ internal class NativeListRowView( +@@ -1847,10 +2162,15 @@ internal class NativeListRowView( val isSummary = variant == "summary" val isGallery = variant == "gallery" val isTable = currentLayout == "table" @@ -658,7 +692,7 @@ index db47e5e..efed94f 100644 (item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null) // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView // level, so header-local insets below are source px minus that outer inset. -@@ -1870,7 +2170,8 @@ internal class NativeListRowView( +@@ -1870,7 +2190,8 @@ internal class NativeListRowView( ), ) if (isNetworkSelector) { @@ -668,7 +702,7 @@ index db47e5e..efed94f 100644 title.textSize = sp(14f) title.typeface = NativeListFonts.medium(context) TextViewCompat.setLineHeight(title, dp(20)) -@@ -1924,14 +2225,22 @@ internal class NativeListRowView( +@@ -1924,14 +2245,22 @@ internal class NativeListRowView( item.json.optString("valueActionKey"), color(theme, "secondaryText", "#0000009B"), ) @@ -692,7 +726,7 @@ index db47e5e..efed94f 100644 if (checkboxData != null && value.isNotEmpty()) { // Value and checkbox share the trailing edge as one compound accessory. trailingColumn.orientation = HORIZONTAL -@@ -1966,6 +2275,27 @@ internal class NativeListRowView( +@@ -1966,6 +2295,27 @@ internal class NativeListRowView( } checkboxData?.let { bindCheckbox(item, it, checkboxState) } } @@ -720,7 +754,7 @@ index db47e5e..efed94f 100644 } private fun bindAction( -@@ -1978,12 +2308,13 @@ internal class NativeListRowView( +@@ -1978,12 +2328,13 @@ internal class NativeListRowView( addLeading(icon, if (isAccountSelector) 32 else 40) leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) if (!icon.has("backgroundColor")) leadingFrame.background = null @@ -736,7 +770,7 @@ index db47e5e..efed94f 100644 } else if (item.json.optString("tone") == "danger") { title.setTextColor(color(theme, "negative", "#C40006D3")) } -@@ -2003,6 +2334,17 @@ internal class NativeListRowView( +@@ -2003,6 +2354,17 @@ internal class NativeListRowView( private fun bindSystem(item: NativeListItem, theme: JSONObject?) { val variant = item.json.optString("variant") @@ -754,7 +788,7 @@ index db47e5e..efed94f 100644 if (variant == "spacer") { minimumHeight = dp(item.json.optInt("height", 0)) return -@@ -2072,7 +2414,11 @@ internal class NativeListRowView( +@@ -2072,7 +2434,11 @@ internal class NativeListRowView( spacingDp: Int = 12, ) { leadingFrame.visibility = VISIBLE @@ -767,7 +801,7 @@ index db47e5e..efed94f 100644 addView(leadingFrame) leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) if (visual == null) return -@@ -2104,7 +2450,7 @@ internal class NativeListRowView( +@@ -2104,7 +2470,7 @@ internal class NativeListRowView( leadingFrame.background = GradientDrawable().apply { setColor(visualBackground) setStroke(1, parseNativeListColor("#0000001F")) @@ -776,7 +810,7 @@ index db47e5e..efed94f 100644 } leadingIcon.iconName = visual.optString("name") leadingIcon.tintColor = safeColor( -@@ -2161,7 +2507,89 @@ internal class NativeListRowView( +@@ -2161,7 +2527,89 @@ internal class NativeListRowView( else -> leadingOutlineProvider(shape) } image.clipToOutline = true @@ -867,7 +901,7 @@ index db47e5e..efed94f 100644 } } -@@ -2223,7 +2651,11 @@ internal class NativeListRowView( +@@ -2223,7 +2671,11 @@ internal class NativeListRowView( for (index in 0 until minOf(2, accessories.length())) { val accessory = accessories.getJSONObject(index) when (accessory.optString("kind")) { @@ -880,7 +914,7 @@ index db47e5e..efed94f 100644 "valuePair" -> showTrailingValuePair(textIndex++, accessory, theme) "checkbox" -> bindCheckbox(item, accessory, checkboxState) "radio" -> showTrailing( -@@ -2285,11 +2717,25 @@ internal class NativeListRowView( +@@ -2285,11 +2737,25 @@ internal class NativeListRowView( return } checkbox.visibility = VISIBLE @@ -910,7 +944,7 @@ index db47e5e..efed94f 100644 } // Row-level disabled opacity already applies to this child. Only apply a // local 0.5 when the accessory alone is disabled, never 0.5 * 0.5. -@@ -2330,6 +2776,8 @@ internal class NativeListRowView( +@@ -2330,6 +2796,8 @@ internal class NativeListRowView( } val groupPosition = when { item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> "single" @@ -919,7 +953,7 @@ index db47e5e..efed94f 100644 else -> when (item.type) { "metricCard" -> "single" "rail" -> "rail" -@@ -2337,7 +2785,7 @@ internal class NativeListRowView( +@@ -2337,7 +2805,7 @@ internal class NativeListRowView( } } var backgroundGroupPosition = if (item.type == "mediaTile") "mediaTile" else groupPosition @@ -928,7 +962,7 @@ index db47e5e..efed94f 100644 // Selection in sectioned lists is represented by the OneKey checkbox, // matching iOS and the app-monorepo network selector. rowBackground = color(theme, "rowBackground", "#FFFFFF") -@@ -2351,6 +2799,8 @@ internal class NativeListRowView( +@@ -2351,6 +2819,8 @@ internal class NativeListRowView( rowBackground = color(theme, "subduedBackground", "#F9F9F9") backgroundGroupPosition = "" } @@ -937,7 +971,7 @@ index db47e5e..efed94f 100644 restingRowBackground = groupedBackground(backgroundGroupPosition, rowBackground) background = restingRowBackground } -@@ -2466,7 +2916,26 @@ internal class NativeListRowView( +@@ -2466,7 +2936,26 @@ internal class NativeListRowView( val icon = trailingIcons[index] icon.iconName = data.optString("name") icon.tintColor = safeColor(data.optString("tintColor"), iconSubduedColor) @@ -965,7 +999,7 @@ index db47e5e..efed94f 100644 icon.glyphSizeDp = null // ListItem.DrillIn is a 24dp icon with mx=-6, for a 12dp layout footprint. icon.layoutParams = LayoutParams(dp(24), dp(24)).apply { -@@ -2516,7 +2985,7 @@ internal class NativeListRowView( +@@ -2516,7 +3005,7 @@ internal class NativeListRowView( when (item.type) { "message" -> 14f "sectionHeader" -> when { @@ -974,7 +1008,7 @@ index db47e5e..efed94f 100644 item.json.optString("variant") == "gallery" -> 18f item.json.optString("variant") == "summary" -> 16f currentLayout == "table" -> 11f -@@ -2530,11 +2999,14 @@ internal class NativeListRowView( +@@ -2530,11 +3019,14 @@ internal class NativeListRowView( } }, ) @@ -990,7 +1024,7 @@ index db47e5e..efed94f 100644 isNetworkSelectorSection -> NativeListFonts.medium(context) currentLayout == "table" -> NativeListFonts.regular(context) item.json.optString("variant") == "summary" -> NativeListFonts.medium(context) -@@ -2552,7 +3024,7 @@ internal class NativeListRowView( +@@ -2552,7 +3044,7 @@ internal class NativeListRowView( else -> 14f }) tertiary.textSize = sp(14f) @@ -999,7 +1033,7 @@ index db47e5e..efed94f 100644 TextViewCompat.setLineHeight(title, dp(20)) } else if (item.type == "sectionHeader" && item.json.optString("variant") == "gallery") { TextViewCompat.setLineHeight(title, dp(24)) -@@ -2589,11 +3061,42 @@ internal class NativeListRowView( +@@ -2589,11 +3081,42 @@ internal class NativeListRowView( column.typeface = NativeListFonts.medium(context) column.fontFeatureSettings = "tnum" } @@ -1043,7 +1077,7 @@ index db47e5e..efed94f 100644 } isNetworkSelectorIdentity -> 47 else -> when (item.type) { -@@ -2617,6 +3120,7 @@ internal class NativeListRowView( +@@ -2617,6 +3140,7 @@ internal class NativeListRowView( else -> 36 } "system" -> when (item.json.optString("variant")) { @@ -1051,7 +1085,7 @@ index db47e5e..efed94f 100644 "noMatch", "end" -> 36 "retry" -> 44 else -> 56 -@@ -2636,14 +3140,14 @@ internal class NativeListRowView( +@@ -2636,14 +3160,14 @@ internal class NativeListRowView( 56 } else -> when { @@ -1068,7 +1102,7 @@ index db47e5e..efed94f 100644 0 } else if ( item.type == "sectionHeader" && item.json.optString("variant") in listOf("summary", "gallery") -@@ -2713,7 +3217,12 @@ internal class NativeListRowView( +@@ -2713,7 +3237,12 @@ internal class NativeListRowView( override fun getOutline(view: View, outline: Outline) { when (shape) { "square" -> outline.setRect(0, 0, view.width, view.height) @@ -1082,7 +1116,7 @@ index db47e5e..efed94f 100644 else -> outline.setOval(0, 0, view.width, view.height) } } -@@ -2742,7 +3251,13 @@ internal class NativeListRowView( +@@ -2742,7 +3271,13 @@ internal class NativeListRowView( token: String, slot: Int, variant: String, @@ -1096,7 +1130,7 @@ index db47e5e..efed94f 100644 val uri = source.optString("uri").trim().takeIf(String::isNotEmpty) imageView.configure( sourceUri = uri, -@@ -2751,17 +3266,39 @@ internal class NativeListRowView( +@@ -2751,17 +3286,39 @@ internal class NativeListRowView( contentFit = source.optString("contentFit", "cover"), cachePolicy = source.optString("cachePolicy", "memory-disk"), autoplay = source.optBoolean("autoplay", false), @@ -1140,7 +1174,7 @@ index db47e5e..efed94f 100644 private fun color(theme: JSONObject?, key: String, fallback: String): Int = safeColor(theme?.optString(key, fallback), parseNativeListColor(fallback)) -@@ -2774,30 +3311,30 @@ internal class NativeListRowView( +@@ -2774,30 +3331,30 @@ internal class NativeListRowView( private fun roundedFill(color: Int, radiusDp: Float) = GradientDrawable().apply { setColor(color) @@ -1177,7 +1211,7 @@ index db47e5e..efed94f 100644 else -> FloatArray(8) } } -@@ -2805,6 +3342,7 @@ internal class NativeListRowView( +@@ -2805,6 +3362,7 @@ internal class NativeListRowView( } private class OneKeyIconView(context: android.content.Context) : View(context) { @@ -1185,7 +1219,7 @@ index db47e5e..efed94f 100644 var iconName: String = "" set(value) { field = value -@@ -2828,9 +3366,11 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2828,9 +3386,11 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { val pathData = iconPaths[iconName] ?: return val drawSize = minOf( minOf(width, height).toFloat(), @@ -1199,7 +1233,7 @@ index db47e5e..efed94f 100644 fill.color = tintColor canvas.save() canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) -@@ -2839,6 +3379,8 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2839,6 +3399,8 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { pathData.forEachIndexed { index, data -> PathParser.createPathFromPathData(data)?.let { path -> path.fillType = sourceFillTypes?.getOrNull(index) ?: Path.FillType.EVEN_ODD @@ -1208,7 +1242,7 @@ index db47e5e..efed94f 100644 canvas.drawPath(path, fill) } } -@@ -2846,10 +3388,42 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2846,10 +3408,42 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { } companion object { @@ -1251,7 +1285,7 @@ index db47e5e..efed94f 100644 "ChevronRightSmallOutline" to listOf(Path.FillType.WINDING), "MinusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), "PlusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), -@@ -2867,6 +3441,16 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2867,6 +3461,16 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { // Exact 24x24 paths from app-monorepo packages/components Icon sources. private val iconPaths = mapOf( @@ -1268,7 +1302,7 @@ index db47e5e..efed94f 100644 "ArrowBottomOutline" to listOf("m13 17.586 5-5L19.414 14 12 21.414 4.586 14 6 12.586l5 5V3h2z"), "ArrowTopOutline" to listOf("M19.414 10 18 11.414l-5-5V21h-2V6.414l-5 5L4.586 10 12 2.586z"), "ChartTrendingUpOutline" to listOf("M22 13h-2V9.414l-7 7-4-4-6 6L1.586 17 9 9.586l4 4L18.586 8H15V6h7z"), -@@ -2910,6 +3494,7 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2910,6 +3514,7 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { } private class OneKeyCheckboxView(context: android.content.Context) : View(context) { @@ -1276,7 +1310,7 @@ index db47e5e..efed94f 100644 private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } private var state = "unchecked" -@@ -2926,9 +3511,11 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex +@@ -2926,9 +3531,11 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex "indeterminate" -> "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1" else -> return } @@ -1652,7 +1686,7 @@ index f0af895..ec5c3fe 100644 + } +} diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift -index e0bf60b..c53c679 100644 +index e0bf60b..33bebb6 100644 --- a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift +++ b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift @@ -1,4 +1,6 @@ @@ -1816,7 +1850,7 @@ index e0bf60b..c53c679 100644 private(set) var bindingEpoch = 0 var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? -@@ -615,9 +691,23 @@ final class NativeListCell: UICollectionViewCell { +@@ -615,12 +691,41 @@ final class NativeListCell: UICollectionViewCell { override func layoutSubviews() { super.layoutSubviews() @@ -1836,11 +1870,38 @@ index e0bf60b..c53c679 100644 + button.transform = .identity + let origin = button.convert(button.bounds, to: contentView).minY + button.transform = CGAffineTransform(translationX: 0, y: 11 - origin) ++ } ++ } ++ ++ // OneKey patch: trim only detached/recycled members, never a live compact ++ // drag proxy or an expanding group. Small groups retain eight reusable cells. ++ private func trimWalletGroupCells(keeping required: Int) { ++ let retained = max(8, required) ++ while walletGroupCells.count > retained { ++ let cell = walletGroupCells.removeLast() ++ rootStack.removeArrangedSubview(cell) ++ cell.removeFromSuperview() ++ cell.prepareForReuse() ++ cell.onAction = nil ++ cell.onBindingInvalidated = nil + } } override func prepareForReuse() { -@@ -664,6 +754,14 @@ final class NativeListCell: UICollectionViewCell { ++ let canTrimMembers = !walletGroupCompactAppearanceActive && (rootStack.layer.animationKeys()?.isEmpty ?? true) + super.prepareForReuse() + invalidateCurrentBinding() + isHighlighted = false +@@ -632,6 +737,8 @@ final class NativeListCell: UICollectionViewCell { + walletGroupCompactContainer.alpha = 1 + walletGroupCompactCell?.prepareForReuse() + walletGroupCells.forEach { $0.prepareForReuse() } ++ walletGroupMembers.removeAll() ++ if canTrimMembers { trimWalletGroupCells(keeping: 0) } + leadingImages.forEach { $0.prepareForReuse() } + secondaryImage.prepareForReuse() + mediaNetworkImage.prepareForReuse() +@@ -664,6 +771,14 @@ final class NativeListCell: UICollectionViewCell { // Checkbox uses the literal neutral7 alpha token. Applying opacity to the // opaque primary text color produces a different RGB result. checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) @@ -1855,7 +1916,7 @@ index e0bf60b..c53c679 100644 visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF") titleLabel.textColor = primary subtitleLabel.textColor = secondary -@@ -693,6 +791,11 @@ final class NativeListCell: UICollectionViewCell { +@@ -693,6 +808,11 @@ final class NativeListCell: UICollectionViewCell { pressedBackgroundColor = restingBackgroundColor } updateBackgroundColor() @@ -1867,7 +1928,7 @@ index e0bf60b..c53c679 100644 if layout == "table" { if item.type == "dataRow" { rootLeadingConstraint.constant = 20 -@@ -707,8 +810,12 @@ final class NativeListCell: UICollectionViewCell { +@@ -707,8 +827,12 @@ final class NativeListCell: UICollectionViewCell { } applyGroupPosition(item.data.string("groupPosition")) isUserInteractionEnabled = !item.data.bool("disabled") @@ -1881,7 +1942,7 @@ index e0bf60b..c53c679 100644 switch item.type { case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) -@@ -724,6 +831,14 @@ final class NativeListCell: UICollectionViewCell { +@@ -724,6 +848,14 @@ final class NativeListCell: UICollectionViewCell { case "system": bindSystem(item, theme: theme) default: break } @@ -1896,7 +1957,7 @@ index e0bf60b..c53c679 100644 } func updateSelection( -@@ -732,6 +847,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -732,6 +864,8 @@ final class NativeListCell: UICollectionViewCell { checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { guard currentItem?.key == item.key else { return } @@ -1905,7 +1966,7 @@ index e0bf60b..c53c679 100644 if item.type == "walletGroup" { currentItem = item let memberData = [item.data.dictionary("parent")].compactMap { $0 } -@@ -777,6 +894,13 @@ final class NativeListCell: UICollectionViewCell { +@@ -777,6 +911,13 @@ final class NativeListCell: UICollectionViewCell { selected ? "#FFFFFFED" : "#FFFFFFAF" ) } @@ -1919,7 +1980,7 @@ index e0bf60b..c53c679 100644 guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return } updateCheckboxPresentation( item, -@@ -786,12 +910,55 @@ final class NativeListCell: UICollectionViewCell { +@@ -786,12 +927,55 @@ final class NativeListCell: UICollectionViewCell { ) } @@ -1975,7 +2036,7 @@ index e0bf60b..c53c679 100644 let valueButton = accessoryButtons[0] valueButton.isHidden = value.isEmpty if value.isEmpty { -@@ -801,7 +968,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -801,7 +985,7 @@ final class NativeListCell: UICollectionViewCell { setButtonLine( valueButton, text: value, @@ -1984,7 +2045,7 @@ index e0bf60b..c53c679 100644 color: nativeListColor(currentTheme, "secondaryText", "#646464"), lineHeight: 24 ) -@@ -826,7 +993,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -826,7 +1010,7 @@ final class NativeListCell: UICollectionViewCell { // Selection is communicated by the destination state for these source // components; neither has a persistent selected tile background. color = nativeListColor(theme, "rowBackground", "#FFFFFF") @@ -1993,7 +2054,7 @@ index e0bf60b..c53c679 100644 // Checkbox-backed section lists in app-monorepo keep rows on $bg; // selection is represented by the checkbox itself. color = nativeListColor(theme, "rowBackground", "#FFFFFF") -@@ -836,10 +1003,27 @@ final class NativeListCell: UICollectionViewCell { +@@ -836,10 +1020,27 @@ final class NativeListCell: UICollectionViewCell { !selected { color = nativeListColor(theme, "subduedBackground", "#F9F9F9") } @@ -2021,7 +2082,18 @@ index e0bf60b..c53c679 100644 walletGroupCompactCell?.invalidateCurrentBinding() walletGroupCells.forEach { $0.invalidateCurrentBinding() } isHighlighted = false -@@ -978,11 +1162,13 @@ final class NativeListCell: UICollectionViewCell { +@@ -848,6 +1049,10 @@ final class NativeListCell: UICollectionViewCell { + $0.removeFromSuperview() + } + walletGroupMembers.removeAll() ++ // OneKey patch: reset has detached the old hierarchy, including on direct ++ // large-to-small binds that do not pass through UICollectionView reuse. ++ let requiredMembers = currentItem?.type == "walletGroup" ? (currentItem?.data.dictionaries("children").count ?? 0) + 1 : 0 ++ trimWalletGroupCells(keeping: requiredMembers) + walletGroupCompactAppearanceActive = false + walletGroupCompactContainer.isHidden = true + walletGroupCompactContainer.alpha = 1 +@@ -978,11 +1183,13 @@ final class NativeListCell: UICollectionViewCell { $0.backgroundColor = .clear } accessoryButtons.enumerated().forEach { index, button in @@ -2035,7 +2107,7 @@ index e0bf60b..c53c679 100644 button.setTitle(nil, for: .normal) button.setAttributedTitle(nil, for: .normal) button.setImage(nil, for: .normal) -@@ -994,6 +1180,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -994,6 +1201,7 @@ final class NativeListCell: UICollectionViewCell { button.backgroundColor = .clear button.layer.cornerRadius = 0 button.contentEdgeInsets = .zero @@ -2043,7 +2115,7 @@ index e0bf60b..c53c679 100644 } checkboxButton.isHidden = true checkboxButton.alpha = 1 -@@ -1028,6 +1215,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -1028,6 +1236,7 @@ final class NativeListCell: UICollectionViewCell { contentView.clipsToBounds = false leadingContainer.alpha = 1 titleLabel.showsDottedUnderline = false @@ -2051,7 +2123,7 @@ index e0bf60b..c53c679 100644 titleLabel.dottedUnderlineVerticalOffset = 0 } -@@ -1073,7 +1261,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -1073,7 +1282,8 @@ final class NativeListCell: UICollectionViewCell { while walletGroupCells.count < walletGroupMembers.count { let memberCell = NativeListCell(frame: .zero) memberCell.translatesAutoresizingMaskIntoConstraints = false @@ -2061,7 +2133,7 @@ index e0bf60b..c53c679 100644 walletGroupCells.append(memberCell) } rootStack.axis = .vertical -@@ -1083,8 +1272,18 @@ final class NativeListCell: UICollectionViewCell { +@@ -1083,8 +1293,18 @@ final class NativeListCell: UICollectionViewCell { rootTrailingConstraint.constant = 0 rootTopConstraint.constant = 0 rootBottomConstraint.constant = 0 @@ -2080,7 +2152,7 @@ index e0bf60b..c53c679 100644 memberCell.onAction = { [weak self] source, action, target, origin in self?.onAction?(source, action, target, origin) } -@@ -1140,7 +1339,10 @@ final class NativeListCell: UICollectionViewCell { +@@ -1140,7 +1360,10 @@ final class NativeListCell: UICollectionViewCell { let point = gesture.location(in: rootStack) for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() where cell.frame.contains(point) { @@ -2092,7 +2164,7 @@ index e0bf60b..c53c679 100644 return } } -@@ -1269,8 +1471,14 @@ final class NativeListCell: UICollectionViewCell { +@@ -1269,8 +1492,14 @@ final class NativeListCell: UICollectionViewCell { contentView.clipsToBounds = true } else { applyGroupPosition(currentItem?.data.string("groupPosition") ?? "") @@ -2108,7 +2180,7 @@ index e0bf60b..c53c679 100644 contentView.clipsToBounds = restingRadius > 0 } } -@@ -1299,6 +1507,17 @@ final class NativeListCell: UICollectionViewCell { +@@ -1299,6 +1528,17 @@ final class NativeListCell: UICollectionViewCell { fallbackLabel.font = nativeListFont(ofSize: 28) addLeading(item.data.dictionary("leading"), key: item.key) rootStack.addArrangedSubview(mainStack) @@ -2126,7 +2198,7 @@ index e0bf60b..c53c679 100644 show(titleLabel, item.data.string("title"), lines: 1) setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) titleLabel.textColor = nativeListColor( -@@ -1306,6 +1525,32 @@ final class NativeListCell: UICollectionViewCell { +@@ -1306,6 +1546,32 @@ final class NativeListCell: UICollectionViewCell { selected ? "primaryText" : "secondaryText", selected ? "#FFFFFFED" : "#FFFFFFAF" ) @@ -2159,7 +2231,7 @@ index e0bf60b..c53c679 100644 return } if item.data.string("presentation") == "accountSelector" { -@@ -1344,6 +1589,12 @@ final class NativeListCell: UICollectionViewCell { +@@ -1344,6 +1610,12 @@ final class NativeListCell: UICollectionViewCell { rootStack.setCustomSpacing(5, after: leadingActionButton) } addLeading(item.data.dictionary("leading"), key: item.key) @@ -2172,7 +2244,7 @@ index e0bf60b..c53c679 100644 rootStack.addArrangedSubview(mainStack) show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1)) show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1)) -@@ -1353,6 +1604,77 @@ final class NativeListCell: UICollectionViewCell { +@@ -1353,6 +1625,77 @@ final class NativeListCell: UICollectionViewCell { setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20) } @@ -2250,7 +2322,7 @@ index e0bf60b..c53c679 100644 tertiaryLabel.textColor = nativeListColor( theme, item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText", -@@ -1997,15 +2319,29 @@ final class NativeListCell: UICollectionViewCell { +@@ -1997,15 +2340,29 @@ final class NativeListCell: UICollectionViewCell { ) { rootStack.addArrangedSubview(mainStack) let variant = item.data.string("variant") @@ -2280,7 +2352,7 @@ index e0bf60b..c53c679 100644 : isNetworkSelector ? .medium : isGallery || layout == "sectioned" ? .semibold : .regular titleLabel.font = nativeListFont( -@@ -2019,8 +2355,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -2019,8 +2376,8 @@ final class NativeListCell: UICollectionViewCell { ) show(titleLabel, item.data.string("title"), lines: 1) if isNetworkSelector { @@ -2291,7 +2363,7 @@ index e0bf60b..c53c679 100644 titleLabel.dottedUnderlineColor = nativeListColor( theme, "secondaryText", -@@ -2028,8 +2364,9 @@ final class NativeListCell: UICollectionViewCell { +@@ -2028,8 +2385,9 @@ final class NativeListCell: UICollectionViewCell { ) rootLeadingConstraint.constant = 12 rootTrailingConstraint.constant = -12 @@ -2303,7 +2375,7 @@ index e0bf60b..c53c679 100644 setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) } else if isHistory { rootLeadingConstraint.constant = 0 -@@ -2095,6 +2432,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -2095,6 +2453,7 @@ final class NativeListCell: UICollectionViewCell { rootTopConstraint.constant = 24 rootBottomConstraint.constant = -20 setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) @@ -2311,7 +2383,7 @@ index e0bf60b..c53c679 100644 let valueActionKey = item.data.string("valueActionKey") let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty ? nil -@@ -2105,11 +2443,11 @@ final class NativeListCell: UICollectionViewCell { +@@ -2105,11 +2464,11 @@ final class NativeListCell: UICollectionViewCell { action: action, color: nativeListColor(theme, "secondaryText", "#646464") ) @@ -2325,7 +2397,7 @@ index e0bf60b..c53c679 100644 color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 24 ) -@@ -2169,6 +2507,28 @@ final class NativeListCell: UICollectionViewCell { +@@ -2169,6 +2528,28 @@ final class NativeListCell: UICollectionViewCell { ) } } @@ -2354,7 +2426,7 @@ index e0bf60b..c53c679 100644 } private func bindAction( -@@ -2185,6 +2545,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -2185,6 +2566,8 @@ final class NativeListCell: UICollectionViewCell { addLeading(icon, key: item.key) if isAccountSelector { leadingContainer.layer.cornerCurve = .continuous @@ -2363,7 +2435,7 @@ index e0bf60b..c53c679 100644 } if icon["backgroundColor"] == nil { leadingContainer.backgroundColor = .clear -@@ -2195,8 +2557,9 @@ final class NativeListCell: UICollectionViewCell { +@@ -2195,8 +2578,9 @@ final class NativeListCell: UICollectionViewCell { rootStack.addArrangedSubview(mainStack) show(titleLabel, item.data.string("title"), lines: 1) if isAccountSelector { @@ -2375,7 +2447,7 @@ index e0bf60b..c53c679 100644 } else if item.data.string("tone") == "danger" { titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") } -@@ -2217,8 +2580,19 @@ final class NativeListCell: UICollectionViewCell { +@@ -2217,8 +2601,19 @@ final class NativeListCell: UICollectionViewCell { } } @@ -2395,7 +2467,7 @@ index e0bf60b..c53c679 100644 case "secondary": return nativeListColor(theme, "secondaryText", "#646464") case "positive": return nativeListColor(theme, "positive", "#218358") case "negative": return nativeListColor(theme, "negative", "#CE2C31") -@@ -2230,6 +2604,36 @@ final class NativeListCell: UICollectionViewCell { +@@ -2230,6 +2625,36 @@ final class NativeListCell: UICollectionViewCell { rootStack.alignment = .center rootStack.distribution = .fill let variant = item.data.string("variant") @@ -2432,7 +2504,7 @@ index e0bf60b..c53c679 100644 if variant == "loading" { leadingWidth.constant = 40 leadingHeight.constant = 40 -@@ -2357,7 +2761,105 @@ final class NativeListCell: UICollectionViewCell { +@@ -2357,7 +2782,105 @@ final class NativeListCell: UICollectionViewCell { tokenPair: tokenPair, shape: shape )) @@ -2539,7 +2611,7 @@ index e0bf60b..c53c679 100644 } NSLayoutConstraint.activate(leadingSlotConstraints) } -@@ -2439,6 +2941,11 @@ final class NativeListCell: UICollectionViewCell { +@@ -2439,6 +2962,11 @@ final class NativeListCell: UICollectionViewCell { switch accessory.string("kind") { case "value": showAccessory(textIndex, accessory.string("text")) @@ -2551,7 +2623,7 @@ index e0bf60b..c53c679 100644 textIndex += 1 case "valuePair": showValuePairAccessory(textIndex, accessory, theme: theme) -@@ -2519,7 +3026,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -2519,7 +3047,7 @@ final class NativeListCell: UICollectionViewCell { state == "unchecked" ? nil : nativeListIcon(named: glyphName), for: .normal ) @@ -2560,7 +2632,7 @@ index e0bf60b..c53c679 100644 // A disabled ListItem already applies 0.5 to its complete content. Avoid // multiplying that opacity on the nested control a second time. checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 -@@ -2543,11 +3050,24 @@ final class NativeListCell: UICollectionViewCell { +@@ -2543,11 +3071,24 @@ final class NativeListCell: UICollectionViewCell { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight @@ -2585,7 +2657,7 @@ index e0bf60b..c53c679 100644 if letterSpacing != 0 { attributes[.kern] = letterSpacing } label.attributedText = NSAttributedString(string: text, attributes: attributes) } -@@ -2563,7 +3083,20 @@ final class NativeListCell: UICollectionViewCell { +@@ -2563,7 +3104,20 @@ final class NativeListCell: UICollectionViewCell { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight @@ -2607,7 +2679,7 @@ index e0bf60b..c53c679 100644 button.setAttributedTitle( NSAttributedString( string: text, -@@ -2571,6 +3104,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -2571,6 +3125,7 @@ final class NativeListCell: UICollectionViewCell { .font: font, .foregroundColor: color, .paragraphStyle: paragraphStyle, @@ -2615,7 +2687,7 @@ index e0bf60b..c53c679 100644 ] ), for: .normal -@@ -2671,6 +3205,9 @@ final class NativeListCell: UICollectionViewCell { +@@ -2671,6 +3226,9 @@ final class NativeListCell: UICollectionViewCell { switch tone.isEmpty ? defaultTone : tone { case "positive": return nativeListColor(theme, "positive", "#218358") case "negative": return nativeListColor(theme, "negative", "#CE2C31") @@ -2625,7 +2697,7 @@ index e0bf60b..c53c679 100644 case "secondary": return nativeListColor(theme, "secondaryText", "#646464") default: return nativeListColor(theme, "primaryText", "#202020") } -@@ -2682,10 +3219,10 @@ final class NativeListCell: UICollectionViewCell { +@@ -2682,10 +3240,10 @@ final class NativeListCell: UICollectionViewCell { button.isHidden = false button.isEnabled = !data.bool("disabled") button.alpha = button.isEnabled ? 1 : 0.4 @@ -2640,7 +2712,7 @@ index e0bf60b..c53c679 100644 button.tintColor = tintColor if let image = nativeListIcon(named: data.string("name")) { button.setImage(image, for: .normal) -@@ -2695,7 +3232,12 @@ final class NativeListCell: UICollectionViewCell { +@@ -2695,7 +3253,12 @@ final class NativeListCell: UICollectionViewCell { ) } let isDrillIn = data.string("kind") == "chevron" @@ -2654,7 +2726,7 @@ index e0bf60b..c53c679 100644 if !isDrillIn, !data.string("actionKey").isEmpty { // Reproduce the trailing edge of IconButton's m=-7 while keeping its // full 36-point frame for padding/highlight behavior. -@@ -2726,8 +3268,15 @@ final class NativeListCell: UICollectionViewCell { +@@ -2726,8 +3289,15 @@ final class NativeListCell: UICollectionViewCell { into imageView: OneKeyImageReusableView, token: String, slot: Int, @@ -2671,7 +2743,7 @@ index e0bf60b..c53c679 100644 let headersJson: String? if let headers = source.dictionary("headers"), JSONSerialization.isValidJSONObject(headers), -@@ -2743,10 +3292,27 @@ final class NativeListCell: UICollectionViewCell { +@@ -2743,10 +3313,27 @@ final class NativeListCell: UICollectionViewCell { contentFit: source.string("contentFit", default: "cover"), cachePolicy: source.string("cachePolicy", default: "memory-disk"), autoplay: source.bool("autoplay"), @@ -2702,7 +2774,7 @@ index e0bf60b..c53c679 100644 ) } -@@ -2837,12 +3403,17 @@ final class NativeListCell: UICollectionViewCell { +@@ -2837,12 +3424,17 @@ final class NativeListCell: UICollectionViewCell { source: String, slot: Int? = nil ) -> NativeListActionOrigin { @@ -2722,7 +2794,7 @@ index e0bf60b..c53c679 100644 ) } -@@ -2851,6 +3422,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -2851,6 +3443,8 @@ final class NativeListCell: UICollectionViewCell { } private func invalidateCurrentBinding() { @@ -3307,6 +3379,128 @@ index 0000000..893a7a3 + clip-rule="evenodd" + /> + +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js +index a172bff..82ca339 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js +@@ -1,6 +1,8 @@ + "use strict"; + +-import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; ++import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; ++import { OneKeyImageCache, OneKeyImageCachePolicy } from '@onekeyfe/react-native-image'; ++import { NativeAvatarPrefetchModel, NativeAvatarPrefetchQueue } from "./avatarPrefetch.js"; + import { callback, getHostComponent } from 'react-native-nitro-modules'; + import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, resolveLocationIndex, scrollFailure, validateOffset } from "./scrolling.js"; + import { serializePatches, serializeSnapshot } from "./validation.js"; +@@ -10,7 +12,7 @@ const NativeListHost = getHostComponent('NativeList', () => NativeListConfig); + function parsePayload(payloadJson) { + return JSON.parse(payloadJson); + } +-export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ ++export const NativeList = forwardRef(function NativeList({ + snapshot, + webVirtualizationEnabled: _webVirtualizationEnabled, + onRowAction, +@@ -50,6 +52,46 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + }; + const snapshotRef = useRef(snapshot); + snapshotRef.current = snapshot; ++ // OneKey patch: range events already cross the bridge only when row indices ++ // change. Prefetch is optional work; scrolling and visible loads stay native. ++ const avatarQueueRef = useRef(null); ++ const avatarRangeRef = useRef(undefined); ++ // OneKey patch: a changed prop is a complete snapshot; unrelated renders must ++ // not erase image patches already dispatched through the imperative handle. ++ // const avatarPrefetchPaused = useRef(false); ++ const avatarModelRef = useRef(null); ++ const avatarPropSnapshotRef = useRef(snapshot); ++ const avatarLifecycle = useRef('pending'); ++ if (!avatarModelRef.current) avatarModelRef.current = new NativeAvatarPrefetchModel(snapshot.rows);else if (avatarPropSnapshotRef.current !== snapshot) avatarModelRef.current.replaceSnapshot(snapshot.rows); ++ avatarPropSnapshotRef.current = snapshot; ++ const updateAvatarPrefetch = () => { ++ const range = avatarRangeRef.current; ++ if (!range) return; ++ avatarQueueRef.current?.update(avatarModelRef.current?.window(range.first, range.last, range.direction) ?? []); ++ }; ++ const updateAvatarPrefetchRef = useRef(updateAvatarPrefetch); ++ updateAvatarPrefetchRef.current = updateAvatarPrefetch; ++ useEffect(() => { ++ avatarLifecycle.current = 'mounted'; ++ const queue = new NativeAvatarPrefetchQueue(source => OneKeyImageCache.preload([{ ++ uri: source.uri, ++ headers: source.headers, ++ resizeWidth: source.width, ++ resizeHeight: source.height, ++ optimizeTos: false, ++ cachePolicy: source.cachePolicy === 'memory' ? OneKeyImageCachePolicy.MEMORY : source.cachePolicy === 'disk' ? OneKeyImageCachePolicy.DISK : OneKeyImageCachePolicy.MEMORY_DISK ++ }])); ++ avatarQueueRef.current = queue; ++ updateAvatarPrefetchRef.current(); ++ return () => { ++ queue.dispose(); ++ avatarQueueRef.current = null; ++ avatarLifecycle.current = 'unmounted'; ++ }; ++ }, []); ++ useEffect(() => { ++ updateAvatarPrefetchRef.current(); ++ }, [snapshot]); + const initialScrollRef = useRef(initialScrollIndex === undefined && initialScrollKey === undefined ? undefined : initialScrollIndex !== undefined ? normalizeIndexScroll({ + index: initialScrollIndex, + animated: false, +@@ -90,10 +132,26 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + useImperativeHandle(forwardedRef, () => ({ + applySnapshot(nextSnapshot) { + snapshotRef.current = nextSnapshot; +- nativeRef.current?.applySnapshot(serializeSnapshot(nextSnapshot)); ++ const native = nativeRef.current; ++ if (native) { ++ native.applySnapshot(serializeSnapshot(nextSnapshot)); ++ if (avatarLifecycle.current !== 'unmounted') { ++ avatarModelRef.current?.replaceSnapshot(nextSnapshot.rows); ++ updateAvatarPrefetchRef.current(); ++ } ++ } + }, + applyPatches(patches) { +- if (patches.length > 0) nativeRef.current?.applyPatches(serializePatches(patches)); ++ // OneKey patch: image updates replace pending prefetch instead of disabling it. ++ // if (imageFieldsChanged) { avatarPrefetchPaused.current = true; avatarQueueRef.current?.update([]); } ++ if (!patches.length) return; ++ const native = nativeRef.current; ++ const dispatch = native ? () => native.applyPatches(serializePatches(patches)) : undefined; ++ if (avatarLifecycle.current === 'unmounted') { ++ dispatch?.(); ++ return; ++ } ++ if (avatarModelRef.current?.applyPatches(patches, dispatch)) updateAvatarPrefetchRef.current(); + }, + reconcileSelection(selectedKeys) { + nativeRef.current?.reconcileSelection(JSON.stringify(selectedKeys)); +@@ -171,7 +229,15 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + callbacksRef.current.onEndReached?.(parsePayload(payloadJson)); + }), + onVisibleRangeChanged: callback(payloadJson => { +- callbacksRef.current.onVisibleRangeChanged?.(parsePayload(payloadJson)); ++ const payload = parsePayload(payloadJson); ++ const previous = avatarRangeRef.current; ++ avatarRangeRef.current = { ++ first: payload.firstIndex, ++ last: payload.lastIndex, ++ direction: previous && payload.firstIndex !== previous.first ? Math.sign(payload.firstIndex - previous.first) : previous?.direction ?? 1 ++ }; ++ updateAvatarPrefetchRef.current(); ++ callbacksRef.current.onVisibleRangeChanged?.(payload); + }) + }), []); + return /*#__PURE__*/_jsx(NativeListHost, { +@@ -180,4 +246,3 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ + snapshotJson: snapshotJson + }); + }); +-//# sourceMappingURL=NativeList.js.map +\ No newline at end of file diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js index ce0b82d..438d88b 100644 --- a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js @@ -3376,6 +3570,183 @@ index ce0b82d..438d88b 100644 }); -//# sourceMappingURL=NativeList.web.js.map \ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js +new file mode 100644 +index 0000000..6501c8c +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js +@@ -0,0 +1,171 @@ ++"use strict"; ++ ++// OneKey patch: share a bounded URI-only window across renderers. No bitmap or ++// account-specific data enters list snapshots or the UI runtime. ++ ++const PREFIX = 'onekey-avatar://blockie/v1/'; ++const MAX_CANDIDATES = 64; ++const IMAGE_PATCH_FIELDS = ['leading', 'secondaryLeading', 'image', 'networkImage', 'thumbnail', 'visual']; ++export function avatarPrefetchWindow(rows, first, last, direction, resolveRow) { ++ if (first < 0 || last < first || first >= rows.length) return []; ++ last = Math.min(last, rows.length - 1); ++ const result = []; ++ const seen = new Set(); ++ let limit = MAX_CANDIDATES; ++ const add = (source, priority) => { ++ if (!source?.uri.startsWith(PREFIX) || source.cachePolicy === 'none' || seen.has(source.uri) || result.length >= limit) return; ++ seen.add(source.uri); ++ result.push({ ++ source, ++ priority ++ }); ++ }; ++ const visual = (value, priority) => { ++ if (!value) return; ++ if ('image' in value) add(value.image, priority); ++ if ('networkImage' in value) add(value.networkImage, priority); ++ if ('images' in value) value.images.forEach(image => add(image, priority)); ++ if ('overlays' in value) value.overlays?.forEach(overlay => add(overlay.image, priority)); ++ }; ++ const row = (value, priority) => { ++ value = resolveRow?.(value) ?? value; ++ if ('leading' in value) visual(value.leading, priority); ++ if ('secondaryLeading' in value) visual(value.secondaryLeading, priority); ++ if ('image' in value) add(value.image, priority); ++ if ('networkImage' in value) add(value.networkImage, priority); ++ if ('thumbnail' in value) add(value.thumbnail, priority); ++ if (value.type === 'walletGroup') { ++ visual(value.parent.leading, priority); ++ for (const child of value.children.slice(0, MAX_CANDIDATES)) { ++ if (result.length >= limit) break; ++ visual(child.leading, priority); ++ } ++ } ++ }; ++ // Bound row examination too: a giant non-avatar group must not scan all data. ++ for (let index = first; index <= last && index < first + 64; index += 1) row(rows[index], 0); ++ const span = Math.min(24, (last - first + 1) * 2); ++ const step = direction < 0 ? -1 : 1; ++ const aheadStart = step > 0 ? last + 1 : first - 1; ++ const aheadLimit = Math.min(MAX_CANDIDATES, result.length + 24); ++ limit = aheadLimit; ++ for (let distance = 0; distance < span && result.length < aheadLimit; distance += 1) { ++ const index = aheadStart + distance * step; ++ if (index < 0 || index >= rows.length) break; ++ row(rows[index], 1); ++ } ++ const behindStart = step > 0 ? first - 1 : last + 1; ++ const behindLimit = Math.min(MAX_CANDIDATES, result.length + 8); ++ limit = behindLimit; ++ for (let distance = 0; distance < Math.min(8, span) && result.length < behindLimit; distance += 1) { ++ const index = behindStart - distance * step; ++ if (index < 0 || index >= rows.length) break; ++ row(rows[index], 2); ++ } ++ return result; ++} ++ ++// OneKey patch: imperative image patches update a sparse prefetch overlay, not ++// the readonly prop or a full cloned snapshot. The key index is rebuilt only for ++// a complete snapshot; ordinary balance patches retain no extra row copies. ++export class NativeAvatarPrefetchModel { ++ imageRows = new Map(); ++ constructor(rows) { ++ this.rows = rows; ++ this.rowByKey = new Map(rows.map(row => [row.key, row])); ++ } ++ replaceSnapshot(rows) { ++ this.rows = rows; ++ this.rowByKey = new Map(rows.map(row => [row.key, row])); ++ this.imageRows.clear(); ++ } ++ applyPatches(patches, dispatch) { ++ // Native methods have no acknowledgement. Mirror only dispatched batches; ++ // a missing host or a synchronous serialization/dispatch failure changes nothing. ++ if (!dispatch) return false; ++ dispatch(); ++ const seen = new Set(); ++ for (const patch of patches) { ++ const row = this.rowByKey.get(patch.key); ++ // Both native renderers reject the whole batch for an unknown key/type. ++ if (!row || row.type !== patch.type || seen.has(patch.key)) return false; ++ seen.add(patch.key); ++ } ++ let changed = false; ++ for (const patch of patches) { ++ const changes = patch.changes; ++ let imageChanges; ++ for (const key of IMAGE_PATCH_FIELDS) { ++ if (changes[key] !== undefined) { ++ imageChanges ??= {}; ++ imageChanges[key] = changes[key]; ++ } ++ } ++ // JSON.stringify omits undefined top-level fields. Native therefore keeps ++ // them unchanged; an explicit replacement visual without image clears it. ++ if (!imageChanges) continue; ++ const row = this.imageRows.get(patch.key) ?? this.rowByKey.get(patch.key); ++ // Preserve the validated row discriminant, matching applyRowPatches' shallow merge. ++ this.imageRows.set(patch.key, { ++ ...row, ++ ...imageChanges, ++ key: row.key, ++ type: row.type ++ }); ++ changed = true; ++ } ++ return changed; ++ } ++ window(first, last, direction) { ++ return avatarPrefetchWindow(this.rows, first, last, direction, row => this.imageRows.get(row.key) ?? row); ++ } ++} ++ ++// Native preload has no cancellation token. One in-flight source is the bound; ++// direction changes replace all not-yet-started work rather than appending it. ++export class NativeAvatarPrefetchQueue { ++ pending = []; ++ disposed = false; ++ recent = new Map(); ++ constructor(preload) { ++ this.preload = preload; ++ } ++ update(candidates) { ++ if (this.disposed) return; ++ const now = Date.now(); ++ this.pending = candidates.filter(({ ++ source, ++ priority ++ }) => priority !== 0 && source.uri !== this.activeUri && now - (this.recent.get(source.uri) ?? 0) > 30_000).map(({ ++ source ++ }) => source); ++ this.schedule(); ++ } ++ dispose() { ++ this.disposed = true; ++ this.pending = []; ++ this.recent.clear(); ++ if (this.timer !== undefined) clearTimeout(this.timer); ++ this.timer = undefined; ++ } ++ schedule() { ++ if (this.disposed || this.activeUri || this.timer !== undefined || !this.pending.length) return; ++ // Yield between short batches without imposing a frame-per-image throughput cap. ++ this.timer = setTimeout(() => { ++ this.timer = undefined; ++ const source = this.pending.shift(); ++ if (!source || this.disposed) return; ++ this.activeUri = source.uri; ++ Promise.resolve().then(() => this.preload(source)).then(success => { ++ if (success && !this.disposed) { ++ this.recent.delete(source.uri); ++ this.recent.set(source.uri, Date.now()); ++ if (this.recent.size > 128) this.recent.delete(this.recent.keys().next().value); ++ } ++ }).catch(() => {/* Visible loading retains its own failure/retry behavior. */}).finally(() => { ++ this.activeUri = undefined; ++ this.schedule(); ++ }); ++ }, 0); ++ } ++} diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js index ef38e33..eabec38 100644 --- a/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js @@ -3481,10 +3852,10 @@ index f9ed44b..eae597c 100644 \ No newline at end of file diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js new file mode 100644 -index 0000000..fff4081 +index 0000000..5c27e23 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js -@@ -0,0 +1,258 @@ +@@ -0,0 +1,443 @@ +// OneKey patch: avatar generation and PNG bytes stay in this external worker. +// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): +// https://github.com/MyCryptoHQ/ethereum-blockies-base64 @@ -3515,6 +3886,128 @@ index 0000000..fff4081 +const queue = []; +let activeLoads = 0; +let databasePromise; ++// OneKey patch: persistence never occupies either display-load slot. Pending bytes ++// bridge the last-lease/reacquire window until a bounded background write finishes. ++const MAX_PENDING_WRITES = 32; ++const MAX_PENDING_BYTES = 4 * 1024 * 1024; ++const WRITE_DEADLINE_MS = 32; ++const READ_BUDGET_MS = 8; ++const MAX_PENDING_READS = 2; ++const DISK_IDLE_MS = 100; ++const pendingBlobs = new Map(); ++const diskQueue = []; ++const touches = new Map(); ++const priorities = new Map(); ++let pendingBytes = 0; ++let writing = false; ++let diskTimer; ++let lastAcquire = 0; ++let pendingReads = 0; ++ ++function diskIsIdle() { ++ return !activeLoads && !queue.length && !pendingReads && Date.now() - lastAcquire >= DISK_IDLE_MS; ++} ++ ++function scheduleDiskWork() { ++ if (writing || diskTimer !== undefined || activeLoads || queue.length || pendingReads || (!diskQueue.length && !touches.size)) return; ++ const delay = Math.max(0, DISK_IDLE_MS - (Date.now() - lastAcquire)); ++ diskTimer = setTimeout(() => { diskTimer = undefined; void drainDiskQueue(); }, delay); ++} ++ ++function persistLater(uri, blob) { ++ if (pendingBlobs.has(uri) || blob.size > MAX_PENDING_BYTES) return; ++ // OneKey patch: a long scroll retains the most recent bounded write window, ++ // rather than filling it once and dropping every later result. Never evict I/O in flight. ++ // if (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) return; ++ while (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) { ++ const oldest = diskQueue.shift(); ++ if (!oldest) return; ++ if (pendingBlobs.get(oldest.uri) === oldest.blob) pendingBlobs.delete(oldest.uri); ++ pendingBytes -= oldest.blob.size; ++ } ++ pendingBlobs.set(uri, blob); ++ pendingBytes += blob.size; ++ diskQueue.push({ uri, blob }); ++ scheduleDiskWork(); ++} ++ ++async function drainDiskQueue() { ++ // OneKey patch: a momentary empty load queue is not a scroll-idle window. ++ // if (writing || activeLoads || queue.length) return; ++ if (writing) return; ++ if (!diskIsIdle()) { scheduleDiskWork(); return; } ++ writing = true; ++ try { ++ if (diskQueue.length) { ++ const { uri, blob } = diskQueue.shift(); ++ let result; ++ try { result = await writeDisk(uri, blob); } catch { /* Persistence remains best-effort. */ } ++ if (result === 'deferred') { ++ diskQueue.unshift({ uri, blob }); ++ } else { ++ if (pendingBlobs.get(uri) === blob) pendingBlobs.delete(uri); ++ pendingBytes -= blob.size; ++ const image = images.get(uri); ++ if (result === true && image?.blob === blob) image.persisted = true; ++ } ++ } else if (touches.size) { ++ await flushTouches(); ++ } ++ } finally { ++ writing = false; ++ scheduleDiskWork(); ++ } ++} ++ ++// OneKey patch: abort is a request, not a bounded browser store-lock release. ++// Display reads have their own budget below, even if this transaction is committing. ++function cacheWriteDeadline(transaction, resolve) { ++ let timer; ++ const check = () => { ++ if (!activeLoads && !queue.length && Date.now() - lastAcquire >= DISK_IDLE_MS) { timer = setTimeout(check, WRITE_DEADLINE_MS); return; } ++ try { transaction.abort(); } catch { /* The browser already started committing. */ } ++ }; ++ timer = setTimeout(check, WRITE_DEADLINE_MS); ++ const finish = (success) => { clearTimeout(timer); resolve(success); }; ++ transaction.oncomplete = () => finish(true); ++ transaction.onabort = () => finish(false); ++} ++ ++function touchLater(uri) { ++ touches.delete(uri); ++ touches.set(uri, Date.now()); ++ if (touches.size > 128) touches.delete(touches.keys().next().value); ++ // OneKey patch: touches share the same idle gate and writer as persistence. ++ // if (touchTimer === undefined) touchTimer = setTimeout(flushTouches, 250); ++ scheduleDiskWork(); ++} ++ ++async function flushTouches() { ++ const database = await openDatabase(); ++ // Opening the database can yield across a new acquire; recheck before starting I/O. ++ if (!diskIsIdle()) return; ++ const batch = [...touches]; ++ touches.clear(); ++ if (database && batch.length) await new Promise((resolve) => { ++ try { ++ const transaction = database.transaction('images', 'readwrite'); ++ cacheWriteDeadline(transaction, resolve); ++ const store = transaction.objectStore('images'); ++ batch.forEach(([uri, accessed]) => { ++ const request = store.get(uri); ++ request.onsuccess = () => { ++ if (request.result) store.put({ ...request.result, accessed: Math.max(request.result.accessed || 0, accessed) }); ++ }; ++ }); ++ } catch { resolve(false); } ++ }); ++} ++ ++function jobPriority(job) { ++ let priority = 2; ++ job.ids.forEach((id) => { priority = Math.min(priority, priorities.get(id) ?? 2); }); ++ return priority; ++} + +function openDatabase() { + if (!databasePromise) { @@ -3543,33 +4036,71 @@ index 0000000..fff4081 +} + +async function readDisk(uri) { -+ const database = await openDatabase(); -+ if (!database) return undefined; ++ // OneKey patch: the cache is optional. A slow read must not retain a display ++ // load slot while abort/commit waits on browser I/O. Keep unfinished reads ++ // separately bounded, including an unresolved shared database open. ++ if (pendingReads >= MAX_PENDING_READS) return undefined; ++ pendingReads += 1; + return new Promise((resolve) => { -+ try { -+ const transaction = database.transaction('images', 'readwrite'); -+ const store = transaction.objectStore('images'); -+ const request = store.get(uri); -+ let blob; -+ request.onsuccess = () => { -+ const record = request.result; -+ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) { -+ blob = record.blob; -+ store.put({ ...record, accessed: Date.now() }); -+ } -+ }; -+ transaction.oncomplete = () => resolve(blob); -+ transaction.onerror = transaction.onabort = () => resolve(undefined); -+ } catch { resolve(undefined); } ++ let transaction; ++ let blob; ++ let settled = false; ++ let finished = false; ++ let abortRequested = false; ++ const finish = (success) => { ++ if (finished) return; ++ finished = true; ++ pendingReads -= 1; ++ clearTimeout(timer); ++ if (!settled) { ++ settled = true; ++ if (success && blob) touchLater(uri); ++ resolve(success ? blob : undefined); ++ } ++ scheduleDiskWork(); ++ }; ++ const timeout = () => { ++ if (finished) return; ++ if (!settled) { settled = true; resolve(undefined); } ++ // Do not release pendingReads here: an aborted IDB transaction can still ++ // hold its store lock for hundreds of milliseconds before onabort arrives. ++ if (transaction && !abortRequested) { ++ abortRequested = true; ++ try { transaction.abort(); } catch { /* Already committing; ignore late callbacks. */ } ++ } ++ }; ++ const timer = setTimeout(timeout, READ_BUDGET_MS); ++ Promise.resolve().then(openDatabase).then((database) => { ++ if (settled || !database) { finish(false); return; } ++ try { ++ transaction = database.transaction('images', 'readonly'); ++ transaction.oncomplete = () => finish(true); ++ transaction.onabort = () => finish(false); ++ transaction.onerror = timeout; ++ const request = transaction.objectStore('images').get(uri); ++ request.onsuccess = () => { ++ if (settled) return; ++ const record = request.result; ++ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) blob = record.blob; ++ // A completed readonly get can serve display before the transaction ends. ++ // Its physical permit still belongs to oncomplete/onabort. ++ settled = true; ++ if (blob) touchLater(uri); ++ resolve(blob); ++ }; ++ } catch { if (transaction) timeout(); else finish(false); } ++ }).catch(() => finish(false)); + }); +} + +async function writeDisk(uri, blob) { + const database = await openDatabase(); -+ if (!database || blob.size > MAX_DISK_BYTES) return; -+ await new Promise((resolve) => { ++ if (!database || blob.size > MAX_DISK_BYTES) return false; ++ if (!diskIsIdle()) return 'deferred'; ++ return new Promise((resolve) => { + try { + const transaction = database.transaction(['images', 'metadata'], 'readwrite'); ++ cacheWriteDeadline(transaction, resolve); + const store = transaction.objectStore('images'); + const metadata = transaction.objectStore('metadata'); + const previous = store.get(uri); @@ -3595,7 +4126,8 @@ index 0000000..fff4081 + }; + }; + }; -+ transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); ++ // OneKey patch: completion/abort clears the deadline before releasing pending bytes. ++ // transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); + } catch { resolve(); } + }); +} @@ -3658,6 +4190,7 @@ index 0000000..fff4081 +function release(id) { + const uri = requests.get(id); + requests.delete(id); ++ priorities.delete(id); + const image = images.get(uri); + image?.references.delete(id); + if (image && image.references.size === 0) { @@ -3674,8 +4207,12 @@ index 0000000..fff4081 +} + +async function runJob(job) { -+ let blob = await readDisk(job.uri); -+ if (blob) { ++ // OneKey patch: reuse generated bytes even if the last UI lease was released ++ // while persistence is still in flight; these bytes already passed generation. ++ const pending = pendingBlobs.get(job.uri); ++ let blob = pending ?? await readDisk(job.uri); ++ let persisted = !!blob && !pending; ++ if (blob && !pending) { + try { + const bitmap = await createImageBitmap(blob); + const valid = bitmap.width === 128 && bitmap.height === 128; @@ -3684,11 +4221,14 @@ index 0000000..fff4081 + } catch { blob = undefined; } + } + if (!blob && job.ids.size) { ++ persisted = false; + blob = await generateBlob(job.seed); -+ await writeDisk(job.uri, blob); ++ // OneKey patch: notify clients and free the load slot without awaiting I/O. ++ // await writeDisk(job.uri, blob); ++ persistLater(job.uri, blob); + } + if (!blob || !job.ids.size) return; -+ const image = { url: URL.createObjectURL(blob), references: new Set() }; ++ const image = { url: URL.createObjectURL(blob), blob, persisted, references: new Set() }; + images.set(job.uri, image); + job.ids.forEach((id) => { + if (requests.get(id) !== job.uri) return; @@ -3700,7 +4240,13 @@ index 0000000..fff4081 + +function pump() { + while (activeLoads < MAX_CONCURRENT_LOADS && queue.length) { -+ const job = queue.shift(); ++ // OneKey patch: queued leases can be promoted without restarting their load. ++ // const job = queue.shift(); ++ let next = 0; ++ for (let index = 1; index < queue.length; index += 1) { ++ if (jobPriority(queue[index]) < jobPriority(queue[next])) next = index; ++ } ++ const [job] = queue.splice(next, 1); + if (jobs.get(job.uri) !== job || !job.ids.size) continue; + job.active = true; + activeLoads += 1; @@ -3712,6 +4258,7 @@ index 0000000..fff4081 + if (jobs.get(job.uri) === job) jobs.delete(job.uri); + activeLoads -= 1; + pump(); ++ void drainDiskQueue(); + }); + } +} @@ -3719,16 +4266,25 @@ index 0000000..fff4081 +onmessage = ({ data }) => { + if (!data || !Number.isSafeInteger(data.id)) return; + if (data.type === 'release') { release(data.id); return; } ++ if (data.type === 'priority') { ++ if (requests.has(data.id)) priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); ++ return; ++ } + if (data.type !== 'acquire') return; + release(data.id); ++ lastAcquire = Date.now(); ++ scheduleDiskWork(); + try { + if (typeof data.uri !== 'string' || !data.uri.startsWith(PREFIX)) throw new Error('Invalid avatar URI'); + const seed = decodeURIComponent(data.uri.slice(PREFIX.length)).toLowerCase(); + if (!seed) throw new Error('Empty avatar seed'); + const uri = PREFIX + encodeURIComponent(seed); + requests.set(data.id, uri); ++ priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); + const image = images.get(uri); + if (image) { ++ // Reacquiring a live Blob can retry best-effort persistence after queue pressure. ++ if (!image.persisted) persistLater(uri, image.blob); + image.references.add(data.id); + postMessage({ type: 'resolved', id: data.id, url: image.url }); + return; @@ -3745,10 +4301,10 @@ index 0000000..fff4081 +}; diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js new file mode 100644 -index 0000000..bbcc33a +index 0000000..3e1fa51 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js -@@ -0,0 +1,137 @@ +@@ -0,0 +1,162 @@ +"use strict"; + +// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. @@ -3767,7 +4323,7 @@ index 0000000..bbcc33a + entries = new Map(); + requests = new Map(); + nextId = 0; -+ acquire(uri, resolve, reject) { ++ acquire(uri, resolve, reject, priority = 2) { + let entry = this.entries.get(uri); + const isNew = !entry; + if (!entry) { @@ -3786,9 +4342,13 @@ index 0000000..bbcc33a + current.references += 1; + const listener = { + resolve, -+ reject ++ reject, ++ priority + }; -+ if (current.url) resolve(current.url);else current.listeners.add(listener); ++ if (current.url) resolve(current.url); ++ // OneKey patch: keep lease priority after resolution as well as while queued. ++ // else current.listeners.add(listener); ++ current.listeners.add(listener); + if (isNew) { + try { + if (!this.worker) { @@ -3803,15 +4363,26 @@ index 0000000..bbcc33a + this.worker.postMessage({ + type: 'acquire', + id: current.id, -+ uri ++ uri, ++ priority + }); + } catch { + queueMicrotask(this.handleFailure); + } + } ++ const updatePriority = () => { ++ if (current.url) return; ++ const priorities = [...current.listeners].map(value => value.priority); ++ this.worker?.postMessage({ ++ type: 'priority', ++ id: current.id, ++ priority: Math.min(2, ...priorities) ++ }); ++ }; ++ updatePriority(); + this.trim(); + let disposed = false; -+ return () => { ++ const release = () => { + if (disposed) return; + disposed = true; + current.listeners.delete(listener); @@ -3824,8 +4395,16 @@ index 0000000..bbcc33a + id: current.id + }); + } ++ updatePriority(); + this.trim(); + }; ++ return Object.assign(release, { ++ setPriority: next => { ++ if (disposed || listener.priority === next) return; ++ listener.priority = next; ++ updatePriority(); ++ } ++ }); + } + handleMessage = event => { + const response = event.data; @@ -3850,14 +4429,16 @@ index 0000000..bbcc33a + }); + entry.listeners.forEach(listener => listener.reject()); + } -+ entry.listeners.clear(); ++ // OneKey patch: retain resolved lease priorities until their explicit release. ++ // entry.listeners.clear(); ++ if (!entry.url) entry.listeners.clear(); + this.trim(); + }; + handleFailure = () => { + this.worker?.terminate(); + this.worker = undefined; + this.requests.forEach(entry => { -+ entry.listeners.forEach(listener => listener.reject()); ++ if (!entry.url) entry.listeners.forEach(listener => listener.reject()); + entry.listeners.clear(); + }); + this.requests.clear(); @@ -3878,27 +4459,28 @@ index 0000000..bbcc33a + } +} +const documentCaches = new WeakMap(); -+export function acquireNativeListAvatar(document, uri, resolve, reject) { ++export function acquireNativeListAvatar(document, uri, resolve, reject, priority = 2) { + let cache = documentCaches.get(document); + if (!cache) { + cache = new NativeListWebAvatarCache(); + documentCaches.set(document, cache); + } -+ return cache.acquire(uri, resolve, reject); ++ return cache.acquire(uri, resolve, reject, priority); +} diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -index 625a913..c1d40b4 100644 +index 625a913..0e702f6 100644 --- a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js +++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -@@ -3,6 +3,7 @@ +@@ -3,6 +3,8 @@ import { checkboxStateForKeys, checkboxStateForSection, isSelectableRow, reduceSelection, selectionStateFromSnapshot } from "../selection.js"; import { calculateAlignedScrollOffset, resolveLocationIndex, scrollFailure, validateOffset } from "../scrolling.js"; import { applyRowPatches, validateSnapshot } from "../validation.js"; ++import { avatarPrefetchWindow } from "../avatarPrefetch.js"; +import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from "./NativeListWebAvatarCache.js"; const SECTION_INDEX_GUTTER = 44; const DEFAULT_VIEWPORT_WIDTH = 320; const DEFAULT_VIEWPORT_HEIGHT = 640; -@@ -147,9 +148,22 @@ function approximateMessageHeight(row, availableWidth) { +@@ -147,9 +149,22 @@ function approximateMessageHeight(row, availableWidth) { return 32 + titleLines * 20 + bodyLines * 20 + 22; } export function estimateWebRowHeight(row, snapshot, availableWidth) { @@ -3924,7 +4506,7 @@ index 625a913..c1d40b4 100644 if (row.type === 'identity' && row.presentation === 'networkSelector') return 47; if (row.type === 'identity' && row.presentation === 'accountSelector') return 58; let base; -@@ -371,7 +385,10 @@ function rowWithoutSelectionState(row) { +@@ -371,7 +386,10 @@ function rowWithoutSelectionState(row) { export function webRowRenderSignature(row) { return JSON.stringify(rowWithoutSelectionState(row)); } @@ -3935,7 +4517,7 @@ index 625a913..c1d40b4 100644 .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -426,7 +443,27 @@ export const WEB_LIST_CSS = ` +@@ -426,7 +444,27 @@ export const WEB_LIST_CSS = ` .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} @@ -3963,7 +4545,7 @@ index 625a913..c1d40b4 100644 `; function createElement(document, tag, className, text) { const element = document.createElement(tag); -@@ -442,12 +479,105 @@ function safeImageUri(uri) { +@@ -442,12 +480,105 @@ function safeImageUri(uri) { if (/^(https?:|data:image\/|blob:|file:)/i.test(trimmed) || trimmed.startsWith('/')) return trimmed; return undefined; } @@ -4071,7 +4653,7 @@ index 625a913..c1d40b4 100644 image.alt = ''; image.draggable = false; image.loading = 'lazy'; -@@ -455,6 +585,247 @@ function createImage(context, source, className) { +@@ -455,6 +586,247 @@ function createImage(context, source, className) { image.style.objectFit = source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover'; return image; } @@ -4319,7 +4901,7 @@ index 625a913..c1d40b4 100644 function iconGlyph(name) { const normalized = name.toLocaleLowerCase(); if (normalized.includes('chevron')) { -@@ -484,7 +855,7 @@ function visualFromRow(row) { +@@ -484,7 +856,7 @@ function visualFromRow(row) { if (row.type === 'metricCard') return row.visual; return undefined; } @@ -4328,7 +4910,7 @@ index 625a913..c1d40b4 100644 if (!visual) return undefined; if (visual.kind === 'stackedImages') { const stack = createElement(context.document, 'span', 'ok-native-list-stacked'); -@@ -500,6 +871,7 @@ function createVisual(context, visual) { +@@ -500,6 +872,7 @@ function createVisual(context, visual) { if (visual.kind === 'icon') { frame.style.background = visual.backgroundColor ?? 'var(--nl-strong)'; const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback', iconGlyph(visual.name)); @@ -4336,7 +4918,7 @@ index 625a913..c1d40b4 100644 if (visual.tintColor) fallback.style.color = visual.tintColor; frame.appendChild(fallback); return frame; -@@ -510,6 +882,7 @@ function createVisual(context, visual) { +@@ -510,6 +883,7 @@ function createVisual(context, visual) { if (image) { image.className = 'ok-native-list-visual-main'; frame.appendChild(image); @@ -4344,7 +4926,7 @@ index 625a913..c1d40b4 100644 } else { frame.appendChild(createElement(context.document, 'span', 'ok-native-list-visual-fallback', 'fallbackText' in visual ? visual.fallbackText ?? '' : '')); } -@@ -520,8 +893,64 @@ function createVisual(context, visual) { +@@ -520,8 +894,64 @@ function createVisual(context, visual) { const corner = createElement(context.document, 'span', 'ok-native-list-visual-corner ok-native-list-visual-fallback', iconGlyph(visual.cornerIcon.name)); if (visual.cornerIcon.tintColor) corner.style.color = visual.cornerIcon.tintColor; if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; @@ -4409,7 +4991,7 @@ index 625a913..c1d40b4 100644 return frame; } function toneColor(tone, fallback) { -@@ -567,6 +996,19 @@ function createCheckbox(context, rowKey, accessory) { +@@ -567,6 +997,19 @@ function createCheckbox(context, rowKey, accessory) { setData(element, 'checkboxFallback', accessory.state); setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); @@ -4429,7 +5011,7 @@ index 625a913..c1d40b4 100644 if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey);else if (accessory.target?.scope === 'row') setData(element, 'selectionKey', rowKey); return element; } -@@ -576,6 +1018,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { +@@ -576,6 +1019,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { element.setAttribute('type', 'button'); element.toggleAttribute('disabled', Boolean(disabled)); } @@ -4437,7 +5019,7 @@ index 625a913..c1d40b4 100644 if (actionKey) setData(element, 'nativeListAction', actionKey); if (tintColor) element.style.color = tintColor; return element; -@@ -584,6 +1027,23 @@ function markActionAnchorSource(element, source, slot) { +@@ -584,6 +1028,23 @@ function markActionAnchorSource(element, source, slot) { setData(element, 'nativeListAnchorSource', source); if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } @@ -4461,7 +5043,7 @@ index 625a913..c1d40b4 100644 function createAccessory(context, rowKey, accessory, slot) { if (accessory.kind === 'checkbox') { const element = createCheckbox(context, rowKey, accessory); -@@ -593,6 +1053,18 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -593,6 +1054,18 @@ function createAccessory(context, rowKey, accessory, slot) { if (accessory.kind === 'icon') { const element = createIconAction(context, accessory.name, accessory.actionKey, accessory.disabled, accessory.tintColor); markActionAnchorSource(element, 'trailingAccessory', slot); @@ -4480,7 +5062,7 @@ index 625a913..c1d40b4 100644 return element; } if (accessory.kind === 'spinner') { -@@ -608,6 +1080,7 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -608,6 +1081,7 @@ function createAccessory(context, rowKey, accessory, slot) { switch (accessory.kind) { case 'value': element.textContent = accessory.text; @@ -4488,7 +5070,7 @@ index 625a913..c1d40b4 100644 if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); break; case 'valuePair': -@@ -646,6 +1119,13 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -646,6 +1120,13 @@ function createAccessory(context, rowKey, accessory, slot) { function appendAccessories(parent, context, rowKey, accessories) { if (!accessories?.length) return; const container = createElement(context.document, 'span', 'ok-native-list-accessories'); @@ -4502,7 +5084,7 @@ index 625a913..c1d40b4 100644 accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot))); parent.appendChild(container); } -@@ -673,9 +1153,83 @@ function createSectionHeader(context, row) { +@@ -673,9 +1154,83 @@ function createSectionHeader(context, row) { markActionAnchorSource(titleIcon, 'leadingAction'); body.appendChild(titleIcon); } @@ -4587,7 +5169,7 @@ index 625a913..c1d40b4 100644 if (row.valueActionKey) { value.setAttribute('type', 'button'); setData(value, 'nativeListAction', row.valueActionKey); -@@ -696,6 +1250,7 @@ function createActionRow(context, row) { +@@ -696,6 +1251,7 @@ function createActionRow(context, row) { if (row.icon) body.appendChild(createVisual(context, row.icon)); const title = createElement(context.document, 'span', 'ok-native-list-action-title', row.title); setData(title, 'tone', row.tone); @@ -4595,7 +5177,7 @@ index 625a913..c1d40b4 100644 body.appendChild(title); if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); appendAccessories(body, context, row.key, row.trailing); -@@ -704,6 +1259,13 @@ function createActionRow(context, row) { +@@ -704,6 +1260,13 @@ function createActionRow(context, row) { function createSystemRow(context, row) { const body = createElement(context.document, 'div', 'ok-native-list-row ok-native-list-system'); setData(body, 'variant', row.variant); @@ -4609,7 +5191,7 @@ index 625a913..c1d40b4 100644 if (row.variant === 'loading') body.appendChild(createElement(context.document, 'span', 'ok-native-list-spinner')); const message = row.variant === 'spacer' ? '' : row.message ?? (row.variant === 'end' ? 'End' : ''); if (message) body.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', message)); -@@ -852,12 +1414,31 @@ function createDataRow(context, row) { +@@ -852,12 +1415,31 @@ function createDataRow(context, row) { function createIdentityActivityOrMessageRow(context, row) { const presentation = row.type === 'identity' ? row.presentation : undefined; const body = createElement(context.document, 'div', ['ok-native-list-row', 'ok-native-list-standard', row.type === 'identity' && !presentation ? 'ok-native-list-identity-row' : '', presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', presentation === 'accountSelector' ? 'ok-native-list-account-row' : ''].filter(Boolean).join(' ')); @@ -4642,7 +5224,7 @@ index 625a913..c1d40b4 100644 if (visual) body.appendChild(visual); if (row.type === 'activity' && row.secondaryLeading) { const secondVisual = createVisual(context, row.secondaryLeading); -@@ -866,7 +1447,46 @@ function createIdentityActivityOrMessageRow(context, row) { +@@ -866,7 +1448,46 @@ function createIdentityActivityOrMessageRow(context, row) { if (row.type === 'message' && row.unread) body.appendChild(createElement(context.document, 'span', 'ok-native-list-unread')); const title = row.title; const subtitle = row.type === 'identity' ? row.subtitle : row.type === 'activity' ? row.description : row.body; @@ -4690,7 +5272,7 @@ index 625a913..c1d40b4 100644 if (row.type === 'activity' && row.status) column.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', row.status)); if (row.type === 'activity' && row.footerActions?.length) { const actions = createElement(context.document, 'span', 'ok-native-list-actions'); -@@ -898,14 +1518,32 @@ function createIdentityActivityOrMessageRow(context, row) { +@@ -898,14 +1519,32 @@ function createIdentityActivityOrMessageRow(context, row) { } return body; } @@ -4724,7 +5306,18 @@ index 625a913..c1d40b4 100644 body.appendChild(memberElement); }); return body; -@@ -953,6 +1591,8 @@ export class NativeListWebEngine { +@@ -945,6 +1584,10 @@ export class NativeListWebEngine { + }; + mounted = new Map(); + pool = []; ++ // OneKey patch: URI leases outlive DOM overscan only inside this bounded window. ++ avatarLeases = new Map(); ++ avatarOffset = 0; ++ avatarDirection = 1; + suppressClickUntil = 0; + pullDistance = 0; + actionAnchorInstanceId = String(++webNativeListInstanceCounter); +@@ -953,6 +1596,8 @@ export class NativeListWebEngine { lastViewportWidth = -1; lastViewportHeight = -1; destroyed = false; @@ -4733,7 +5326,7 @@ index 625a913..c1d40b4 100644 constructor(host, snapshot, callbacks, virtualizationEnabled = true) { this.document = host.ownerDocument; this.snapshot = validateSnapshot(snapshot); -@@ -991,6 +1631,9 @@ export class NativeListWebEngine { +@@ -991,6 +1636,9 @@ export class NativeListWebEngine { passive: true }); this.root.addEventListener('click', this.handleClick); @@ -4743,7 +5336,7 @@ index 625a913..c1d40b4 100644 this.root.addEventListener('keydown', this.handleKeyDown); this.viewport.addEventListener('pointerdown', this.handleReorderPointerDown, { passive: false -@@ -1125,7 +1768,7 @@ export class NativeListWebEngine { +@@ -1125,7 +1773,7 @@ export class NativeListWebEngine { scrollToLocation(params, scroll) { const index = resolveLocationIndex(this.snapshot.rows, params); if (index === undefined) { @@ -4752,7 +5345,7 @@ index 625a913..c1d40b4 100644 this.emitScrollFailure(params.itemIndex, params.sectionIndex >= sectionCount ? 'section-out-of-range' : 'item-out-of-range'); return; } -@@ -1172,6 +1815,8 @@ export class NativeListWebEngine { +@@ -1172,6 +1820,8 @@ export class NativeListWebEngine { this.document.defaultView?.removeEventListener('resize', this.handleWindowResize); this.viewport.removeEventListener('scroll', this.handleScroll); this.root.removeEventListener('click', this.handleClick); @@ -4761,7 +5354,7 @@ index 625a913..c1d40b4 100644 this.root.removeEventListener('keydown', this.handleKeyDown); this.cancelPointerReorder(true); this.viewport.removeEventListener('pointerdown', this.handleReorderPointerDown); -@@ -1189,6 +1834,8 @@ export class NativeListWebEngine { +@@ -1189,14 +1839,19 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointerup', this.handlePullEnd); this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; @@ -4770,15 +5363,18 @@ index 625a913..c1d40b4 100644 this.root.remove(); this.hideReorderPreview(); this.reorderPreview.remove(); -@@ -1197,6 +1844,7 @@ export class NativeListWebEngine { + if (host) host.style.position = this.previousHostPosition; + this.mounted.clear(); this.pool.length = 0; ++ this.avatarLeases.forEach(release => release()); ++ this.avatarLeases.clear(); } setSnapshot(snapshot, selectedKeys) { + this.measuredWarningHeights.clear(); this.snapshot = snapshot; this.rows = effectiveRows(snapshot); this.selectedKeys = selectedKeys ?? selectionStateFromSnapshot(snapshot).selectedKeys; -@@ -1219,6 +1867,11 @@ export class NativeListWebEngine { +@@ -1219,6 +1874,11 @@ export class NativeListWebEngine { '--nl-primary': theme.primaryText, '--nl-secondary': theme.secondaryText, '--nl-disabled': theme.disabledText, @@ -4790,7 +5386,7 @@ index 625a913..c1d40b4 100644 '--nl-icon': theme.icon, '--nl-icon-subdued': theme.iconSubdued, '--nl-separator': theme.separator, -@@ -1242,11 +1895,19 @@ export class NativeListWebEngine { +@@ -1242,11 +1902,19 @@ export class NativeListWebEngine { const viewportHeight = this.viewport.clientHeight; if (this.lastViewportWidth >= 0 && (viewportWidth !== this.lastViewportWidth || viewportHeight !== this.lastViewportHeight)) { this.invalidateActionAnchor('layout'); @@ -4811,7 +5407,52 @@ index 625a913..c1d40b4 100644 this.content.style.width = String(this.layout.contentWidth) + 'px'; this.content.style.height = String(this.layout.contentHeight) + 'px'; if (previousHorizontal !== this.layout.horizontal) { -@@ -1264,6 +1925,7 @@ export class NativeListWebEngine { +@@ -1256,7 +1924,44 @@ export class NativeListWebEngine { + this.renderWindow(); + this.performPendingScroll(); + }; ++ updateAvatarWindow() { ++ const offset = this.currentOffset(); ++ if (offset !== this.avatarOffset) this.avatarDirection = Math.sign(offset - this.avatarOffset); ++ this.avatarOffset = offset; ++ const visible = visibleWebLayoutItems(this.layout, offset, this.viewportLength(), 0); ++ const first = visible[0]?.index ?? -1; ++ const last = visible[visible.length - 1]?.index ?? -1; ++ const candidates = avatarPrefetchWindow(this.rows, first, last, this.avatarDirection); ++ const desired = new Set(candidates.map(({ ++ source ++ }) => canonicalNativeListAvatarUri(source.uri)).filter(uri => uri !== undefined)); ++ this.avatarLeases.forEach((release, uri) => { ++ if (!desired.has(uri)) { ++ release(); ++ this.avatarLeases.delete(uri); ++ } ++ }); ++ candidates.forEach(({ ++ source, ++ priority ++ }) => { ++ const uri = canonicalNativeListAvatarUri(source.uri); ++ if (!uri) return; ++ const existing = this.avatarLeases.get(uri); ++ if (existing) existing.setPriority(priority);else { ++ let lease; ++ lease = acquireNativeListAvatar(this.document, uri, () => {}, () => { ++ if (this.avatarLeases.get(uri) === lease) { ++ lease?.(); ++ this.avatarLeases.delete(uri); ++ } ++ }, priority); ++ this.avatarLeases.set(uri, lease); ++ } ++ }); ++ } + renderWindow() { ++ this.updateAvatarWindow(); + const viewportLength = this.viewportLength(); + const visible = webLayoutItemsForMount(this.layout, this.currentOffset(), viewportLength, this.virtualizationEnabled, viewportLength * OVERSCAN_VIEWPORTS); + const desired = new Set(visible.map(item => item.index)); +@@ -1264,6 +1969,7 @@ export class NativeListWebEngine { if (!desired.has(index)) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); @@ -4819,7 +5460,7 @@ index 625a913..c1d40b4 100644 element.remove(); this.pool.push(element); } -@@ -1285,6 +1947,20 @@ export class NativeListWebEngine { +@@ -1285,6 +1991,20 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); @@ -4840,7 +5481,7 @@ index 625a913..c1d40b4 100644 this.updateVisibleSelection(); this.updateVisibleState(); } -@@ -1295,12 +1971,16 @@ export class NativeListWebEngine { +@@ -1295,12 +2015,16 @@ export class NativeListWebEngine { } renderElement(element, index, row, overlay = false) { this.invalidateActionAnchorForElement(element); @@ -4857,7 +5498,7 @@ index 625a913..c1d40b4 100644 setData(element, 'nativeListReorderable', this.isReorderable(row)); setData(element, 'nativeListDragging', this.pointerReorder?.active && this.pointerReorder.sourceKey === row.key); setData(element, 'separator', row.separator); -@@ -1320,11 +2000,67 @@ export class NativeListWebEngine { +@@ -1320,11 +2044,67 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index }; @@ -4926,7 +5567,7 @@ index 625a913..c1d40b4 100644 this.footer.replaceChildren(); if (!row) return; const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -1357,7 +2093,9 @@ export class NativeListWebEngine { +@@ -1357,7 +2137,9 @@ export class NativeListWebEngine { updateVisibleSelection() { const update = (element, row) => { if (!row) return; @@ -4937,7 +5578,7 @@ index 625a913..c1d40b4 100644 setData(element, 'nativeListSelected', selected); element.setAttribute('aria-selected', String(selected)); element.querySelectorAll('.ok-native-list-checkbox').forEach(checkbox => { -@@ -1396,7 +2134,9 @@ export class NativeListWebEngine { +@@ -1396,7 +2178,9 @@ export class NativeListWebEngine { } this.checkEndReached(last?.index ?? -1); this.updateStickyHeader(first?.index ?? -1); @@ -4948,7 +5589,7 @@ index 625a913..c1d40b4 100644 } updateStickyHeader(firstVisibleIndex) { if (!this.snapshot.layout.stickyHeaders || this.layout.horizontal || firstVisibleIndex < 0) { -@@ -1407,7 +2147,7 @@ export class NativeListWebEngine { +@@ -1407,7 +2191,7 @@ export class NativeListWebEngine { let index = -1; for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { const row = this.rows[cursor]; @@ -4957,7 +5598,7 @@ index 625a913..c1d40b4 100644 index = cursor; break; } -@@ -1433,7 +2173,7 @@ export class NativeListWebEngine { +@@ -1433,7 +2217,7 @@ export class NativeListWebEngine { let nextIndex = -1; for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { const candidate = this.rows[cursor]; @@ -4966,7 +5607,7 @@ index 625a913..c1d40b4 100644 nextIndex = cursor; break; } -@@ -1443,10 +2183,15 @@ export class NativeListWebEngine { +@@ -1443,10 +2227,15 @@ export class NativeListWebEngine { this.sticky.style.transform = 'translate3d(0,' + String(translate) + 'px,0)'; this.updateVisibleSelection(); } @@ -4984,7 +5625,7 @@ index 625a913..c1d40b4 100644 }); this.indexRail.querySelectorAll('[data-section-key]').forEach(button => setData(button, 'active', button.dataset.sectionKey === activeKey)); } -@@ -1539,7 +2284,14 @@ export class NativeListWebEngine { +@@ -1539,7 +2328,14 @@ export class NativeListWebEngine { const bindingEpoch = rowElement.dataset.nativeListBindingEpoch; if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; this.invalidateActionAnchor('rebind'); @@ -5000,7 +5641,7 @@ index 625a913..c1d40b4 100644 const token = [this.actionAnchorInstanceId, this.snapshot.generation, ++this.actionAnchorCounter, bindingEpoch].join(':'); const slotValue = actionElement.dataset.nativeListAnchorSlot; const direction = this.document.defaultView?.getComputedStyle(actionElement).direction === 'rtl' || actionElement.closest('[dir="rtl"]') ? 'rtl' : 'ltr'; -@@ -1577,7 +2329,9 @@ export class NativeListWebEngine { +@@ -1577,7 +2373,9 @@ export class NativeListWebEngine { }); } handleRowPress(row, rowElement, sourceElement = rowElement) { @@ -5011,7 +5652,7 @@ index 625a913..c1d40b4 100644 if (this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && isSelectableRow(row)) { this.activateSelection({ scope: 'row' -@@ -1597,6 +2351,27 @@ export class NativeListWebEngine { +@@ -1597,6 +2395,27 @@ export class NativeListWebEngine { this.emitRowAction(row, actionKey); } } @@ -5039,7 +5680,7 @@ index 625a913..c1d40b4 100644 handleClick = event => { if (Date.now() < this.suppressClickUntil) { event.preventDefault(); -@@ -1627,6 +2402,12 @@ export class NativeListWebEngine { +@@ -1627,6 +2446,12 @@ export class NativeListWebEngine { this.handleRowPress(sourceRow, rowElement ?? undefined, memberElement ?? rowElement ?? undefined); }; handleKeyDown = event => { @@ -5052,7 +5693,19 @@ index 625a913..c1d40b4 100644 if (event.key === 'Escape') { if (this.pointerReorder?.active) { event.preventDefault(); -@@ -1816,7 +2597,14 @@ export class NativeListWebEngine { +@@ -1729,7 +2554,10 @@ export class NativeListWebEngine { + if (this.frameHandle !== undefined || this.destroyed) return; + this.frameHandle = this.requestFrame(() => { + this.frameHandle = undefined; +- if (this.virtualizationEnabled) this.renderWindow();else this.updateVisibleState(); ++ if (this.virtualizationEnabled) this.renderWindow();else { ++ this.updateAvatarWindow(); ++ this.updateVisibleState(); ++ } + }); + } + requestFrame(callback) { +@@ -1816,7 +2644,14 @@ export class NativeListWebEngine { const index = Number(rowElement?.dataset.nativeListRowIndex); const row = this.rows[index]; if (!row || !this.isReorderable(row)) return; @@ -5068,7 +5721,7 @@ index 625a913..c1d40b4 100644 const view = this.document.defaultView; const state = { pointerId: event.pointerId, -@@ -1832,9 +2620,8 @@ export class NativeListWebEngine { +@@ -1832,9 +2667,8 @@ export class NativeListWebEngine { active: false }; this.pointerReorder = state; @@ -5080,7 +5733,7 @@ index 625a913..c1d40b4 100644 state.longPressTimer = view?.setTimeout(() => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS); } }; -@@ -1935,6 +2722,20 @@ export class NativeListWebEngine { +@@ -1935,6 +2769,20 @@ export class NativeListWebEngine { state.previewOffsetX = state.startX - rect.left; state.previewOffsetY = Math.min(previewHeight, Math.max(0, state.startY - rect.top)); this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); @@ -5101,7 +5754,7 @@ index 625a913..c1d40b4 100644 const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; if (badgeText) { -@@ -1970,6 +2771,7 @@ export class NativeListWebEngine { +@@ -1970,6 +2818,7 @@ export class NativeListWebEngine { } clearReorderPreviewVisual() { this.reorderPreview.hidden = true; @@ -5109,12 +5762,55 @@ index 625a913..c1d40b4 100644 this.reorderPreview.replaceChildren(); this.reorderPreview.style.removeProperty('transform'); this.reorderPreview.style.removeProperty('transition'); -@@ -2235,4 +3037,3 @@ export class NativeListWebEngine { +@@ -2235,4 +3084,3 @@ export class NativeListWebEngine { }); } } -//# sourceMappingURL=NativeListWebEngine.js.map \ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts +index 2d4d817..4226865 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts +@@ -15,4 +15,3 @@ export declare const NativeList: React.ForwardRefExoticComponent>>; +-//# sourceMappingURL=NativeList.d.ts.map +\ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/avatarPrefetch.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/avatarPrefetch.d.ts +new file mode 100644 +index 0000000..261ab3c +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/avatarPrefetch.d.ts +@@ -0,0 +1,27 @@ ++import type { ImageSource, RowModel, RowPatch } from './models'; ++export type AvatarCandidate = Readonly<{ ++ source: ImageSource; ++ priority: 0 | 1 | 2; ++}>; ++export declare function avatarPrefetchWindow(rows: readonly RowModel[], first: number, last: number, direction: number, resolveRow?: (row: RowModel) => RowModel): AvatarCandidate[]; ++export declare class NativeAvatarPrefetchModel { ++ private rows; ++ private rowByKey; ++ private readonly imageRows; ++ constructor(rows: readonly RowModel[]); ++ replaceSnapshot(rows: readonly RowModel[]): void; ++ applyPatches(patches: readonly RowPatch[], dispatch?: () => void): boolean; ++ window(first: number, last: number, direction: number): AvatarCandidate[]; ++} ++export declare class NativeAvatarPrefetchQueue { ++ private readonly preload; ++ private pending; ++ private activeUri; ++ private timer; ++ private disposed; ++ private readonly recent; ++ constructor(preload: (source: ImageSource) => Promise); ++ update(candidates: readonly AvatarCandidate[]): void; ++ dispose(): void; ++ private schedule; ++} diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts index 60c6a5c..69829a5 100644 --- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts @@ -5301,12 +5997,158 @@ index 60c6a5c..69829a5 100644 \ No newline at end of file diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts new file mode 100644 -index 0000000..9cd9d65 +index 0000000..6c2ee1d --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts -@@ -0,0 +1,2 @@ +@@ -0,0 +1,5 @@ ++export type AvatarLease = (() => void) & { ++ setPriority: (priority: number) => void; ++}; +export declare function canonicalNativeListAvatarUri(uri: string): string | undefined; -+export declare function acquireNativeListAvatar(document: Document, uri: string, resolve: (url: string) => void, reject: () => void): () => void; ++export declare function acquireNativeListAvatar(document: Document, uri: string, resolve: (url: string) => void, reject: () => void, priority?: number): AvatarLease; +diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts +index 646b4b8..85d1bf0 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts ++++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts +@@ -77,6 +77,9 @@ export declare class NativeListWebEngine { + private resizeObserver; + private pendingScroll; + private lastVisibleSignature; ++ private readonly avatarLeases; ++ private avatarOffset; ++ private avatarDirection; + private reachedGeneration; + private stickyKey; + private previewTimer; +@@ -100,6 +103,7 @@ export declare class NativeListWebEngine { + private lastViewportWidth; + private lastViewportHeight; + private destroyed; ++ private measuredWarningHeights; + constructor(host: HTMLElement, snapshot: NativeListSnapshot, callbacks: NativeListWebCallbacks, virtualizationEnabled?: boolean); + updateCallbacks(callbacks: NativeListWebCallbacks): void; + setVirtualizationEnabled(enabled: boolean): void; +@@ -117,6 +121,7 @@ export declare class NativeListWebEngine { + private setSnapshot; + private applyTheme; + private recomputeLayout; ++ private updateAvatarWindow; + private renderWindow; + private positionElement; + private renderElement; +@@ -145,6 +150,8 @@ export declare class NativeListWebEngine { + private invalidateActionAnchor; + private emitActionAnchorInvalidated; + private handleRowPress; ++ private handleTitlePointerOver; ++ private handleTitlePointerOut; + private handleClick; + private handleKeyDown; + private startKeyboardReorder; +@@ -200,4 +207,3 @@ export declare class NativeListWebEngine { + private remapMountedRows; + private updateReorderVisualState; + } +-//# sourceMappingURL=NativeListWebEngine.d.ts.map +\ No newline at end of file +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx +index 8dc8672..7a67a3e 100644 +--- a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx ++++ b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx +@@ -1,4 +1,6 @@ +-import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; ++import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; ++import { OneKeyImageCache, OneKeyImageCachePolicy } from '@onekeyfe/react-native-image'; ++import { NativeAvatarPrefetchModel, NativeAvatarPrefetchQueue } from './avatarPrefetch'; + import { callback, getHostComponent } from 'react-native-nitro-modules'; + import type { + NativeListMethods, +@@ -93,6 +95,40 @@ export const NativeList = forwardRef( + }; + const snapshotRef = useRef(snapshot); + snapshotRef.current = snapshot; ++ // OneKey patch: range events already cross the bridge only when row indices ++ // change. Prefetch is optional work; scrolling and visible loads stay native. ++ const avatarQueueRef = useRef(null); ++ const avatarRangeRef = useRef<{ first: number; last: number; direction: number } | undefined>(undefined); ++ // OneKey patch: a changed prop is a complete snapshot; unrelated renders must ++ // not erase image patches already dispatched through the imperative handle. ++ // const avatarPrefetchPaused = useRef(false); ++ const avatarModelRef = useRef(null); ++ const avatarPropSnapshotRef = useRef(snapshot); ++ const avatarLifecycle = useRef<'pending' | 'mounted' | 'unmounted'>('pending'); ++ if (!avatarModelRef.current) avatarModelRef.current = new NativeAvatarPrefetchModel(snapshot.rows); ++ else if (avatarPropSnapshotRef.current !== snapshot) avatarModelRef.current.replaceSnapshot(snapshot.rows); ++ avatarPropSnapshotRef.current = snapshot; ++ const updateAvatarPrefetch = () => { ++ const range = avatarRangeRef.current; ++ if (!range) return; ++ avatarQueueRef.current?.update(avatarModelRef.current?.window(range.first, range.last, range.direction) ?? []); ++ }; ++ const updateAvatarPrefetchRef = useRef(updateAvatarPrefetch); ++ updateAvatarPrefetchRef.current = updateAvatarPrefetch; ++ useEffect(() => { ++ avatarLifecycle.current = 'mounted'; ++ const queue = new NativeAvatarPrefetchQueue((source) => OneKeyImageCache.preload([{ ++ uri: source.uri, headers: source.headers, resizeWidth: source.width, ++ resizeHeight: source.height, optimizeTos: false, ++ cachePolicy: source.cachePolicy === 'memory' ? OneKeyImageCachePolicy.MEMORY : source.cachePolicy === 'disk' ? OneKeyImageCachePolicy.DISK : OneKeyImageCachePolicy.MEMORY_DISK, ++ }])); ++ avatarQueueRef.current = queue; ++ updateAvatarPrefetchRef.current(); ++ return () => { queue.dispose(); avatarQueueRef.current = null; avatarLifecycle.current = 'unmounted'; }; ++ }, []); ++ useEffect(() => { ++ updateAvatarPrefetchRef.current(); ++ }, [snapshot]); + const initialScrollRef = useRef< + | Readonly<{ + index?: number; +@@ -180,11 +216,23 @@ export const NativeList = forwardRef( + useImperativeHandle(forwardedRef, () => ({ + applySnapshot(nextSnapshot) { + snapshotRef.current = nextSnapshot; +- nativeRef.current?.applySnapshot(serializeSnapshot(nextSnapshot)); ++ const native = nativeRef.current; ++ if (native) { ++ native.applySnapshot(serializeSnapshot(nextSnapshot)); ++ if (avatarLifecycle.current !== 'unmounted') { ++ avatarModelRef.current?.replaceSnapshot(nextSnapshot.rows); ++ updateAvatarPrefetchRef.current(); ++ } ++ } + }, + applyPatches(patches) { +- if (patches.length > 0) +- nativeRef.current?.applyPatches(serializePatches(patches)); ++ // OneKey patch: image updates replace pending prefetch instead of disabling it. ++ // if (imageFieldsChanged) { avatarPrefetchPaused.current = true; avatarQueueRef.current?.update([]); } ++ if (!patches.length) return; ++ const native = nativeRef.current; ++ const dispatch = native ? () => native.applyPatches(serializePatches(patches)) : undefined; ++ if (avatarLifecycle.current === 'unmounted') { dispatch?.(); return; } ++ if (avatarModelRef.current?.applyPatches(patches, dispatch)) updateAvatarPrefetchRef.current(); + }, + reconcileSelection(selectedKeys) { + nativeRef.current?.reconcileSelection(JSON.stringify(selectedKeys)); +@@ -279,9 +327,14 @@ export const NativeList = forwardRef( + ); + }), + onVisibleRangeChanged: callback((payloadJson: string) => { +- callbacksRef.current.onVisibleRangeChanged?.( +- parsePayload(payloadJson) +- ); ++ const payload = parsePayload(payloadJson); ++ const previous = avatarRangeRef.current; ++ avatarRangeRef.current = { ++ first: payload.firstIndex, last: payload.lastIndex, ++ direction: previous && payload.firstIndex !== previous.first ? Math.sign(payload.firstIndex - previous.first) : previous?.direction ?? 1, ++ }; ++ updateAvatarPrefetchRef.current(); ++ callbacksRef.current.onVisibleRangeChanged?.(payload); + }), + }), + [] diff --git a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx index e4cca82..e15f896 100644 --- a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx @@ -5379,12 +6221,223 @@ index e4cca82..e15f896 100644 } }, reconcileSelection(selectedKeys) { +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/avatar-scheduling.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/avatar-scheduling.cjs +new file mode 100644 +index 0000000..3510bc4 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/avatar-scheduling.cjs +@@ -0,0 +1,205 @@ ++// OneKey patch: exercise actual worker/broker code with controlled I/O completion. ++const assert = require('node:assert/strict'); ++const fs = require('node:fs'); ++const path = require('node:path'); ++const vm = require('node:vm'); ++const { test } = require('node:test'); ++const ts = require('typescript'); ++const { IDBFactory } = require('fake-indexeddb'); ++const prefix = 'onekey-avatar://blockie/v1/'; ++const sourceRoot = path.resolve(__dirname, '..'); ++const tick = () => new Promise((resolve) => setTimeout(resolve, 1)); ++async function until(predicate) { for (let i = 0; i < 3000; i++) { if (predicate()) return; await tick(); } throw new Error('Timed out'); } ++function worker({ db = new IDBFactory(), size = 100 } = {}) { ++ const messages = [], generated = [], urls = new Map(), revoked = []; ++ const context = { ++ indexedDB: db, Blob, setTimeout, clearTimeout, ++ createImageBitmap: async () => ({ width: 128, height: 128, close() {} }), ++ URL: { createObjectURL(blob) { const url = `blob:${messages.length}:${urls.size}`; urls.set(url, blob); return url; }, revokeObjectURL(url) { revoked.push(url); urls.delete(url); } }, ++ postMessage(message) { messages.push(message); }, onmessage: undefined, ++ generate: async (seed) => { generated.push(seed); return new Blob([new Uint8Array(size)], { type: 'image/png' }); }, ++ }; ++ vm.createContext(context); ++ vm.runInContext(fs.readFileSync(path.join(sourceRoot, 'web/NativeListAvatarWorker.js'), 'utf8'), context); ++ vm.runInContext('generateBlob = generate', context); ++ const read = (expression) => vm.runInContext(expression, context); ++ const send = (type, id, seed = String(id), priority = 2) => context.onmessage({ data: { type, id, uri: prefix + seed, priority } }); ++ return { context, messages, generated, urls, revoked, read, send, db }; ++} ++function loadTypeScript(name, globals = {}) { ++ const source = fs.readFileSync(path.join(sourceRoot, name), 'utf8').replaceAll('import.meta.url', "'https://unit.test/module.js'"); ++ const result = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }); ++ const exports = {}; ++ const context = vm.createContext({ exports, setTimeout, clearTimeout, ...globals }); ++ vm.runInContext(result.outputText, context); ++ return exports; ++} ++test('display and subsequent load slots do not await disk; pending bytes deduplicate reacquire', async () => { ++ const w = worker(); ++ let finishWrite; ++ w.context.blockedWrite = () => new Promise((resolve) => { finishWrite = resolve; }); ++ w.read('readDisk = async () => undefined; writeDisk = blockedWrite'); ++ w.send('acquire', 1, 'a'); ++ await until(() => w.messages.length === 1); ++ assert.equal(w.read('activeLoads'), 0); ++ assert.equal(w.read('pendingBlobs.size'), 1); ++ w.send('release', 1, 'a'); ++ w.send('acquire', 2, 'a'); ++ await until(() => w.messages.length === 2); ++ assert.deepEqual(w.generated, ['a']); ++ w.send('acquire', 3, 'b'); ++ await until(() => w.messages.length === 3); ++ assert.deepEqual(w.generated, ['a', 'b']); ++ // OneKey patch: background persistence starts after the shared quiet window. ++ await until(() => finishWrite); ++ // Quota/write failure remains best-effort, never a late display error. ++ w.read('writeDisk = async () => { throw new Error("quota"); }'); ++ finishWrite(); ++ await until(() => w.read('pendingBlobs.size') === 0); ++ assert(w.messages.every((message) => message.type === 'resolved')); ++}); ++test('background persistence is bounded without blocking visible results', async () => { ++ const w = worker({ size: 200_000 }); ++ let release; ++ w.context.blockedWrite = () => new Promise((resolve) => { release = resolve; }); ++ w.read('readDisk = async () => undefined; writeDisk = blockedWrite'); ++ for (let index = 0; index < 80; index++) { ++ w.send('acquire', index); ++ await until(() => w.messages.length === index + 1); ++ w.send('release', index); ++ } ++ assert(w.read('pendingBlobs.size') <= 32); ++ assert(w.read('pendingBytes') <= 4 * 1024 * 1024); ++ assert.equal(w.read('activeLoads'), 0); ++ assert.equal(w.urls.size, 0); ++ await until(() => release); ++ w.read('writeDisk = async () => {}'); ++ release(); ++ await until(() => w.read('pendingBytes') === 0); ++}); ++test('queued visible promotion wins over FIFO prefetch without duplicate generation', async () => { ++ const w = worker(); ++ const reads = []; ++ w.context.blockedRead = () => new Promise((resolve) => reads.push(resolve)); ++ w.read('readDisk = blockedRead; writeDisk = async () => {}'); ++ w.send('acquire', 1, 'active-a'); w.send('acquire', 2, 'active-b'); ++ w.send('acquire', 3, 'earlier-prefetch'); w.send('acquire', 4, 'now-visible'); ++ w.send('priority', 4, 'now-visible', 0); ++ reads[0](); ++ await until(() => reads.length === 3); ++ assert.equal(w.read('jobs.get("' + prefix + 'now-visible").active'), true); ++ assert.equal(w.read('jobs.get("' + prefix + 'earlier-prefetch").active'), false); ++ w.send('release', 3); // Drop stale opposite-direction work before it starts. ++ reads[1](); reads[2](); ++ await until(() => w.read('activeLoads') === 0); ++ assert(!w.generated.includes('earlier-prefetch')); ++ assert.equal(w.generated.filter((seed) => seed === 'now-visible').length, 1); ++}); ++test('last-lease cancellation during generation never publishes or leaks a Blob URL', async () => { ++ const w = worker(); let finish; ++ w.context.slowGenerate = () => new Promise((resolve) => { finish = resolve; }); ++ w.read('readDisk = async () => undefined; generateBlob = slowGenerate; writeDisk = async () => {}'); ++ w.send('acquire', 1); await until(() => finish); ++ w.send('release', 1); finish(new Blob(['png'], { type: 'image/png' })); ++ await until(() => w.read('activeLoads') === 0); ++ assert.equal(w.messages.length, 0); assert.equal(w.urls.size, 0); ++}); ++test('disk reopen avoids generation; readonly hits defer touch; corrupt cache regenerates', async () => { ++ const w = worker(); w.send('acquire', 1, 'cached'); ++ await until(() => w.messages.length === 1 && w.read('pendingBlobs.size') === 0); ++ const reopened = worker({ db: w.db }); reopened.send('acquire', 1, 'cached'); ++ await until(() => reopened.messages.length === 1); ++ assert.equal(reopened.generated.length, 0); ++ assert.equal(reopened.read('touches.size'), 1); ++ const corrupt = worker({ db: w.db }); ++ corrupt.context.createImageBitmap = async () => { throw new Error('corrupt'); }; ++ corrupt.send('acquire', 1, 'cached'); ++ await until(() => corrupt.messages.length === 1); ++ assert.deepEqual(corrupt.generated, ['cached']); ++ const denied = worker({ db: { open() { throw new Error('denied'); } } }); ++ denied.send('acquire', 1); await until(() => denied.messages.length === 1); ++ assert.equal(denied.generated.length, 1); ++}); ++test('broker promotes a shared queued URI and release restores remaining priority', () => { ++ let instance; ++ class FakeWorker { ++ constructor() { instance = this; this.messages = []; } ++ addEventListener() {} ++ postMessage(message) { this.messages.push(message); } ++ } ++ const api = loadTypeScript('web/NativeListWebAvatarCache.ts', { Worker: FakeWorker, URL, queueMicrotask }); ++ const document = {}; ++ const prefetch = api.acquireNativeListAvatar(document, prefix + 'a', () => {}, () => {}, 2); ++ const visible = api.acquireNativeListAvatar(document, prefix + 'a', () => {}, () => {}, 0); ++ assert.equal(instance.messages.filter((m) => m.type === 'acquire').length, 1); ++ assert.equal(instance.messages.at(-1).priority, 0); ++ visible(); assert.equal(instance.messages.at(-1).priority, 2); ++ prefetch.setPriority(1); assert.equal(instance.messages.at(-1).priority, 1); ++ prefetch(); assert(instance.messages.some((m) => m.type === 'release')); ++}); ++test('window prioritizes visibility, reverses ahead work and bounds large groups', () => { ++ const { avatarPrefetchWindow } = loadTypeScript('avatarPrefetch.ts'); ++ const row = (i) => ({ type: 'identity', key: String(i), title: String(i), leading: { kind: 'account', image: { uri: prefix + i, width: 32, height: 32 } } }); ++ const rows = Array.from({ length: 1000 }, (_, i) => row(i)); ++ const forward = avatarPrefetchWindow(rows, 30, 39, 1); ++ assert.equal(forward[0].source.uri, prefix + '30'); ++ assert.equal(forward.find((item) => item.priority === 1).source.uri, prefix + '40'); ++ const reverse = avatarPrefetchWindow(rows, 30, 39, -1); ++ assert.equal(reverse.find((item) => item.priority === 1).source.uri, prefix + '29'); ++ assert(forward.length <= 64); assert(reverse.length <= 64); ++ const large = avatarPrefetchWindow([{ type: 'walletGroup', key: '0', parent: row(0), children: rows.slice(1) }], 0, 0, 1); ++ assert(large.length <= 64); ++ assert.equal(avatarPrefetchWindow(rows, -1, -1, 1).length, 0); ++}); ++test('native short batches replace stale pending direction, deduplicate and stop on unmount', async () => { ++ const { NativeAvatarPrefetchQueue } = loadTypeScript('avatarPrefetch.ts'); ++ const started = [], finish = []; ++ const queue = new NativeAvatarPrefetchQueue((source) => { started.push(source.uri); return new Promise((resolve) => finish.push(resolve)); }); ++ const candidates = (...values) => values.map((value) => ({ source: { uri: prefix + value, width: 32, height: 32 }, priority: 1 })); ++ queue.update(candidates('a', 'b', 'c')); await until(() => started.length === 1); ++ queue.update(candidates('x', 'y')); finish[0](true); ++ await until(() => started.length === 2); ++ assert.deepEqual(started, [prefix + 'a', prefix + 'x']); ++ queue.update(candidates('a', 'x', 'z')); queue.dispose(); finish[1](true); ++ await new Promise((resolve) => setTimeout(resolve, 25)); ++ assert.equal(started.length, 2); ++}); ++test('over-budget actual IDB write rolls back and unblocks a subsequent display miss', async () => { ++ const w = worker(); ++ w.send('acquire', 1, 'prime'); ++ await until(() => w.messages.length === 1 && w.read('pendingBlobs.size') === 0); ++ const database = await w.read('openDatabase()'); ++ const originalTransaction = database.transaction.bind(database); ++ let blocked = false, aborted = false; ++ database.transaction = (...args) => { ++ const transaction = originalTransaction(...args); ++ if (args[1] === 'readwrite' && Array.isArray(args[0]) && !blocked) { ++ blocked = true; ++ // Keep the real readwrite transaction alive until the production deadline ++ // aborts it. A later readonly transaction really shares this store lock. ++ let alive = true; ++ transaction.addEventListener('abort', () => { alive = false; aborted = true; }); ++ const store = transaction.objectStore('images'); ++ const keepAlive = () => { ++ if (!alive) return; ++ try { const request = store.get('lock'); request.onsuccess = keepAlive; } catch {} ++ }; ++ keepAlive(); ++ } ++ return transaction; ++ }; ++ w.send('acquire', 2, 'blocked-write'); ++ await until(() => blocked && w.messages.length === 2); ++ w.send('acquire', 3, 'subsequent-miss'); ++ await until(() => w.messages.length === 3 && aborted); ++ assert.equal(w.messages[2].type, 'resolved'); ++ assert(w.generated.includes('subsequent-miss')); ++ await until(() => w.read('pendingBlobs.size') === 0); ++ // The aborted transaction must not leave a half-written row/metadata counter. ++ const check = originalTransaction(['images', 'metadata'], 'readonly'); ++ const failed = check.objectStore('images').get(prefix + 'blocked-write'); ++ const counter = check.objectStore('metadata').get('size'); ++ await new Promise((resolve) => { check.oncomplete = resolve; }); ++ assert.equal(failed.result, undefined); ++ assert.equal(counter.result.count, 2); ++}); diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs new file mode 100644 -index 0000000..2ceb919 +index 0000000..3937d88 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs -@@ -0,0 +1,431 @@ +@@ -0,0 +1,461 @@ +// OneKey patch: focused regression checks for the serialized selector adapter contract. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); @@ -5396,7 +6449,7 @@ index 0000000..2ceb919 +const originalLoader = require.extensions['.ts']; +require.extensions['.ts'] = (module, filename) => { + if (!filename.startsWith(packageRoot)) return originalLoader?.(module, filename); -+ const result = ts.transpileModule(fs.readFileSync(filename, 'utf8'), { ++ const result = ts.transpileModule(fs.readFileSync(filename, 'utf8').replaceAll('import.meta.url', "'https://unit.test/NativeList.js'"), { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, + }); + module._compile(result.outputText, filename); @@ -5816,6 +6869,214 @@ index 0000000..2ceb919 + assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg')); + page.close(); +}); ++test('URI prefetch extends beyond mounted DOM and releases leases when the engine is destroyed', () => { ++ const previousWorker = global.Worker; ++ const workers = []; ++ global.Worker = class { ++ constructor() { this.messages = []; workers.push(this); } ++ addEventListener() {} ++ postMessage(message) { this.messages.push(message); } ++ }; ++ const dom = new JSDOM('
', { pretendToBeVisual: true }); ++ const view = dom.window; ++ global.Element = view.Element; global.HTMLElement = view.HTMLElement; global.Node = view.Node; ++ view.HTMLElement.prototype.scrollTo = function ({ top = 0, left = 0 }) { this.scrollTop = top; this.scrollLeft = left; }; ++ const rows = Array.from({ length: 1000 }, (_, index) => identity(String(index), { ++ presentation: 'accountSelector', height: 60, ++ leading: { kind: 'account', image: { uri: 'onekey-avatar://blockie/v1/' + index, width: 32, height: 32 } }, ++ })); ++ const engine = new NativeListWebEngine(view.document.getElementById('host'), snapshot(rows), {}, true); ++ try { ++ const mountedKeys = [...view.document.querySelectorAll('[data-native-list-row-key]')].map((element) => Number(element.dataset.nativeListRowKey)); ++ const messages = workers.flatMap((worker) => worker.messages); ++ const acquire = messages.filter((message) => message.type === 'acquire'); ++ assert(acquire.length < 80); ++ assert(mountedKeys.length < 40); ++ assert(acquire.some((message) => Number(message.uri.split('/').at(-1)) > Math.max(...mountedKeys))); ++ assert.equal(acquire[0].priority, 0); ++ engine.destroy(); ++ const released = new Set(workers.flatMap((worker) => worker.messages).filter((message) => message.type === 'release').map((message) => message.id)); ++ assert(acquire.every((message) => released.has(message.id))); ++ } finally { engine.destroy(); view.close(); global.Worker = previousWorker; } ++}); +diff --git a/node_modules/@onekeyfe/react-native-native-list/src/avatarPrefetch.ts b/node_modules/@onekeyfe/react-native-native-list/src/avatarPrefetch.ts +new file mode 100644 +index 0000000..1379de9 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-native-list/src/avatarPrefetch.ts +@@ -0,0 +1,172 @@ ++// OneKey patch: share a bounded URI-only window across renderers. No bitmap or ++// account-specific data enters list snapshots or the UI runtime. ++import type { ImageSource, LeadingVisual, RowModel, RowPatch } from './models'; ++ ++export type AvatarCandidate = Readonly<{ source: ImageSource; priority: 0 | 1 | 2 }>; ++const PREFIX = 'onekey-avatar://blockie/v1/'; ++const MAX_CANDIDATES = 64; ++const IMAGE_PATCH_FIELDS = ['leading', 'secondaryLeading', 'image', 'networkImage', 'thumbnail', 'visual'] as const; ++ ++export function avatarPrefetchWindow( ++ rows: readonly RowModel[], first: number, last: number, direction: number, ++ resolveRow?: (row: RowModel) => RowModel ++): AvatarCandidate[] { ++ if (first < 0 || last < first || first >= rows.length) return []; ++ last = Math.min(last, rows.length - 1); ++ const result: AvatarCandidate[] = []; ++ const seen = new Set(); ++ let limit = MAX_CANDIDATES; ++ const add = (source: ImageSource | undefined, priority: 0 | 1 | 2) => { ++ if (!source?.uri.startsWith(PREFIX) || source.cachePolicy === 'none' || seen.has(source.uri) || result.length >= limit) return; ++ seen.add(source.uri); ++ result.push({ source, priority }); ++ }; ++ const visual = (value: LeadingVisual | undefined, priority: 0 | 1 | 2) => { ++ if (!value) return; ++ if ('image' in value) add(value.image, priority); ++ if ('networkImage' in value) add(value.networkImage, priority); ++ if ('images' in value) value.images.forEach((image) => add(image, priority)); ++ if ('overlays' in value) value.overlays?.forEach((overlay) => add(overlay.image, priority)); ++ }; ++ const row = (value: RowModel, priority: 0 | 1 | 2) => { ++ value = resolveRow?.(value) ?? value; ++ if ('leading' in value) visual(value.leading, priority); ++ if ('secondaryLeading' in value) visual(value.secondaryLeading, priority); ++ if ('image' in value) add(value.image, priority); ++ if ('networkImage' in value) add(value.networkImage, priority); ++ if ('thumbnail' in value) add(value.thumbnail, priority); ++ if (value.type === 'walletGroup') { ++ visual(value.parent.leading, priority); ++ for (const child of value.children.slice(0, MAX_CANDIDATES)) { ++ if (result.length >= limit) break; ++ visual(child.leading, priority); ++ } ++ } ++ }; ++ // Bound row examination too: a giant non-avatar group must not scan all data. ++ for (let index = first; index <= last && index < first + 64; index += 1) row(rows[index], 0); ++ const span = Math.min(24, (last - first + 1) * 2); ++ const step = direction < 0 ? -1 : 1; ++ const aheadStart = step > 0 ? last + 1 : first - 1; ++ const aheadLimit = Math.min(MAX_CANDIDATES, result.length + 24); ++ limit = aheadLimit; ++ for (let distance = 0; distance < span && result.length < aheadLimit; distance += 1) { ++ const index = aheadStart + distance * step; ++ if (index < 0 || index >= rows.length) break; ++ row(rows[index], 1); ++ } ++ const behindStart = step > 0 ? first - 1 : last + 1; ++ const behindLimit = Math.min(MAX_CANDIDATES, result.length + 8); ++ limit = behindLimit; ++ for (let distance = 0; distance < Math.min(8, span) && result.length < behindLimit; distance += 1) { ++ const index = behindStart - distance * step; ++ if (index < 0 || index >= rows.length) break; ++ row(rows[index], 2); ++ } ++ return result; ++} ++ ++// OneKey patch: imperative image patches update a sparse prefetch overlay, not ++// the readonly prop or a full cloned snapshot. The key index is rebuilt only for ++// a complete snapshot; ordinary balance patches retain no extra row copies. ++export class NativeAvatarPrefetchModel { ++ private rows: readonly RowModel[]; ++ private rowByKey: Map; ++ private readonly imageRows = new Map(); ++ ++ constructor(rows: readonly RowModel[]) { ++ this.rows = rows; ++ this.rowByKey = new Map(rows.map((row) => [row.key, row])); ++ } ++ ++ replaceSnapshot(rows: readonly RowModel[]) { ++ this.rows = rows; ++ this.rowByKey = new Map(rows.map((row) => [row.key, row])); ++ this.imageRows.clear(); ++ } ++ ++ applyPatches(patches: readonly RowPatch[], dispatch?: () => void): boolean { ++ // Native methods have no acknowledgement. Mirror only dispatched batches; ++ // a missing host or a synchronous serialization/dispatch failure changes nothing. ++ if (!dispatch) return false; ++ dispatch(); ++ const seen = new Set(); ++ for (const patch of patches) { ++ const row = this.rowByKey.get(patch.key); ++ // Both native renderers reject the whole batch for an unknown key/type. ++ if (!row || row.type !== patch.type || seen.has(patch.key)) return false; ++ seen.add(patch.key); ++ } ++ let changed = false; ++ for (const patch of patches) { ++ const changes: Readonly> = patch.changes; ++ let imageChanges: Record | undefined; ++ for (const key of IMAGE_PATCH_FIELDS) { ++ if (changes[key] !== undefined) { ++ imageChanges ??= {}; ++ imageChanges[key] = changes[key]; ++ } ++ } ++ // JSON.stringify omits undefined top-level fields. Native therefore keeps ++ // them unchanged; an explicit replacement visual without image clears it. ++ if (!imageChanges) continue; ++ const row = this.imageRows.get(patch.key) ?? this.rowByKey.get(patch.key)!; ++ // Preserve the validated row discriminant, matching applyRowPatches' shallow merge. ++ this.imageRows.set(patch.key, { ...row, ...imageChanges, key: row.key, type: row.type } as RowModel); ++ changed = true; ++ } ++ return changed; ++ } ++ ++ window(first: number, last: number, direction: number): AvatarCandidate[] { ++ return avatarPrefetchWindow(this.rows, first, last, direction, (row) => this.imageRows.get(row.key) ?? row); ++ } ++} ++ ++// Native preload has no cancellation token. One in-flight source is the bound; ++// direction changes replace all not-yet-started work rather than appending it. ++export class NativeAvatarPrefetchQueue { ++ private pending: ImageSource[] = []; ++ private activeUri: string | undefined; ++ private timer: ReturnType | undefined; ++ private disposed = false; ++ private readonly recent = new Map(); ++ ++ constructor(private readonly preload: (source: ImageSource) => Promise) {} ++ ++ update(candidates: readonly AvatarCandidate[]) { ++ if (this.disposed) return; ++ const now = Date.now(); ++ this.pending = candidates.filter(({ source, priority }) => priority !== 0 && source.uri !== this.activeUri && now - (this.recent.get(source.uri) ?? 0) > 30_000).map(({ source }) => source); ++ this.schedule(); ++ } ++ ++ dispose() { ++ this.disposed = true; ++ this.pending = []; ++ this.recent.clear(); ++ if (this.timer !== undefined) clearTimeout(this.timer); ++ this.timer = undefined; ++ } ++ ++ private schedule() { ++ if (this.disposed || this.activeUri || this.timer !== undefined || !this.pending.length) return; ++ // Yield between short batches without imposing a frame-per-image throughput cap. ++ this.timer = setTimeout(() => { ++ this.timer = undefined; ++ const source = this.pending.shift(); ++ if (!source || this.disposed) return; ++ this.activeUri = source.uri; ++ Promise.resolve().then(() => this.preload(source)).then((success) => { ++ if (success && !this.disposed) { ++ this.recent.delete(source.uri); ++ this.recent.set(source.uri, Date.now()); ++ if (this.recent.size > 128) this.recent.delete(this.recent.keys().next().value as string); ++ } ++ }).catch(() => { /* Visible loading retains its own failure/retry behavior. */ }).finally(() => { ++ this.activeUri = undefined; ++ this.schedule(); ++ }); ++ }, 0); ++ } ++} diff --git a/node_modules/@onekeyfe/react-native-native-list/src/models.ts b/node_modules/@onekeyfe/react-native-native-list/src/models.ts index 82ca2dd..5fed01c 100644 --- a/node_modules/@onekeyfe/react-native-native-list/src/models.ts @@ -6092,10 +7353,10 @@ index a64a0ec..10e9c48 100644 (!Number.isSafeInteger(patch.changes.revision) || diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js new file mode 100644 -index 0000000..fff4081 +index 0000000..5c27e23 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js -@@ -0,0 +1,258 @@ +@@ -0,0 +1,443 @@ +// OneKey patch: avatar generation and PNG bytes stay in this external worker. +// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): +// https://github.com/MyCryptoHQ/ethereum-blockies-base64 @@ -6126,6 +7387,128 @@ index 0000000..fff4081 +const queue = []; +let activeLoads = 0; +let databasePromise; ++// OneKey patch: persistence never occupies either display-load slot. Pending bytes ++// bridge the last-lease/reacquire window until a bounded background write finishes. ++const MAX_PENDING_WRITES = 32; ++const MAX_PENDING_BYTES = 4 * 1024 * 1024; ++const WRITE_DEADLINE_MS = 32; ++const READ_BUDGET_MS = 8; ++const MAX_PENDING_READS = 2; ++const DISK_IDLE_MS = 100; ++const pendingBlobs = new Map(); ++const diskQueue = []; ++const touches = new Map(); ++const priorities = new Map(); ++let pendingBytes = 0; ++let writing = false; ++let diskTimer; ++let lastAcquire = 0; ++let pendingReads = 0; ++ ++function diskIsIdle() { ++ return !activeLoads && !queue.length && !pendingReads && Date.now() - lastAcquire >= DISK_IDLE_MS; ++} ++ ++function scheduleDiskWork() { ++ if (writing || diskTimer !== undefined || activeLoads || queue.length || pendingReads || (!diskQueue.length && !touches.size)) return; ++ const delay = Math.max(0, DISK_IDLE_MS - (Date.now() - lastAcquire)); ++ diskTimer = setTimeout(() => { diskTimer = undefined; void drainDiskQueue(); }, delay); ++} ++ ++function persistLater(uri, blob) { ++ if (pendingBlobs.has(uri) || blob.size > MAX_PENDING_BYTES) return; ++ // OneKey patch: a long scroll retains the most recent bounded write window, ++ // rather than filling it once and dropping every later result. Never evict I/O in flight. ++ // if (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) return; ++ while (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) { ++ const oldest = diskQueue.shift(); ++ if (!oldest) return; ++ if (pendingBlobs.get(oldest.uri) === oldest.blob) pendingBlobs.delete(oldest.uri); ++ pendingBytes -= oldest.blob.size; ++ } ++ pendingBlobs.set(uri, blob); ++ pendingBytes += blob.size; ++ diskQueue.push({ uri, blob }); ++ scheduleDiskWork(); ++} ++ ++async function drainDiskQueue() { ++ // OneKey patch: a momentary empty load queue is not a scroll-idle window. ++ // if (writing || activeLoads || queue.length) return; ++ if (writing) return; ++ if (!diskIsIdle()) { scheduleDiskWork(); return; } ++ writing = true; ++ try { ++ if (diskQueue.length) { ++ const { uri, blob } = diskQueue.shift(); ++ let result; ++ try { result = await writeDisk(uri, blob); } catch { /* Persistence remains best-effort. */ } ++ if (result === 'deferred') { ++ diskQueue.unshift({ uri, blob }); ++ } else { ++ if (pendingBlobs.get(uri) === blob) pendingBlobs.delete(uri); ++ pendingBytes -= blob.size; ++ const image = images.get(uri); ++ if (result === true && image?.blob === blob) image.persisted = true; ++ } ++ } else if (touches.size) { ++ await flushTouches(); ++ } ++ } finally { ++ writing = false; ++ scheduleDiskWork(); ++ } ++} ++ ++// OneKey patch: abort is a request, not a bounded browser store-lock release. ++// Display reads have their own budget below, even if this transaction is committing. ++function cacheWriteDeadline(transaction, resolve) { ++ let timer; ++ const check = () => { ++ if (!activeLoads && !queue.length && Date.now() - lastAcquire >= DISK_IDLE_MS) { timer = setTimeout(check, WRITE_DEADLINE_MS); return; } ++ try { transaction.abort(); } catch { /* The browser already started committing. */ } ++ }; ++ timer = setTimeout(check, WRITE_DEADLINE_MS); ++ const finish = (success) => { clearTimeout(timer); resolve(success); }; ++ transaction.oncomplete = () => finish(true); ++ transaction.onabort = () => finish(false); ++} ++ ++function touchLater(uri) { ++ touches.delete(uri); ++ touches.set(uri, Date.now()); ++ if (touches.size > 128) touches.delete(touches.keys().next().value); ++ // OneKey patch: touches share the same idle gate and writer as persistence. ++ // if (touchTimer === undefined) touchTimer = setTimeout(flushTouches, 250); ++ scheduleDiskWork(); ++} ++ ++async function flushTouches() { ++ const database = await openDatabase(); ++ // Opening the database can yield across a new acquire; recheck before starting I/O. ++ if (!diskIsIdle()) return; ++ const batch = [...touches]; ++ touches.clear(); ++ if (database && batch.length) await new Promise((resolve) => { ++ try { ++ const transaction = database.transaction('images', 'readwrite'); ++ cacheWriteDeadline(transaction, resolve); ++ const store = transaction.objectStore('images'); ++ batch.forEach(([uri, accessed]) => { ++ const request = store.get(uri); ++ request.onsuccess = () => { ++ if (request.result) store.put({ ...request.result, accessed: Math.max(request.result.accessed || 0, accessed) }); ++ }; ++ }); ++ } catch { resolve(false); } ++ }); ++} ++ ++function jobPriority(job) { ++ let priority = 2; ++ job.ids.forEach((id) => { priority = Math.min(priority, priorities.get(id) ?? 2); }); ++ return priority; ++} + +function openDatabase() { + if (!databasePromise) { @@ -6154,33 +7537,71 @@ index 0000000..fff4081 +} + +async function readDisk(uri) { -+ const database = await openDatabase(); -+ if (!database) return undefined; ++ // OneKey patch: the cache is optional. A slow read must not retain a display ++ // load slot while abort/commit waits on browser I/O. Keep unfinished reads ++ // separately bounded, including an unresolved shared database open. ++ if (pendingReads >= MAX_PENDING_READS) return undefined; ++ pendingReads += 1; + return new Promise((resolve) => { -+ try { -+ const transaction = database.transaction('images', 'readwrite'); -+ const store = transaction.objectStore('images'); -+ const request = store.get(uri); -+ let blob; -+ request.onsuccess = () => { -+ const record = request.result; -+ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) { -+ blob = record.blob; -+ store.put({ ...record, accessed: Date.now() }); -+ } -+ }; -+ transaction.oncomplete = () => resolve(blob); -+ transaction.onerror = transaction.onabort = () => resolve(undefined); -+ } catch { resolve(undefined); } ++ let transaction; ++ let blob; ++ let settled = false; ++ let finished = false; ++ let abortRequested = false; ++ const finish = (success) => { ++ if (finished) return; ++ finished = true; ++ pendingReads -= 1; ++ clearTimeout(timer); ++ if (!settled) { ++ settled = true; ++ if (success && blob) touchLater(uri); ++ resolve(success ? blob : undefined); ++ } ++ scheduleDiskWork(); ++ }; ++ const timeout = () => { ++ if (finished) return; ++ if (!settled) { settled = true; resolve(undefined); } ++ // Do not release pendingReads here: an aborted IDB transaction can still ++ // hold its store lock for hundreds of milliseconds before onabort arrives. ++ if (transaction && !abortRequested) { ++ abortRequested = true; ++ try { transaction.abort(); } catch { /* Already committing; ignore late callbacks. */ } ++ } ++ }; ++ const timer = setTimeout(timeout, READ_BUDGET_MS); ++ Promise.resolve().then(openDatabase).then((database) => { ++ if (settled || !database) { finish(false); return; } ++ try { ++ transaction = database.transaction('images', 'readonly'); ++ transaction.oncomplete = () => finish(true); ++ transaction.onabort = () => finish(false); ++ transaction.onerror = timeout; ++ const request = transaction.objectStore('images').get(uri); ++ request.onsuccess = () => { ++ if (settled) return; ++ const record = request.result; ++ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) blob = record.blob; ++ // A completed readonly get can serve display before the transaction ends. ++ // Its physical permit still belongs to oncomplete/onabort. ++ settled = true; ++ if (blob) touchLater(uri); ++ resolve(blob); ++ }; ++ } catch { if (transaction) timeout(); else finish(false); } ++ }).catch(() => finish(false)); + }); +} + +async function writeDisk(uri, blob) { + const database = await openDatabase(); -+ if (!database || blob.size > MAX_DISK_BYTES) return; -+ await new Promise((resolve) => { ++ if (!database || blob.size > MAX_DISK_BYTES) return false; ++ if (!diskIsIdle()) return 'deferred'; ++ return new Promise((resolve) => { + try { + const transaction = database.transaction(['images', 'metadata'], 'readwrite'); ++ cacheWriteDeadline(transaction, resolve); + const store = transaction.objectStore('images'); + const metadata = transaction.objectStore('metadata'); + const previous = store.get(uri); @@ -6206,7 +7627,8 @@ index 0000000..fff4081 + }; + }; + }; -+ transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); ++ // OneKey patch: completion/abort clears the deadline before releasing pending bytes. ++ // transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); + } catch { resolve(); } + }); +} @@ -6269,6 +7691,7 @@ index 0000000..fff4081 +function release(id) { + const uri = requests.get(id); + requests.delete(id); ++ priorities.delete(id); + const image = images.get(uri); + image?.references.delete(id); + if (image && image.references.size === 0) { @@ -6285,8 +7708,12 @@ index 0000000..fff4081 +} + +async function runJob(job) { -+ let blob = await readDisk(job.uri); -+ if (blob) { ++ // OneKey patch: reuse generated bytes even if the last UI lease was released ++ // while persistence is still in flight; these bytes already passed generation. ++ const pending = pendingBlobs.get(job.uri); ++ let blob = pending ?? await readDisk(job.uri); ++ let persisted = !!blob && !pending; ++ if (blob && !pending) { + try { + const bitmap = await createImageBitmap(blob); + const valid = bitmap.width === 128 && bitmap.height === 128; @@ -6295,11 +7722,14 @@ index 0000000..fff4081 + } catch { blob = undefined; } + } + if (!blob && job.ids.size) { ++ persisted = false; + blob = await generateBlob(job.seed); -+ await writeDisk(job.uri, blob); ++ // OneKey patch: notify clients and free the load slot without awaiting I/O. ++ // await writeDisk(job.uri, blob); ++ persistLater(job.uri, blob); + } + if (!blob || !job.ids.size) return; -+ const image = { url: URL.createObjectURL(blob), references: new Set() }; ++ const image = { url: URL.createObjectURL(blob), blob, persisted, references: new Set() }; + images.set(job.uri, image); + job.ids.forEach((id) => { + if (requests.get(id) !== job.uri) return; @@ -6311,7 +7741,13 @@ index 0000000..fff4081 + +function pump() { + while (activeLoads < MAX_CONCURRENT_LOADS && queue.length) { -+ const job = queue.shift(); ++ // OneKey patch: queued leases can be promoted without restarting their load. ++ // const job = queue.shift(); ++ let next = 0; ++ for (let index = 1; index < queue.length; index += 1) { ++ if (jobPriority(queue[index]) < jobPriority(queue[next])) next = index; ++ } ++ const [job] = queue.splice(next, 1); + if (jobs.get(job.uri) !== job || !job.ids.size) continue; + job.active = true; + activeLoads += 1; @@ -6323,6 +7759,7 @@ index 0000000..fff4081 + if (jobs.get(job.uri) === job) jobs.delete(job.uri); + activeLoads -= 1; + pump(); ++ void drainDiskQueue(); + }); + } +} @@ -6330,16 +7767,25 @@ index 0000000..fff4081 +onmessage = ({ data }) => { + if (!data || !Number.isSafeInteger(data.id)) return; + if (data.type === 'release') { release(data.id); return; } ++ if (data.type === 'priority') { ++ if (requests.has(data.id)) priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); ++ return; ++ } + if (data.type !== 'acquire') return; + release(data.id); ++ lastAcquire = Date.now(); ++ scheduleDiskWork(); + try { + if (typeof data.uri !== 'string' || !data.uri.startsWith(PREFIX)) throw new Error('Invalid avatar URI'); + const seed = decodeURIComponent(data.uri.slice(PREFIX.length)).toLowerCase(); + if (!seed) throw new Error('Empty avatar seed'); + const uri = PREFIX + encodeURIComponent(seed); + requests.set(data.id, uri); ++ priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); + const image = images.get(uri); + if (image) { ++ // Reacquiring a live Blob can retry best-effort persistence after queue pressure. ++ if (!image.persisted) persistLater(uri, image.blob); + image.references.add(data.id); + postMessage({ type: 'resolved', id: data.id, url: image.url }); + return; @@ -6356,20 +7802,21 @@ index 0000000..fff4081 +}; diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts new file mode 100644 -index 0000000..d7fceab +index 0000000..18ba311 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts -@@ -0,0 +1,138 @@ +@@ -0,0 +1,158 @@ +// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. +const AVATAR_PREFIX = 'onekey-avatar://blockie/v1/'; +const MAX_RETAINED_AVATARS = 128; ++export type AvatarLease = (() => void) & { setPriority: (priority: number) => void }; + +type AvatarEntry = { + id: number; + uri: string; + url?: string; + references: number; -+ listeners: Set<{ resolve: (url: string) => void; reject: () => void }>; ++ listeners: Set<{ resolve: (url: string) => void; reject: () => void; priority: number }>; +}; + +type AvatarResponse = @@ -6392,7 +7839,7 @@ index 0000000..d7fceab + private nextId = 0; + private worker: Worker | undefined; + -+ acquire(uri: string, resolve: (url: string) => void, reject: () => void): () => void { ++ acquire(uri: string, resolve: (url: string) => void, reject: () => void, priority = 2): AvatarLease { + let entry = this.entries.get(uri); + const isNew = !entry; + if (!entry) { @@ -6404,9 +7851,11 @@ index 0000000..d7fceab + this.entries.delete(uri); + this.entries.set(uri, current); + current.references += 1; -+ const listener = { resolve, reject }; ++ const listener = { resolve, reject, priority }; + if (current.url) resolve(current.url); -+ else current.listeners.add(listener); ++ // OneKey patch: keep lease priority after resolution as well as while queued. ++ // else current.listeners.add(listener); ++ current.listeners.add(listener); + if (isNew) { + try { + if (!this.worker) { @@ -6418,14 +7867,20 @@ index 0000000..d7fceab + this.worker.addEventListener('error', this.handleFailure); + this.worker.addEventListener('messageerror', this.handleFailure); + } -+ this.worker.postMessage({ type: 'acquire', id: current.id, uri }); ++ this.worker.postMessage({ type: 'acquire', id: current.id, uri, priority }); + } catch { + queueMicrotask(this.handleFailure); + } + } ++ const updatePriority = () => { ++ if (current.url) return; ++ const priorities = [...current.listeners].map((value) => value.priority); ++ this.worker?.postMessage({ type: 'priority', id: current.id, priority: Math.min(2, ...priorities) }); ++ }; ++ updatePriority(); + this.trim(); + let disposed = false; -+ return () => { ++ const release = () => { + if (disposed) return; + disposed = true; + current.listeners.delete(listener); @@ -6435,8 +7890,16 @@ index 0000000..d7fceab + this.requests.delete(current.id); + this.worker?.postMessage({ type: 'release', id: current.id }); + } ++ updatePriority(); + this.trim(); + }; ++ return Object.assign(release, { ++ setPriority: (next: number) => { ++ if (disposed || listener.priority === next) return; ++ listener.priority = next; ++ updatePriority(); ++ }, ++ }); + } + + private readonly handleMessage = (event: MessageEvent) => { @@ -6456,7 +7919,9 @@ index 0000000..d7fceab + this.worker?.postMessage({ type: 'release', id: entry.id }); + entry.listeners.forEach((listener) => listener.reject()); + } -+ entry.listeners.clear(); ++ // OneKey patch: retain resolved lease priorities until their explicit release. ++ // entry.listeners.clear(); ++ if (!entry.url) entry.listeners.clear(); + this.trim(); + }; + @@ -6464,7 +7929,7 @@ index 0000000..d7fceab + this.worker?.terminate(); + this.worker = undefined; + this.requests.forEach((entry) => { -+ entry.listeners.forEach((listener) => listener.reject()); ++ if (!entry.url) entry.listeners.forEach((listener) => listener.reject()); + entry.listeners.clear(); + }); + this.requests.clear(); @@ -6489,28 +7954,30 @@ index 0000000..d7fceab + document: Document, + uri: string, + resolve: (url: string) => void, -+ reject: () => void -+): () => void { ++ reject: () => void, ++ priority = 2 ++): AvatarLease { + let cache = documentCaches.get(document); + if (!cache) { + cache = new NativeListWebAvatarCache(); + documentCaches.set(document, cache); + } -+ return cache.acquire(uri, resolve, reject); ++ return cache.acquire(uri, resolve, reject, priority); +} diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -index 11d57d1..1ff0801 100644 +index 11d57d1..80b8871 100644 --- a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -@@ -37,6 +37,7 @@ import { +@@ -37,6 +37,8 @@ import { type NormalizedPositionScroll, } from '../scrolling'; import { applyRowPatches, validateSnapshot } from '../validation'; -+import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from './NativeListWebAvatarCache'; ++import { avatarPrefetchWindow } from '../avatarPrefetch'; ++import { acquireNativeListAvatar, canonicalNativeListAvatarUri, type AvatarLease } from './NativeListWebAvatarCache'; const SECTION_INDEX_GUTTER = 44; const DEFAULT_VIEWPORT_WIDTH = 320; -@@ -354,11 +355,23 @@ export function estimateWebRowHeight( +@@ -354,11 +356,23 @@ export function estimateWebRowHeight( snapshot: NativeListSnapshot, availableWidth: number ): number { @@ -6537,7 +8004,7 @@ index 11d57d1..1ff0801 100644 if (row.type === 'identity' && row.presentation === 'networkSelector') return 47; if (row.type === 'identity' && row.presentation === 'accountSelector') -@@ -699,7 +712,9 @@ export function webRowRenderSignature(row: RowModel): string { +@@ -699,7 +713,9 @@ export function webRowRenderSignature(row: RowModel): string { return JSON.stringify(rowWithoutSelectionState(row)); } @@ -6547,7 +8014,7 @@ index 11d57d1..1ff0801 100644 .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -754,7 +769,27 @@ export const WEB_LIST_CSS = ` +@@ -754,7 +770,27 @@ export const WEB_LIST_CSS = ` .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} @@ -6575,7 +8042,7 @@ index 11d57d1..1ff0801 100644 `; function createElement( -@@ -788,16 +823,114 @@ function safeImageUri(uri: string): string | undefined { +@@ -788,16 +824,114 @@ function safeImageUri(uri: string): string | undefined { return undefined; } @@ -6692,7 +8159,7 @@ index 11d57d1..1ff0801 100644 image.alt = ''; image.draggable = false; image.loading = 'lazy'; -@@ -807,6 +940,43 @@ function createImage( +@@ -807,6 +941,43 @@ function createImage( return image; } @@ -6736,7 +8203,7 @@ index 11d57d1..1ff0801 100644 function iconGlyph(name: string): string { const normalized = name.toLocaleLowerCase(); if (normalized.includes('chevron')) { -@@ -841,7 +1011,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { +@@ -841,7 +1012,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { function createVisual( context: RenderContext, @@ -6746,7 +8213,7 @@ index 11d57d1..1ff0801 100644 ): HTMLElement | undefined { if (!visual) return undefined; if (visual.kind === 'stackedImages') { -@@ -873,6 +1044,7 @@ function createVisual( +@@ -873,6 +1045,7 @@ function createVisual( 'ok-native-list-visual-fallback', iconGlyph(visual.name) ); @@ -6754,7 +8221,7 @@ index 11d57d1..1ff0801 100644 if (visual.tintColor) fallback.style.color = visual.tintColor; frame.appendChild(fallback); return frame; -@@ -885,6 +1057,7 @@ function createVisual( +@@ -885,6 +1058,7 @@ function createVisual( if (image) { image.className = 'ok-native-list-visual-main'; frame.appendChild(image); @@ -6762,7 +8229,7 @@ index 11d57d1..1ff0801 100644 } else { frame.appendChild( createElement( -@@ -913,8 +1086,51 @@ function createVisual( +@@ -913,8 +1087,51 @@ function createVisual( corner.style.color = visual.cornerIcon.tintColor; if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; @@ -6814,7 +8281,7 @@ index 11d57d1..1ff0801 100644 return frame; } -@@ -1022,6 +1238,16 @@ function createCheckbox( +@@ -1022,6 +1239,16 @@ function createCheckbox( setData(element, 'checkboxFallback', accessory.state); setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); @@ -6831,7 +8298,7 @@ index 11d57d1..1ff0801 100644 if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey); else if (accessory.target?.scope === 'row') -@@ -1046,6 +1272,7 @@ function createIconAction( +@@ -1046,6 +1273,7 @@ function createIconAction( element.setAttribute('type', 'button'); element.toggleAttribute('disabled', Boolean(disabled)); } @@ -6839,7 +8306,7 @@ index 11d57d1..1ff0801 100644 if (actionKey) setData(element, 'nativeListAction', actionKey); if (tintColor) element.style.color = tintColor; return element; -@@ -1060,6 +1287,20 @@ function markActionAnchorSource( +@@ -1060,6 +1288,20 @@ function markActionAnchorSource( if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } @@ -6860,7 +8327,7 @@ index 11d57d1..1ff0801 100644 function createAccessory( context: RenderContext, rowKey: string, -@@ -1080,6 +1321,18 @@ function createAccessory( +@@ -1080,6 +1322,18 @@ function createAccessory( accessory.tintColor ); markActionAnchorSource(element, 'trailingAccessory', slot); @@ -6879,7 +8346,7 @@ index 11d57d1..1ff0801 100644 return element; } if (accessory.kind === 'spinner') { -@@ -1099,6 +1352,7 @@ function createAccessory( +@@ -1099,6 +1353,7 @@ function createAccessory( switch (accessory.kind) { case 'value': element.textContent = accessory.text; @@ -6887,7 +8354,7 @@ index 11d57d1..1ff0801 100644 if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); break; -@@ -1157,6 +1411,13 @@ function appendAccessories( +@@ -1157,6 +1412,13 @@ function appendAccessories( 'span', 'ok-native-list-accessories' ); @@ -6901,7 +8368,7 @@ index 11d57d1..1ff0801 100644 accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot)) ); -@@ -1234,7 +1495,59 @@ function createSectionHeader( +@@ -1234,7 +1496,59 @@ function createSectionHeader( markActionAnchorSource(titleIcon, 'leadingAction'); body.appendChild(titleIcon); } @@ -6962,7 +8429,7 @@ index 11d57d1..1ff0801 100644 if (row.value) { const value = createElement( context.document, -@@ -1244,6 +1557,15 @@ function createSectionHeader( +@@ -1244,6 +1558,15 @@ function createSectionHeader( : 'ok-native-list-value ok-native-list-section-value', row.value ); @@ -6978,7 +8445,7 @@ index 11d57d1..1ff0801 100644 if (row.valueActionKey) { value.setAttribute('type', 'button'); setData(value, 'nativeListAction', row.valueActionKey); -@@ -1292,6 +1614,7 @@ function createActionRow( +@@ -1292,6 +1615,7 @@ function createActionRow( row.title ); setData(title, 'tone', row.tone); @@ -6986,7 +8453,7 @@ index 11d57d1..1ff0801 100644 body.appendChild(title); if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); -@@ -1309,6 +1632,13 @@ function createSystemRow( +@@ -1309,6 +1633,13 @@ function createSystemRow( 'ok-native-list-row ok-native-list-system' ); setData(body, 'variant', row.variant); @@ -7000,7 +8467,7 @@ index 11d57d1..1ff0801 100644 if (row.variant === 'loading') body.appendChild( createElement(context.document, 'span', 'ok-native-list-spinner') -@@ -1703,6 +2033,11 @@ function createIdentityActivityOrMessageRow( +@@ -1703,6 +2034,11 @@ function createIdentityActivityOrMessageRow( .filter(Boolean) .join(' ') ); @@ -7012,7 +8479,7 @@ index 11d57d1..1ff0801 100644 if (row.type === 'identity' && row.leadingAction) { const action = createIconAction( context, -@@ -1714,7 +2049,15 @@ function createIdentityActivityOrMessageRow( +@@ -1714,7 +2050,15 @@ function createIdentityActivityOrMessageRow( markActionAnchorSource(action, 'leadingAction'); body.appendChild(action); } @@ -7029,7 +8496,7 @@ index 11d57d1..1ff0801 100644 if (visual) body.appendChild(visual); if (row.type === 'activity' && row.secondaryLeading) { const secondVisual = createVisual(context, row.secondaryLeading); -@@ -1737,8 +2080,44 @@ function createIdentityActivityOrMessageRow( +@@ -1737,8 +2081,44 @@ function createIdentityActivityOrMessageRow( subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, @@ -7075,7 +8542,7 @@ index 11d57d1..1ff0801 100644 if (row.type === 'activity' && row.status) column.appendChild( createElement( -@@ -1814,6 +2193,13 @@ function createIdentityActivityOrMessageRow( +@@ -1814,6 +2194,13 @@ function createIdentityActivityOrMessageRow( return body; } @@ -7089,7 +8556,7 @@ index 11d57d1..1ff0801 100644 function createWalletGroupRow( context: RenderContext, row: Extract -@@ -1830,11 +2216,18 @@ function createWalletGroupRow( +@@ -1830,11 +2217,18 @@ function createWalletGroupRow( 'ok-native-list-wallet-member' ); setData(memberElement, 'nativeListGroupMemberKey', member.key); @@ -7111,7 +8578,18 @@ index 11d57d1..1ff0801 100644 body.appendChild(memberElement); }); return body; -@@ -1919,6 +2312,8 @@ export class NativeListWebEngine { +@@ -1894,6 +2288,10 @@ export class NativeListWebEngine { + private resizeObserver: ResizeObserver | undefined; + private pendingScroll: PendingScroll | undefined; + private lastVisibleSignature: string | undefined; ++ // OneKey patch: URI leases outlive DOM overscan only inside this bounded window. ++ private readonly avatarLeases = new Map(); ++ private avatarOffset = 0; ++ private avatarDirection = 1; + private reachedGeneration: number | undefined; + private stickyKey: string | undefined; + private previewTimer: number | undefined; +@@ -1919,6 +2317,8 @@ export class NativeListWebEngine { private lastViewportWidth = -1; private lastViewportHeight = -1; private destroyed = false; @@ -7120,7 +8598,7 @@ index 11d57d1..1ff0801 100644 constructor( host: HTMLElement, -@@ -2004,6 +2399,9 @@ export class NativeListWebEngine { +@@ -2004,6 +2404,9 @@ export class NativeListWebEngine { passive: true, }); this.root.addEventListener('click', this.handleClick); @@ -7130,7 +8608,7 @@ index 11d57d1..1ff0801 100644 this.root.addEventListener('keydown', this.handleKeyDown); this.viewport.addEventListener( 'pointerdown', -@@ -2160,7 +2558,7 @@ export class NativeListWebEngine { +@@ -2160,7 +2563,7 @@ export class NativeListWebEngine { const index = resolveLocationIndex(this.snapshot.rows, params); if (index === undefined) { const sectionCount = this.snapshot.rows.filter( @@ -7139,7 +8617,7 @@ index 11d57d1..1ff0801 100644 ).length; this.emitScrollFailure( params.itemIndex, -@@ -2219,6 +2617,8 @@ export class NativeListWebEngine { +@@ -2219,6 +2622,8 @@ export class NativeListWebEngine { ); this.viewport.removeEventListener('scroll', this.handleScroll); this.root.removeEventListener('click', this.handleClick); @@ -7148,7 +8626,7 @@ index 11d57d1..1ff0801 100644 this.root.removeEventListener('keydown', this.handleKeyDown); this.cancelPointerReorder(true); this.viewport.removeEventListener( -@@ -2251,6 +2651,8 @@ export class NativeListWebEngine { +@@ -2251,18 +2656,23 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointerup', this.handlePullEnd); this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; @@ -7157,7 +8635,14 @@ index 11d57d1..1ff0801 100644 this.root.remove(); this.hideReorderPreview(); this.reorderPreview.remove(); -@@ -2263,6 +2665,7 @@ export class NativeListWebEngine { + if (host) host.style.position = this.previousHostPosition; + this.mounted.clear(); + this.pool.length = 0; ++ this.avatarLeases.forEach((release) => release()); ++ this.avatarLeases.clear(); + } + + private setSnapshot( snapshot: NativeListSnapshot, selectedKeys?: ReadonlySet ) { @@ -7165,7 +8650,7 @@ index 11d57d1..1ff0801 100644 this.snapshot = snapshot; this.rows = effectiveRows(snapshot); this.selectedKeys = -@@ -2288,6 +2691,11 @@ export class NativeListWebEngine { +@@ -2288,6 +2698,11 @@ export class NativeListWebEngine { '--nl-primary': theme.primaryText, '--nl-secondary': theme.secondaryText, '--nl-disabled': theme.disabledText, @@ -7177,7 +8662,7 @@ index 11d57d1..1ff0801 100644 '--nl-icon': theme.icon, '--nl-icon-subdued': theme.iconSubdued, '--nl-separator': theme.separator, -@@ -2316,12 +2724,17 @@ export class NativeListWebEngine { +@@ -2316,12 +2731,17 @@ export class NativeListWebEngine { viewportHeight !== this.lastViewportHeight) ) { this.invalidateActionAnchor('layout'); @@ -7196,7 +8681,46 @@ index 11d57d1..1ff0801 100644 viewportWidth, viewportHeight, this.reorderCompactKey -@@ -2350,6 +2763,7 @@ export class NativeListWebEngine { +@@ -2336,7 +2756,38 @@ export class NativeListWebEngine { + this.performPendingScroll(); + }; + ++ private updateAvatarWindow() { ++ const offset = this.currentOffset(); ++ if (offset !== this.avatarOffset) this.avatarDirection = Math.sign(offset - this.avatarOffset); ++ this.avatarOffset = offset; ++ const visible = visibleWebLayoutItems(this.layout, offset, this.viewportLength(), 0); ++ const first = visible[0]?.index ?? -1; ++ const last = visible[visible.length - 1]?.index ?? -1; ++ const candidates = avatarPrefetchWindow(this.rows, first, last, this.avatarDirection); ++ const desired = new Set(candidates.map(({ source }) => canonicalNativeListAvatarUri(source.uri)).filter((uri) => uri !== undefined)); ++ this.avatarLeases.forEach((release, uri) => { ++ if (!desired.has(uri)) { release(); this.avatarLeases.delete(uri); } ++ }); ++ candidates.forEach(({ source, priority }) => { ++ const uri = canonicalNativeListAvatarUri(source.uri); ++ if (!uri) return; ++ const existing = this.avatarLeases.get(uri); ++ if (existing) existing.setPriority(priority); ++ else { ++ let lease: AvatarLease | undefined; ++ lease = acquireNativeListAvatar(this.document, uri, () => {}, () => { ++ if (this.avatarLeases.get(uri) === lease) { ++ lease?.(); ++ this.avatarLeases.delete(uri); ++ } ++ }, priority); ++ this.avatarLeases.set(uri, lease); ++ } ++ }); ++ } ++ + private renderWindow() { ++ this.updateAvatarWindow(); + const viewportLength = this.viewportLength(); + const visible = webLayoutItemsForMount( + this.layout, +@@ -2350,6 +2801,7 @@ export class NativeListWebEngine { if (!desired.has(index)) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); @@ -7204,7 +8728,7 @@ index 11d57d1..1ff0801 100644 element.remove(); this.pool.push(element); } -@@ -2377,6 +2791,17 @@ export class NativeListWebEngine { +@@ -2377,6 +2829,17 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); @@ -7222,7 +8746,7 @@ index 11d57d1..1ff0801 100644 this.updateVisibleSelection(); this.updateVisibleState(); } -@@ -2395,6 +2820,7 @@ export class NativeListWebEngine { +@@ -2395,6 +2858,7 @@ export class NativeListWebEngine { overlay = false ) { this.invalidateActionAnchorForElement(element); @@ -7230,7 +8754,7 @@ index 11d57d1..1ff0801 100644 const bindingEpoch = String(++this.bindingEpochCounter); element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' -@@ -2403,6 +2829,9 @@ export class NativeListWebEngine { +@@ -2403,6 +2867,9 @@ export class NativeListWebEngine { setData(element, 'nativeListBindingEpoch', bindingEpoch); setData(element, 'nativeListRowIndex', index); setData(element, 'nativeListDisabled', Boolean(row.disabled)); @@ -7240,7 +8764,7 @@ index 11d57d1..1ff0801 100644 setData(element, 'nativeListReorderable', this.isReorderable(row)); setData( element, -@@ -2435,12 +2864,48 @@ export class NativeListWebEngine { +@@ -2435,12 +2902,48 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index, }; @@ -7290,7 +8814,7 @@ index 11d57d1..1ff0801 100644 this.footer.replaceChildren(); if (!row) return; const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -2487,7 +2952,9 @@ export class NativeListWebEngine { +@@ -2487,7 +2990,9 @@ export class NativeListWebEngine { private updateVisibleSelection() { const update = (element: HTMLElement, row: RowModel | undefined) => { if (!row) return; @@ -7301,7 +8825,7 @@ index 11d57d1..1ff0801 100644 setData(element, 'nativeListSelected', selected); element.setAttribute('aria-selected', String(selected)); element -@@ -2548,7 +3015,9 @@ export class NativeListWebEngine { +@@ -2548,7 +3053,9 @@ export class NativeListWebEngine { } this.checkEndReached(last?.index ?? -1); this.updateStickyHeader(first?.index ?? -1); @@ -7312,7 +8836,7 @@ index 11d57d1..1ff0801 100644 } private updateStickyHeader(firstVisibleIndex: number) { -@@ -2564,7 +3033,7 @@ export class NativeListWebEngine { +@@ -2564,7 +3071,7 @@ export class NativeListWebEngine { let index = -1; for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { const row = this.rows[cursor]; @@ -7321,7 +8845,7 @@ index 11d57d1..1ff0801 100644 index = cursor; break; } -@@ -2594,7 +3063,7 @@ export class NativeListWebEngine { +@@ -2594,7 +3101,7 @@ export class NativeListWebEngine { for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { const candidate = this.rows[cursor]; if ( @@ -7330,7 +8854,7 @@ index 11d57d1..1ff0801 100644 candidate.variant !== 'summary' ) { nextIndex = cursor; -@@ -2610,12 +3079,15 @@ export class NativeListWebEngine { +@@ -2610,12 +3117,15 @@ export class NativeListWebEngine { this.updateVisibleSelection(); } @@ -7349,7 +8873,7 @@ index 11d57d1..1ff0801 100644 row.indexTitle ) activeKey = row.key; -@@ -2793,7 +3265,9 @@ export class NativeListWebEngine { +@@ -2793,7 +3303,9 @@ export class NativeListWebEngine { if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; this.invalidateActionAnchor('rebind'); @@ -7360,7 +8884,7 @@ index 11d57d1..1ff0801 100644 const token = [ this.actionAnchorInstanceId, this.snapshot.generation, -@@ -2866,7 +3340,9 @@ export class NativeListWebEngine { +@@ -2866,7 +3378,9 @@ export class NativeListWebEngine { rowElement?: HTMLElement, sourceElement = rowElement ) { @@ -7371,7 +8895,7 @@ index 11d57d1..1ff0801 100644 if ( this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && -@@ -2894,6 +3370,28 @@ export class NativeListWebEngine { +@@ -2894,6 +3408,28 @@ export class NativeListWebEngine { } } @@ -7400,7 +8924,7 @@ index 11d57d1..1ff0801 100644 private handleClick = (event: Event) => { if (Date.now() < this.suppressClickUntil) { event.preventDefault(); -@@ -2944,6 +3442,12 @@ export class NativeListWebEngine { +@@ -2944,6 +3480,12 @@ export class NativeListWebEngine { }; private handleKeyDown = (event: KeyboardEvent) => { @@ -7413,7 +8937,16 @@ index 11d57d1..1ff0801 100644 if (event.key === 'Escape') { if (this.pointerReorder?.active) { event.preventDefault(); -@@ -3203,12 +3707,13 @@ export class NativeListWebEngine { +@@ -3073,7 +3615,7 @@ export class NativeListWebEngine { + this.frameHandle = this.requestFrame(() => { + this.frameHandle = undefined; + if (this.virtualizationEnabled) this.renderWindow(); +- else this.updateVisibleState(); ++ else { this.updateAvatarWindow(); this.updateVisibleState(); } + }); + } + +@@ -3203,12 +3745,13 @@ export class NativeListWebEngine { const index = Number(rowElement?.dataset.nativeListRowIndex); const row = this.rows[index]; if (!row || !this.isReorderable(row)) return; @@ -7433,7 +8966,7 @@ index 11d57d1..1ff0801 100644 const view = this.document.defaultView; const state: PointerReorderState = { -@@ -3225,9 +3730,8 @@ export class NativeListWebEngine { +@@ -3225,9 +3768,8 @@ export class NativeListWebEngine { active: false, }; this.pointerReorder = state; @@ -7445,7 +8978,7 @@ index 11d57d1..1ff0801 100644 state.longPressTimer = view?.setTimeout( () => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS -@@ -3363,6 +3867,18 @@ export class NativeListWebEngine { +@@ -3363,6 +3905,18 @@ export class NativeListWebEngine { Math.max(0, state.startY - rect.top) ); this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); @@ -7464,7 +8997,7 @@ index 11d57d1..1ff0801 100644 const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) -@@ -3430,6 +3946,7 @@ export class NativeListWebEngine { +@@ -3430,6 +3984,7 @@ export class NativeListWebEngine { private clearReorderPreviewVisual() { this.reorderPreview.hidden = true; From 105e3f8c992e8c7b04cd36bf41e4c983f6ad23a1 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 18:22:46 +0800 Subject: [PATCH 04/18] docs: record selector performance follow-up and remaining stalls --- docs/native-list-selector-performance.md | 173 +++++++++++++++++++++-- 1 file changed, 165 insertions(+), 8 deletions(-) diff --git a/docs/native-list-selector-performance.md b/docs/native-list-selector-performance.md index 4f54e19e5431..4bd67604c149 100644 --- a/docs/native-list-selector-performance.md +++ b/docs/native-list-selector-performance.md @@ -2,7 +2,7 @@ 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 URI avatar/cache change passes the listed pixel and persistence checks. Steady scrolling is approximately 60 JS rAF FPS. **Cold opening and first-time avatar loading during extreme scrolling have not passed an all-scenarios full-frame requirement.** +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 has not passed; investigation is ongoing. Earlier URI-only pixel/persistence results are retained below with their original provenance. ## Implementation @@ -38,7 +38,164 @@ 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. -## Measurement conditions +## 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. + +| Current patch | 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`. + +### Remaining acceptance boundary + +The corrected Desktop 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 Desktop was launched with the normal production renderer and an isolated file-origin QA profile. Wallet resource images load, but NativeList account avatars show the error state, so the first-modal readiness gate failed and no valid production performance sample was produced. The fixture was restored; the separate development-mode client remains running. This loading failure is under investigation. Building a renderer alone does not establish production UI/performance acceptance. 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 | | --- | --- | --- | @@ -54,7 +211,7 @@ Network tests use the QA environment's normal server/configuration data: 157 all 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 +### Steady scrolling | Target | List | Sampled intervals | JS rAF FPS | P95 (ms) | Maximum (ms) | Intervals >25 ms | | --- | --- | ---: | ---: | ---: | ---: | ---: | @@ -75,7 +232,7 @@ The native rolling UI sampler reported 59.94-60.09 for the iOS account list and 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 +### 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) | | --- | ---: | ---: | ---: | ---: | @@ -92,7 +249,7 @@ The old approximately 7.2 s iOS result is an entire JS long task that included e 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 +### Avatar pixels, persistence, and visibility | Check | Observed result | Boundary | | --- | --- | --- | @@ -109,7 +266,7 @@ A same-code host V8 descriptor-construction comparison measured approximately 62 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 +### 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. @@ -120,7 +277,7 @@ A separate Desktop test scrolls four legs at 6500 px/s and samples visible DOM/i 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 +### 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. @@ -138,4 +295,4 @@ Measurements were captured on application parent `b5b152a1adcccde687e6fbb3b437de 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. -Open acceptance items are cold-page initialization long tasks, first-visit avatar waiting at extreme scroll speeds, actual extension runtime coverage, and Release-device/high-refresh-rate measurement. +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. From 3b151d7fd32ecb0453a344451fe00afc380f8eb5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 18:56:25 +0800 Subject: [PATCH 05/18] fix: load native list avatar worker in production desktop --- docs/native-list-selector-performance.md | 31 +- ...yfe+react-native-native-list+3.0.105.patch | 296 +++++++++++++++--- 2 files changed, 281 insertions(+), 46 deletions(-) diff --git a/docs/native-list-selector-performance.md b/docs/native-list-selector-performance.md index 4bd67604c149..7ba18e80dab1 100644 --- a/docs/native-list-selector-performance.md +++ b/docs/native-list-selector-performance.md @@ -2,7 +2,7 @@ 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 has not passed; investigation is ongoing. Earlier URI-only pixel/persistence results are retained below with their original provenance. +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 @@ -42,7 +42,7 @@ cache capacity; it is not evidence that the previous memory sample was a leak. 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. -| Current patch | SHA-256 | +| 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` | @@ -185,9 +185,34 @@ Web avatar limits also need distinct ownership labels: 128 is the document cache 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 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 Desktop was launched with the normal production renderer and an isolated file-origin QA profile. Wallet resource images load, but NativeList account avatars show the error state, so the first-modal readiness gate failed and no valid production performance sample was produced. The fixture was restored; the separate development-mode client remains running. This loading failure is under investigation. Building a renderer alone does not establish production UI/performance acceptance. 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. +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. diff --git a/patches/@onekeyfe+react-native-native-list+3.0.105.patch b/patches/@onekeyfe+react-native-native-list+3.0.105.patch index f4c8df04e767..eeef36b046d3 100644 --- a/patches/@onekeyfe+react-native-native-list+3.0.105.patch +++ b/patches/@onekeyfe+react-native-native-list+3.0.105.patch @@ -4301,10 +4301,10 @@ index 0000000..5c27e23 +}; diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js new file mode 100644 -index 0000000..3e1fa51 +index 0000000..f896e62 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js -@@ -0,0 +1,162 @@ +@@ -0,0 +1,266 @@ +"use strict"; + +// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. @@ -4323,6 +4323,8 @@ index 0000000..3e1fa51 + entries = new Map(); + requests = new Map(); + nextId = 0; ++ // OneKey patch: one cancellable startup serves every pending avatar lease. ++ workerGeneration = 0; + acquire(uri, resolve, reject, priority = 2) { + let entry = this.entries.get(uri); + const isNew = !entry; @@ -4350,24 +4352,25 @@ index 0000000..3e1fa51 + // else current.listeners.add(listener); + current.listeners.add(listener); + if (isNew) { -+ try { -+ if (!this.worker) { -+ // Deliberately avoid *.worker.js and its inline Blob-worker loader. -+ this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { -+ name: 'onekey-native-list-avatar' -+ }); -+ this.worker.addEventListener('message', this.handleMessage); -+ this.worker.addEventListener('error', this.handleFailure); -+ this.worker.addEventListener('messageerror', this.handleFailure); -+ } -+ this.worker.postMessage({ -+ type: 'acquire', -+ id: current.id, -+ uri, -+ priority -+ }); -+ } catch { -+ queueMicrotask(this.handleFailure); ++ // OneKey patch: file workers need async asset loading; keep pending requests in this cache. ++ // try { ++ // if (!this.worker) { ++ // // Deliberately avoid *.worker.js and its inline Blob-worker loader. ++ // this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { ++ // name: 'onekey-native-list-avatar', ++ // }); ++ // this.worker.addEventListener('message', this.handleMessage); ++ // this.worker.addEventListener('error', this.handleFailure); ++ // this.worker.addEventListener('messageerror', this.handleFailure); ++ // } ++ // this.worker.postMessage({ type: 'acquire', id: current.id, uri, priority }); ++ // } catch { ++ // queueMicrotask(this.handleFailure); ++ // } ++ if (this.worker) { ++ this.sendAcquire(current); ++ } else { ++ this.startWorker(); + } + } + const updatePriority = () => { @@ -4394,6 +4397,12 @@ index 0000000..3e1fa51 + type: 'release', + id: current.id + }); ++ if (this.requests.size === 0 && this.startingWorker) { ++ const starting = this.startingWorker; ++ this.startingWorker = undefined; ++ this.workerGeneration += 1; ++ starting.controller?.abort(); ++ } + } + updatePriority(); + this.trim(); @@ -4406,6 +4415,86 @@ index 0000000..3e1fa51 + } + }); + } ++ ++ // OneKey patch: HTTP/extension keeps the bundler's original Worker entry. Electron's ++ // file interceptor can serve the same self-contained source as an asset for a Blob worker. ++ startWorker() { ++ if (this.worker || this.startingWorker) return; ++ const starting = { ++ generation: ++this.workerGeneration ++ }; ++ this.startingWorker = starting; ++ const assetURL = new URL('./NativeListAvatarWorker.js', import.meta.url); ++ if (assetURL.protocol === 'file:') { ++ starting.controller = new AbortController(); ++ void (async () => { ++ const response = await fetch(assetURL, { ++ signal: starting.controller?.signal ++ }); ++ if (!response.ok) throw new Error('NativeList avatar worker asset failed to load'); ++ const source = await response.blob(); ++ if (this.startingWorker !== starting) return; ++ const sourceURL = URL.createObjectURL(source); ++ try { ++ this.activateWorker(new Worker(sourceURL, { ++ name: 'onekey-native-list-avatar' ++ }), starting); ++ } finally { ++ // Worker construction captures the script URL; the document must not retain its bytes. ++ URL.revokeObjectURL(sourceURL); ++ } ++ })().catch(() => { ++ if (this.startingWorker === starting || this.workerGeneration === starting.generation) { ++ this.handleFailure(); ++ } ++ }); ++ return; ++ } ++ try { ++ this.activateWorker(new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { ++ name: 'onekey-native-list-avatar' ++ }), starting); ++ } catch { ++ queueMicrotask(() => { ++ if (this.workerGeneration === starting.generation) this.handleFailure(); ++ }); ++ } ++ } ++ activateWorker(worker, starting) { ++ if (this.startingWorker !== starting) { ++ worker.terminate(); ++ return; ++ } ++ this.startingWorker = undefined; ++ this.worker = worker; ++ // OneKey patch: queued events from a failed instance cannot affect its replacement. ++ worker.addEventListener('message', event => { ++ if (this.worker === worker) this.handleMessage(event); ++ }); ++ const failed = () => { ++ if (this.worker === worker) this.handleFailure(); ++ }; ++ worker.addEventListener('error', failed); ++ worker.addEventListener('messageerror', failed); ++ this.requests.forEach(entry => { ++ if (entry.references > 0 && !entry.url) this.sendAcquire(entry); ++ }); ++ } ++ sendAcquire(entry) { ++ const worker = this.worker; ++ try { ++ worker?.postMessage({ ++ type: 'acquire', ++ id: entry.id, ++ uri: entry.uri, ++ priority: Math.min(2, ...[...entry.listeners].map(listener => listener.priority)) ++ }); ++ } catch { ++ queueMicrotask(() => { ++ if (this.worker === worker) this.handleFailure(); ++ }); ++ } ++ } + handleMessage = event => { + const response = event.data; + if (!response || !Number.isSafeInteger(response.id)) return; @@ -4435,14 +4524,29 @@ index 0000000..3e1fa51 + this.trim(); + }; + handleFailure = () => { ++ // OneKey patch: detach the failed generation before callbacks can acquire a replacement. ++ // this.worker?.terminate(); ++ // this.worker = undefined; ++ // this.requests.forEach((entry) => { ++ // if (!entry.url) entry.listeners.forEach((listener) => listener.reject()); ++ // entry.listeners.clear(); ++ // }); ++ // this.requests.clear(); ++ // this.entries.clear(); ++ const failedEntries = [...this.requests.values()]; ++ const starting = this.startingWorker; ++ this.startingWorker = undefined; ++ this.workerGeneration += 1; ++ starting?.controller?.abort(); + this.worker?.terminate(); + this.worker = undefined; -+ this.requests.forEach(entry => { -+ if (!entry.url) entry.listeners.forEach(listener => listener.reject()); -+ entry.listeners.clear(); -+ }); + this.requests.clear(); + this.entries.clear(); ++ failedEntries.forEach(entry => { ++ const listeners = [...entry.listeners]; ++ entry.listeners.clear(); ++ if (!entry.url) listeners.forEach(listener => listener.reject()); ++ }); + }; + trim() { + for (const [uri, entry] of this.entries) { @@ -7802,10 +7906,10 @@ index 0000000..5c27e23 +}; diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts new file mode 100644 -index 0000000..18ba311 +index 0000000..d921a4c --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts -@@ -0,0 +1,158 @@ +@@ -0,0 +1,264 @@ +// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. +const AVATAR_PREFIX = 'onekey-avatar://blockie/v1/'; +const MAX_RETAINED_AVATARS = 128; @@ -7838,6 +7942,9 @@ index 0000000..18ba311 + private readonly requests = new Map(); + private nextId = 0; + private worker: Worker | undefined; ++ // OneKey patch: one cancellable startup serves every pending avatar lease. ++ private workerGeneration = 0; ++ private startingWorker: { generation: number; controller?: AbortController } | undefined; + + acquire(uri: string, resolve: (url: string) => void, reject: () => void, priority = 2): AvatarLease { + let entry = this.entries.get(uri); @@ -7857,19 +7964,25 @@ index 0000000..18ba311 + // else current.listeners.add(listener); + current.listeners.add(listener); + if (isNew) { -+ try { -+ if (!this.worker) { -+ // Deliberately avoid *.worker.js and its inline Blob-worker loader. -+ this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { -+ name: 'onekey-native-list-avatar', -+ }); -+ this.worker.addEventListener('message', this.handleMessage); -+ this.worker.addEventListener('error', this.handleFailure); -+ this.worker.addEventListener('messageerror', this.handleFailure); -+ } -+ this.worker.postMessage({ type: 'acquire', id: current.id, uri, priority }); -+ } catch { -+ queueMicrotask(this.handleFailure); ++ // OneKey patch: file workers need async asset loading; keep pending requests in this cache. ++ // try { ++ // if (!this.worker) { ++ // // Deliberately avoid *.worker.js and its inline Blob-worker loader. ++ // this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { ++ // name: 'onekey-native-list-avatar', ++ // }); ++ // this.worker.addEventListener('message', this.handleMessage); ++ // this.worker.addEventListener('error', this.handleFailure); ++ // this.worker.addEventListener('messageerror', this.handleFailure); ++ // } ++ // this.worker.postMessage({ type: 'acquire', id: current.id, uri, priority }); ++ // } catch { ++ // queueMicrotask(this.handleFailure); ++ // } ++ if (this.worker) { ++ this.sendAcquire(current); ++ } else { ++ this.startWorker(); + } + } + const updatePriority = () => { @@ -7889,6 +8002,12 @@ index 0000000..18ba311 + this.entries.delete(uri); + this.requests.delete(current.id); + this.worker?.postMessage({ type: 'release', id: current.id }); ++ if (this.requests.size === 0 && this.startingWorker) { ++ const starting = this.startingWorker; ++ this.startingWorker = undefined; ++ this.workerGeneration += 1; ++ starting.controller?.abort(); ++ } + } + updatePriority(); + this.trim(); @@ -7902,6 +8021,82 @@ index 0000000..18ba311 + }); + } + ++ // OneKey patch: HTTP/extension keeps the bundler's original Worker entry. Electron's ++ // file interceptor can serve the same self-contained source as an asset for a Blob worker. ++ private startWorker() { ++ if (this.worker || this.startingWorker) return; ++ const starting: NonNullable = { ++ generation: ++this.workerGeneration, ++ }; ++ this.startingWorker = starting; ++ const assetURL = new URL('./NativeListAvatarWorker.js', import.meta.url); ++ if (assetURL.protocol === 'file:') { ++ starting.controller = new AbortController(); ++ void (async () => { ++ const response = await fetch(assetURL, { ++ signal: starting.controller?.signal, ++ }); ++ if (!response.ok) throw new Error('NativeList avatar worker asset failed to load'); ++ const source = await response.blob(); ++ if (this.startingWorker !== starting) return; ++ const sourceURL = URL.createObjectURL(source); ++ try { ++ this.activateWorker(new Worker(sourceURL, { name: 'onekey-native-list-avatar' }), starting); ++ } finally { ++ // Worker construction captures the script URL; the document must not retain its bytes. ++ URL.revokeObjectURL(sourceURL); ++ } ++ })().catch(() => { ++ if (this.startingWorker === starting || this.workerGeneration === starting.generation) { ++ this.handleFailure(); ++ } ++ }); ++ return; ++ } ++ try { ++ this.activateWorker(new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { ++ name: 'onekey-native-list-avatar', ++ }), starting); ++ } catch { ++ queueMicrotask(() => { ++ if (this.workerGeneration === starting.generation) this.handleFailure(); ++ }); ++ } ++ } ++ ++ private activateWorker(worker: Worker, starting: NonNullable) { ++ if (this.startingWorker !== starting) { ++ worker.terminate(); ++ return; ++ } ++ this.startingWorker = undefined; ++ this.worker = worker; ++ // OneKey patch: queued events from a failed instance cannot affect its replacement. ++ worker.addEventListener('message', (event: MessageEvent) => { ++ if (this.worker === worker) this.handleMessage(event); ++ }); ++ const failed = () => { if (this.worker === worker) this.handleFailure(); }; ++ worker.addEventListener('error', failed); ++ worker.addEventListener('messageerror', failed); ++ this.requests.forEach((entry) => { ++ if (entry.references > 0 && !entry.url) this.sendAcquire(entry); ++ }); ++ } ++ ++ private sendAcquire(entry: AvatarEntry) { ++ const worker = this.worker; ++ try { ++ worker?.postMessage({ ++ type: 'acquire', id: entry.id, uri: entry.uri, ++ priority: Math.min(2, ...[...entry.listeners].map((listener) => listener.priority)), ++ }); ++ } catch { ++ queueMicrotask(() => { ++ if (this.worker === worker) this.handleFailure(); ++ }); ++ } ++ } ++ + private readonly handleMessage = (event: MessageEvent) => { + const response = event.data; + if (!response || !Number.isSafeInteger(response.id)) return; @@ -7926,14 +8121,29 @@ index 0000000..18ba311 + }; + + private readonly handleFailure = () => { ++ // OneKey patch: detach the failed generation before callbacks can acquire a replacement. ++ // this.worker?.terminate(); ++ // this.worker = undefined; ++ // this.requests.forEach((entry) => { ++ // if (!entry.url) entry.listeners.forEach((listener) => listener.reject()); ++ // entry.listeners.clear(); ++ // }); ++ // this.requests.clear(); ++ // this.entries.clear(); ++ const failedEntries = [...this.requests.values()]; ++ const starting = this.startingWorker; ++ this.startingWorker = undefined; ++ this.workerGeneration += 1; ++ starting?.controller?.abort(); + this.worker?.terminate(); + this.worker = undefined; -+ this.requests.forEach((entry) => { -+ if (!entry.url) entry.listeners.forEach((listener) => listener.reject()); -+ entry.listeners.clear(); -+ }); + this.requests.clear(); + this.entries.clear(); ++ failedEntries.forEach((entry) => { ++ const listeners = [...entry.listeners]; ++ entry.listeners.clear(); ++ if (!entry.url) listeners.forEach((listener) => listener.reject()); ++ }); + }; + + private trim() { From ee1e48d3664faa112e187be1393a158210ecaf08 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 19:30:47 +0800 Subject: [PATCH 06/18] fix: handle missing selector DeFi entries --- .../accountSelectorValueV2.test.ts | 22 +++++++++++++++++++ .../WalletDetails/accountSelectorValueV2.ts | 2 +- .../useNetworkListPresentationV2.test.tsx | 21 +++++++++++++++++- .../useNetworkListPresentationV2.ts | 2 +- 4 files changed, 44 insertions(+), 3 deletions(-) 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 index 2e3b9daf4adf..acd24cd2a53b 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.test.ts @@ -86,6 +86,28 @@ describe('account selector V2 values', () => { ).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({ diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts index b96b2d81dabd..e9e4e76516fb 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/WalletDetails/accountSelectorValueV2.ts @@ -102,7 +102,7 @@ export function formatAccountSelectorValueV2({ } else { const deFiAll = Object.values(overview?.overview ?? {}).reduce( (sum, current) => - new BigNumber(sum).plus(current.netWorth ?? '0').toFixed(), + new BigNumber(sum).plus(current?.netWorth ?? '0').toFixed(), perpsNetWorth, ); total = calculateAccountTotalValue({ diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx index 2e9c50bfd792..6fe54c9e47ff 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx @@ -5,7 +5,10 @@ import { renderHook } from '@testing-library/react'; import { numberFormatAsRenderText } from '@onekeyhq/shared/src/utils/numberUtils'; -import { useNetworkListPresentationV2 } from './useNetworkListPresentationV2'; +import { + getNetworkValueV2, + useNetworkListPresentationV2, +} from './useNetworkListPresentationV2'; const mockTheme = new Proxy({}, { get: () => ({ val: '#000000' }) }); let mockHideValue = false; @@ -32,6 +35,22 @@ describe('network currency presentation V2', () => { }; }); + 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')); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts index 9ac60b4523db..e92be23e05ff 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts @@ -43,7 +43,7 @@ export function getNetworkValueV2({ if (network.isAllNetworks) { return Object.values(accountDeFiOverview) .reduce( - (total, value) => total.plus(value.netWorth ?? 0), + (total, value) => total.plus(value?.netWorth ?? 0), Object.values(accountNetworkValues).reduce( (total, value) => total.plus(value ?? '0'), new BigNumber(0), From 4dcf6d0299a394adaf024166a4e8bb43495e6fe9 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 19:41:17 +0800 Subject: [PATCH 07/18] fix: isolate iOS image coder modules --- apps/mobile/ios/Podfile.lock | 2 +- ...@onekeyfe+react-native-image+3.0.105.patch | 120 +++++++++++++++++- 2 files changed, 114 insertions(+), 8 deletions(-) diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index bc4e2eec6f5f..f49ebe49e4bf 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -4498,7 +4498,7 @@ SPEC CHECKSUMS: NetworkInfo: 3a615c690b29bdb27f149d1d4219a260dc517c35 NitroMmkv: 437a1303946283cfefe556d74e989228874aa8c0 NitroModules: d5be9f4559fc5178388ccf3ba2e152230b766d67 - OneKeyImage: 370fde6b94eb0f9889db93a9973bd96479e05052 + OneKeyImage: 4f8b337bd1ec92df01eaa9af755016d9a90c9e8f OneKeyTextInput: 2a260c9713ff205d80a3f644f917e81da479f258 OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e Pbkdf2: f733bdc7b1ea667d48ddb816e5793884d2fdeb5a diff --git a/patches/@onekeyfe+react-native-image+3.0.105.patch b/patches/@onekeyfe+react-native-image+3.0.105.patch index 8a78a8195ceb..d455c7aaf6db 100644 --- a/patches/@onekeyfe+react-native-image+3.0.105.patch +++ b/patches/@onekeyfe+react-native-image+3.0.105.patch @@ -1,3 +1,18 @@ +diff --git a/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec b/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec +index a8befa8..50b91c3 100644 +--- a/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec ++++ b/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec +@@ -12,8 +12,9 @@ Pod::Spec.new do |s| + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" } + +- s.source_files = ["ios/**/*.{swift,m,mm}", "cpp/**/*.{hpp,cpp}"] ++ s.source_files = ["ios/**/*.{h,swift,m,mm}", "cpp/**/*.{hpp,cpp}"] + s.exclude_files = "ios/tests/**/*" ++ s.public_header_files = ["ios/OneKeyImageCoderBridge.h"] + + s.dependency "React-jsi" + s.dependency "React-callinvoker" diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt new file mode 100644 index 0000000..88f0152 @@ -1107,7 +1122,7 @@ index 0000000..7b81e86 +} diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift new file mode 100644 -index 0000000..9b67732 +index 0000000..f4588c6 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift @@ -0,0 +1,217 @@ @@ -1130,14 +1145,14 @@ index 0000000..9b67732 + + private final class Subscription: NSObject, SDWebImageOperation { + let id = UUID() -+ let options: SDWebImageOptions ++ let options: SDWebImage.SDWebImageOptions + let context: [SDWebImageContextOption: Any]? + private let lock = NSLock() + private var completion: SDImageLoaderCompletedBlock? + private var cancellation: (() -> Void)? + private var terminal = false + -+ init(options: SDWebImageOptions, context: [SDWebImageContextOption: Any]?, ++ init(options: SDWebImage.SDWebImageOptions, context: [SDWebImageContextOption: Any]?, + completion: SDImageLoaderCompletedBlock?) { + self.options = options; self.context = context; self.completion = completion + } @@ -1208,7 +1223,7 @@ index 0000000..9b67732 + + func shouldBlockFailedURL(with url: URL, error: Error) -> Bool { false } + -+ func requestImage(with url: URL?, options: SDWebImageOptions, ++ func requestImage(with url: URL?, options: SDWebImage.SDWebImageOptions, + context: [SDWebImageContextOption: Any]?, progress: SDImageLoaderProgressBlock?, + completed: SDImageLoaderCompletedBlock?) -> SDWebImageOperation? { + let subscriber = Subscription(options: options, context: context, completion: completed) @@ -1518,11 +1533,102 @@ index 8517d8a..00f0f20 100644 ) return await load(url: url, context: context, safetyHandle: safetyHandle) } +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.h b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.h +new file mode 100644 +index 0000000..e28d6cf +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.h +@@ -0,0 +1,14 @@ ++#import ++ ++NS_ASSUME_NONNULL_BEGIN ++ ++// Keep optional coder modules out of the Swift compilation unit so SDWebImage ++// Objective-C types are imported under a single Swift module identity. ++@interface OneKeyImageCoderBridge : NSObject ++ +++ (void)addCodersToManager:(id)manager NS_SWIFT_NAME(addCoders(to:)); +++ (void)ensureWebPCoderRegistered; ++ ++@end ++ ++NS_ASSUME_NONNULL_END +diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.m b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.m +new file mode 100644 +index 0000000..beec851 +--- /dev/null ++++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.m +@@ -0,0 +1,25 @@ ++#import "OneKeyImageCoderBridge.h" ++ ++#import ++#import ++#import ++ ++@implementation OneKeyImageCoderBridge ++ +++ (void)addCodersToManager:(id)manager { ++ SDImageCodersManager *coderManager = (SDImageCodersManager *)manager; ++ [coderManager addCoder:SDImageSVGCoder.sharedCoder]; ++ [coderManager addCoder:SDImageWebPCoder.sharedCoder]; ++} ++ +++ (void)ensureWebPCoderRegistered { ++ SDImageCodersManager *manager = SDImageCodersManager.sharedManager; ++ for (id coder in manager.coders) { ++ if (coder == SDImageWebPCoder.sharedCoder) { ++ return; ++ } ++ } ++ [manager addCoder:SDImageWebPCoder.sharedCoder]; ++} ++ ++@end diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift -index 393660a..2a29939 100644 +index 393660a..5439929 100644 --- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift +++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift -@@ -697,7 +697,10 @@ enum OneKeyImageRequestContext { +@@ -2,8 +2,6 @@ import CryptoKit + import Foundation + import ImageIO + import SDWebImage +-import SDWebImageSVGCoder +-import SDWebImageWebPCoder + import UIKit + + enum OneKeyImageSafetyViolation: LocalizedError, Equatable, Sendable { +@@ -467,28 +465,18 @@ enum OneKeyImageSafetyPolicy { + } + + enum OneKeyImageCoderRegistry { +- private static let svgCoder = SDImageSVGCoder.shared +- private static let webPCoder = SDImageWebPCoder.shared +- + static let coder: SDImageCodersManager = { + // Keep OneKey's decoder set independent from Expo's global registrations. + // SDImageCodersManager starts with ImageIO, GIF and APNG coders. + let manager = SDImageCodersManager() +- manager.addCoder(svgCoder) +- manager.addCoder(webPCoder) ++ OneKeyImageCoderBridge.addCoders(to: manager) + return manager + }() + + private static let globalRegistration: Void = { + // SDAnimatedImage resolves its animated coder through the global manager, + // even when a request-local coder is provided in the SDWebImage context. +- let global = SDImageCodersManager.shared +- let isAlreadyRegistered = (global.coders ?? []).contains { +- ($0 as AnyObject) === webPCoder +- } +- if !isAlreadyRegistered { +- global.addCoder(webPCoder) +- } ++ OneKeyImageCoderBridge.ensureWebPCoderRegistered() + }() + + static func ensureWebPRegistered() { +@@ -697,7 +685,10 @@ enum OneKeyImageRequestContext { cachePolicy: OneKeyImageCachePolicy, thumbnailPixelSize: CGSize?, safetyTracker: OneKeyImageSafetyTracker?, @@ -1534,7 +1640,7 @@ index 393660a..2a29939 100644 ) -> [SDWebImageContextOption: Any] { var context = baseContext context[.customManager] = manager -@@ -728,6 +731,14 @@ enum OneKeyImageRequestContext { +@@ -728,6 +719,14 @@ enum OneKeyImageRequestContext { context[.storeCacheType] = cacheType.rawValue context[.originalQueryCacheType] = cacheType.rawValue context[.originalStoreCacheType] = cacheType.rawValue From e6aa222fefd71b5b6f618c4e8db700be8f6efc48 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 20:11:17 +0800 Subject: [PATCH 08/18] fix: stabilize selector images --- .../accountSelectorNativeListV2.test.ts | 34 ++++++++++++ .../accountSelectorNativeListV2.ts | 9 +++- .../UnifiedNetworkSelectorV2.tsx | 5 ++ .../useNetworkListPresentationV2.test.tsx | 52 +++++++++++++++++++ .../useNetworkListPresentationV2.ts | 31 ++++++++++- 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.test.ts 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 index 7df3add307dd..7f5544841438 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/accountSelectorNativeListV2.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { EFirmwareType } from '@onekeyfe/hd-shared'; -import { Image } from 'react-native'; +import { Image, Platform } from 'react-native'; import { useTheme } from '@onekeyhq/components'; import { buildOptimizedImageSource } from '@onekeyhq/components/src/primitives/Image/optimization'; @@ -12,6 +12,7 @@ import type { 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'; @@ -60,7 +61,11 @@ export function accountSelectorAssetUriV2( source: ImageSourcePropType | string, ): string { if (typeof source === 'string') return source; - return Image.resolveAssetSource(source)?.uri ?? ''; + 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) { diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx index c6dec261d6ef..065f11f9fef6 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx @@ -51,6 +51,7 @@ 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'; @@ -245,6 +246,10 @@ function UnifiedNetworkSelectorV2() { [networkMeta], ); + useEffect(() => { + void preloadNetworkImagesV2(networks.allNetworks); + }, [networks.allNetworks]); + // Keep networksState in sync with revalidation. The seed above handles // first paint; this effect picks up later updates from the SWR fetch. useEffect(() => { diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx index 6fe54c9e47ff..ff57a80f317a 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx @@ -4,20 +4,32 @@ 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 } }], @@ -26,6 +38,7 @@ jest.mock('@onekeyhq/kit-bg/src/states/jotai/atoms', () => ({ describe('network currency presentation V2', () => { beforeEach(() => { + mockPreloadImages.mockClear(); mockHideValue = false; mockCurrency = 'usd'; mockCurrencyMap = { @@ -35,6 +48,45 @@ describe('network currency presentation V2', () => { }; }); + 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, + resizeWidth: 32, + optimize: false, + 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({ diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts index e92be23e05ff..ac093bbeffe8 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts @@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react'; import BigNumber from 'bignumber.js'; -import { useTheme } from '@onekeyhq/components'; +import { Image, useTheme } from '@onekeyhq/components'; import { buildOptimizedImageSource } from '@onekeyhq/components/src/primitives/Image/optimization'; import { convertFiat } from '@onekeyhq/kit/src/utils/fiatConvert'; import { @@ -79,6 +79,33 @@ export function getNetworkTitleMatchV2( 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, + resizeWidth: 32, + optimize: false, + cachePolicy: 'memory-disk', + })), + ); +} + export function useNetworkListPresentationV2(sourceCurrency?: string) { const theme = useTheme(); const [{ currencyMap }] = useCurrencyPersistAtom(); @@ -187,7 +214,7 @@ export function useNetworkListPresentationV2(sourceCurrency?: string) { height: 32, contentFit: 'cover', cachePolicy: 'memory-disk', - loadingStrategy: 'none', + loadingStrategy: 'skeleton', }, shape: 'circle', backgroundColor: theme.bgApp.val, From a642c1efe7eb4f6f99b4adae72129dd9bf001433 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 21:38:51 +0800 Subject: [PATCH 09/18] feat: add real HD wallet stress data generator --- packages/kit-bg/src/services/ServiceDemo.ts | 106 ++++++++++++++++++ .../pages/Tab/DevSettingsSection/index.tsx | 34 +++++- 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/packages/kit-bg/src/services/ServiceDemo.ts b/packages/kit-bg/src/services/ServiceDemo.ts index 7ca1ca14b914..b4b749a94fb0 100644 --- a/packages/kit-bg/src/services/ServiceDemo.ts +++ b/packages/kit-bg/src/services/ServiceDemo.ts @@ -2,12 +2,18 @@ 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, backgroundMethodForDev, toastIfError, } from '@onekeyhq/shared/src/background/backgroundDecorators'; +import type { IBackgroundMethodWithDevOnlyPassword } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; import { DB_MAIN_CONTEXT_ID } from '@onekeyhq/shared/src/consts/dbConsts'; import { @@ -20,6 +26,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'; @@ -28,6 +38,7 @@ import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import type { IWalletConnectChainString } from '@onekeyhq/shared/src/walletConnect/types'; import { EMessageTypesEth } from '@onekeyhq/shared/types/message'; +import { EReasonForNeedPassword } from '@onekeyhq/shared/types/setting'; import type { IDecodedTx } from '@onekeyhq/shared/types/tx'; import { EDecodedTxActionType, @@ -36,6 +47,7 @@ import { import localDb from '../dbs/local/localDb'; import { ELocalDBStoreNames } from '../dbs/local/localDBStoreNames'; +import { EIndexedDBBucketNames } from '../dbs/local/types'; import { settingsPersistAtom } from '../states/jotai/atoms'; import { vaultFactory } from '../vaults/factory'; @@ -45,6 +57,9 @@ import type { IDBExternalAccount } from '../dbs/local/types'; import type { ITransferInfo } from '../vaults/types'; import type { AllNetworkAddressParams } from '@onekeyfe/hd-core'; +const LARGE_WALLET_DATA_WALLET_COUNT = 1000; +const LARGE_WALLET_DATA_ACCOUNT_COUNT = 1000; + @backgroundClass() class ServiceDemo extends ServiceBase { constructor({ backgroundApi }: { backgroundApi: any }) { @@ -125,6 +140,97 @@ class ServiceDemo extends ServiceBase { return c; } + @backgroundMethodForDev() + async createLargeWalletsAndAccounts( + _params: IBackgroundMethodWithDevOnlyPassword, + ) { + const startedAt = Date.now(); + let walletsCreated = 0; + let accountsCreated = 0; + let password = ''; + const accountIndexes = range(0, LARGE_WALLET_DATA_ACCOUNT_COUNT); + + try { + ({ password } = + await this.backgroundApi.servicePassword.promptPasswordVerify({ + reason: EReasonForNeedPassword.CreateOrRemoveWallet, + skipPostVerifyBackgroundTasks: true, + })); + + for ( + let walletIndex = 0; + walletIndex < LARGE_WALLET_DATA_WALLET_COUNT; + walletIndex += 1 + ) { + let mnemonic = ''; + let revealableSeed: IBip39RevealableSeed | undefined; + try { + 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; + + const indexedAccounts = + await this.backgroundApi.serviceAccount.addIndexedAccount({ + walletId: wallet.id, + indexes: accountIndexes, + skipIfExists: false, + }); + accountsCreated += indexedAccounts.length; + + await localDb.withTransaction( + EIndexedDBBucketNames.account, + async (tx) => { + await localDb.txUpdateWallet({ + tx, + walletId: wallet.id, + updater: (walletRecord) => { + if (!walletRecord.nextIds) { + walletRecord.nextIds = {}; + } + walletRecord.nextIds.accountHdIndex = + LARGE_WALLET_DATA_ACCOUNT_COUNT; + return walletRecord; + }, + }); + }, + ); + } finally { + mnemonic = ''; + if (revealableSeed) { + revealableSeed.entropyWithLangPrefixed = ''; + revealableSeed.seed = ''; + revealableSeed = undefined; + } + } + } + } finally { + password = ''; + appEventBus.emit(EAppEventBusNames.WalletUpdate, undefined); + appEventBus.emit(EAppEventBusNames.AccountUpdate, undefined); + } + + return { + accountsCreated, + accountsRequested: + LARGE_WALLET_DATA_WALLET_COUNT * LARGE_WALLET_DATA_ACCOUNT_COUNT, + durationMs: Date.now() - startedAt, + walletsCreated, + walletsRequested: LARGE_WALLET_DATA_WALLET_COUNT, + }; + } + @backgroundMethodForDev() async demoDbUpdateUUID() { const c = await localDb.demoDbUpdateUUID(); 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..3a5f5717e14e 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,38 @@ const BaseDevSettingsSection = () => { }} /> + { + showDevOnlyPasswordDialog({ + title: + 'Danger Zone: Create 1,000 Real HD Wallets × 1,000 Accounts', + description: + 'This generates and encrypts 1,000 independent recovery phrases, then creates 1,000 usable HD wallets, 1,000,000 indexed accounts, credentials, and standard cloud-sync records. Recovery phrases are never displayed or logged, and the wallets are marked as not backed up. Network addresses are derived normally when used. This may take a long time and make the app temporarily unresponsive. Each run creates another complete data set.', + confirmButtonProps: { + testID: + 'create-large-wallet-account-data-confirm', + }, + onConfirm: async (params) => { + const result = + await backgroundApiProxy.serviceDemo.createLargeWalletsAndAccounts( + params, + ); + 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`, + }); + }, + }); + }} + /> + Date: Mon, 7 Sep 2026 21:40:50 +0800 Subject: [PATCH 10/18] fix: refine native list section index --- ...yfe+react-native-native-list+3.0.105.patch | 1097 ++++++++++++++--- 1 file changed, 952 insertions(+), 145 deletions(-) diff --git a/patches/@onekeyfe+react-native-native-list+3.0.105.patch b/patches/@onekeyfe+react-native-native-list+3.0.105.patch index eeef36b046d3..a7bafe2d07d0 100644 --- a/patches/@onekeyfe+react-native-native-list+3.0.105.patch +++ b/patches/@onekeyfe+react-native-native-list+3.0.105.patch @@ -1325,7 +1325,7 @@ index db47e5e..4ef24aa 100644 PathParser.createPathFromPathData(pathData)?.let { canvas.drawPath(it, glyphPaint) } canvas.restore() diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt -index f0af895..ec5c3fe 100644 +index f0af895..1294235 100644 --- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt @@ -102,6 +102,7 @@ class NativeListView( @@ -1345,7 +1345,40 @@ index f0af895..ec5c3fe 100644 recyclerView.setHasFixedSize(false) layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { -@@ -244,17 +247,29 @@ class NativeListView( +@@ -158,11 +161,15 @@ class NativeListView( + ) + contentContainer.addView( + sectionIndexView, +- FrameLayout.LayoutParams(dp(48), FrameLayout.LayoutParams.MATCH_PARENT, Gravity.END), ++ FrameLayout.LayoutParams( ++ dp(SECTION_INDEX_RAIL_WIDTH_DP), ++ FrameLayout.LayoutParams.MATCH_PARENT, ++ Gravity.END, ++ ), + ) + sectionIndexPreview.apply { + gravity = Gravity.CENTER +- textSize = NativeListScale.font(resources, 28f) ++ textSize = NativeListScale.font(resources, 22f) + typeface = NativeListFonts.semibold(context) + visibility = GONE + alpha = 0f +@@ -170,7 +177,13 @@ class NativeListView( + } + contentContainer.addView( + sectionIndexPreview, +- FrameLayout.LayoutParams(dp(72), dp(72), Gravity.CENTER), ++ FrameLayout.LayoutParams( ++ dp(SECTION_INDEX_PREVIEW_SIZE_DP), ++ dp(SECTION_INDEX_PREVIEW_SIZE_DP), ++ Gravity.CENTER_VERTICAL or Gravity.END, ++ ).apply { ++ marginEnd = dp(SECTION_INDEX_PREVIEW_END_MARGIN_DP) ++ }, + ) + addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) + footerView.visibility = GONE +@@ -244,17 +257,29 @@ class NativeListView( invalidateActionAnchor("snapshot") val previous = config if (previous != null && canApplyStableContentUpdate(previous, next)) { @@ -1375,7 +1408,7 @@ index f0af895..ec5c3fe 100644 recyclerView.post { bindVisibleSelection(changedSummaryKeys) bindFooterSelection() -@@ -264,6 +279,8 @@ class NativeListView( +@@ -264,6 +289,8 @@ class NativeListView( return } config = next @@ -1384,7 +1417,7 @@ index f0af895..ec5c3fe 100644 endReachedGeneration = null pendingReorder = null adapter.theme = next.theme -@@ -327,30 +344,98 @@ class NativeListView( +@@ -327,30 +354,98 @@ class NativeListView( val newItem = next.items[index] oldItem.key == newItem.key && oldItem.type == newItem.type && @@ -1446,7 +1479,10 @@ index f0af895..ec5c3fe 100644 + if (result.has("selected")) { + if (result.opt("selected") !is Boolean) return null + result.remove("selected") -+ } + } +- val previousStructure = JSONObject(previous.content).apply { +- remove("title") +- remove("value") + if (type == "walletGroup") { + val parent = data.optJSONObject("parent") ?: return null + if (parent.optString("type") != "identity") return null @@ -1460,16 +1496,14 @@ index f0af895..ec5c3fe 100644 + } + result.put("children", normalizedChildren) } -- val previousStructure = JSONObject(previous.content).apply { +- val nextStructure = JSONObject(next.content).apply { - remove("title") - remove("value") + if (type == "sectionHeader" && data.optString("variant") == "summary") { + result.remove("title") + result.remove("value") } -- val nextStructure = JSONObject(next.content).apply { -- remove("title") -- remove("value") +- return previousStructure.toString() == nextStructure.toString() + if (!controlled) return result + fun checkboxData(value: Any?): JSONObject? { + val checkbox = value as? JSONObject ?: return null @@ -1477,8 +1511,7 @@ index f0af895..ec5c3fe 100644 + if (checkbox.has("state") && checkbox.opt("state") !in setOf("checked", "unchecked", "indeterminate")) return null + checkbox.remove("state") + return checkbox - } -- return previousStructure.toString() == nextStructure.toString() ++ } + if (type in setOf("dataRow", "sectionHeader", "action") && result.has("checkbox")) { + result.put("checkbox", checkboxData(result.opt("checkbox")) ?: return null) + } @@ -1498,7 +1531,7 @@ index f0af895..ec5c3fe 100644 } fun applyPatches(patchesJson: String) { -@@ -389,6 +474,8 @@ class NativeListView( +@@ -389,6 +484,8 @@ class NativeListView( invalidateActionAnchor("snapshot") val next = current.copy(items = nextItems, selectedKeys = selected) config = next @@ -1507,7 +1540,17 @@ index f0af895..ec5c3fe 100644 adapter.selectedKeys = selected adapter.submitList(nextItems) { relayoutContents() } bindFooter(next) -@@ -742,7 +829,7 @@ class NativeListView( +@@ -731,7 +828,8 @@ class NativeListView( + val horizontalPadding = next.contentPaddingHorizontal ?: defaultPadding + val topPadding = next.contentPaddingTop ?: defaultPadding + val bottomPadding = next.contentPaddingBottom ?: defaultPadding +- val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else 48 ++ // OneKey patch: the section index overlays rows and keeps only an accessory-safe inset. ++ val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else SECTION_INDEX_CONTENT_INSET_DP + recyclerView.setPaddingRelative( + dp(horizontalPadding), + dp(topPadding), +@@ -742,7 +840,7 @@ class NativeListView( recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty() spacingDecoration?.let(recyclerView::removeItemDecoration) @@ -1516,7 +1559,22 @@ index f0af895..ec5c3fe 100644 stickyDecoration?.let(recyclerView::removeItemDecoration) stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) { StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration) -@@ -846,6 +933,7 @@ class NativeListView( +@@ -769,12 +867,13 @@ class NativeListView( + sectionIndexEntries.map { it.title }, + themeColor(next.theme, "secondaryText", "#646464"), + themeColor(next.theme, "accent", "#108303"), ++ themeColor(next.theme, "inverseText", "#FCFCFC"), + ) + sectionIndexView.visibility = if (sectionIndexEntries.isEmpty()) GONE else VISIBLE + sectionIndexPreview.setTextColor(themeColor(next.theme, "inverseText", "#FCFCFC")) + sectionIndexPreview.background = GradientDrawable().apply { + setColor(themeColor(next.theme, "inverseBackground", "#202020")) +- cornerRadius = dp(16).toFloat() ++ cornerRadius = dp(14).toFloat() + } + sectionIndexView.setActiveIndex( + previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } }, +@@ -846,6 +945,7 @@ class NativeListView( null, next.selectedKeys.contains(footer.key), ::resolveCheckboxState, @@ -1524,7 +1582,7 @@ index f0af895..ec5c3fe 100644 ) } } -@@ -898,15 +986,17 @@ class NativeListView( +@@ -898,15 +998,17 @@ class NativeListView( origin.sourceView.getLocationInWindow(location) val record = ActionAnchorRecord(token, origin) actionAnchor = record @@ -1546,7 +1604,7 @@ index f0af895..ec5c3fe 100644 ) .put("source", origin.source) .put("generation", generation) -@@ -1037,7 +1127,9 @@ class NativeListView( +@@ -1037,7 +1139,9 @@ class NativeListView( current.theme, current.layout, position, @@ -1557,7 +1615,7 @@ index f0af895..ec5c3fe 100644 ::resolveCheckboxState, ) } -@@ -1297,12 +1389,14 @@ class NativeListView( +@@ -1297,12 +1401,14 @@ class NativeListView( ?.let(recyclerView::getChildViewHolder) ?.takeIf { holder -> adapter.itemAt(holder.bindingAdapterPosition)?.let { item -> @@ -1578,7 +1636,7 @@ index f0af895..ec5c3fe 100644 } == true } if (candidate != null) handler.postDelayed(startDrag, REORDER_LONG_PRESS_MS) -@@ -1460,7 +1554,7 @@ class NativeListView( +@@ -1460,9 +1566,13 @@ class NativeListView( ) } @@ -1586,8 +1644,74 @@ index f0af895..ec5c3fe 100644 + private fun dp(value: Int): Int = if (usesSelectorSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) companion object { ++ private const val SECTION_INDEX_CONTENT_INSET_DP = 16 ++ private const val SECTION_INDEX_RAIL_WIDTH_DP = 32 ++ private const val SECTION_INDEX_PREVIEW_SIZE_DP = 48 ++ private const val SECTION_INDEX_PREVIEW_END_MARGIN_DP = 40 private const val REORDER_LONG_PRESS_MS = 200L -@@ -1697,7 +1791,11 @@ private class NativeListSectionIndexView( + private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10 + private const val REORDER_PLACEHOLDER_INSET_DP = 8 +@@ -1546,7 +1656,9 @@ private class NativeListSectionIndexView( + private var titles: List = emptyList() + private var normalColor = Color.GRAY + private var activeColor = Color.BLACK ++ private var activeTextColor = Color.WHITE + private var lastTouchIndex: Int? = null ++ private val activeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) + private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + textAlign = Paint.Align.CENTER + typeface = NativeListFonts.medium(context) +@@ -1563,10 +1675,16 @@ private class NativeListSectionIndexView( + contentDescription = ACCESSIBILITY_LABEL + } + +- fun configure(titles: List, normalColor: Int, activeColor: Int) { ++ fun configure( ++ titles: List, ++ normalColor: Int, ++ activeColor: Int, ++ activeTextColor: Int, ++ ) { + this.titles = titles + this.normalColor = normalColor + this.activeColor = activeColor ++ this.activeTextColor = activeTextColor + activeIndex = null + updateContentDescription() + invalidate() +@@ -1590,8 +1708,30 @@ private class NativeListSectionIndexView( + activePaint.color = activeColor + activePaint.textSize = textSize + titles.forEachIndexed { index, title -> +- val paint = if (index == activeIndex) activePaint else normalPaint ++ val active = index == activeIndex ++ val paint = if (active) activePaint else normalPaint + val centerY = originY + cellHeight * (index + 0.5f) ++ if (active && cellHeight >= NativeListScale.dp(resources, 12f)) { ++ val badgeWidth = NativeListScale.dp(resources, 20f) ++ val badgeHeight = minOf(cellHeight, NativeListScale.dp(resources, 16f)) ++ activeBackgroundPaint.color = activeColor ++ canvas.drawRoundRect( ++ width / 2f - badgeWidth / 2f, ++ centerY - badgeHeight / 2f, ++ width / 2f + badgeWidth / 2f, ++ centerY + badgeHeight / 2f, ++ badgeHeight / 2f, ++ badgeHeight / 2f, ++ activeBackgroundPaint, ++ ) ++ } ++ paint.color = if (active && cellHeight >= NativeListScale.dp(resources, 12f)) { ++ activeTextColor ++ } else if (active) { ++ activeColor ++ } else { ++ normalColor ++ } + val baseline = centerY - (paint.descent() + paint.ascent()) / 2f + canvas.drawText(title, width / 2f, baseline, paint) + } +@@ -1697,7 +1837,11 @@ private class NativeListSectionIndexView( } } @@ -1600,7 +1724,7 @@ index f0af895..ec5c3fe 100644 override fun getItemOffsets( outRect: android.graphics.Rect, view: View, -@@ -1706,7 +1804,14 @@ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.Ite +@@ -1706,7 +1850,14 @@ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.Ite ) { if (spacing <= 0) return val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL @@ -1616,7 +1740,7 @@ index f0af895..ec5c3fe 100644 } } -@@ -1733,22 +1838,32 @@ private class StickySectionHeaderDecoration( +@@ -1733,22 +1884,32 @@ private class StickySectionHeaderDecoration( for (index in first downTo 0) { val candidate = adapter.itemAt(index) if (candidate?.type == "sectionHeader") { @@ -1655,7 +1779,7 @@ index f0af895..ec5c3fe 100644 val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it } val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL val textWidth = if (isHistory) { -@@ -1789,6 +1904,29 @@ private class StickySectionHeaderDecoration( +@@ -1789,6 +1950,29 @@ private class StickySectionHeaderDecoration( internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean = item.type == "sectionHeader" && @@ -2847,7 +2971,7 @@ index 7dd7368..3f3b90a 100644 + .withRenderingMode(["GoogleIllus", "BotIllus", "AccountErrorCustom"].contains(name) ? .alwaysOriginal : .alwaysTemplate) } diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift -index d89c4c2..127acb3 100644 +index d89c4c2..c852266 100644 --- a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift +++ b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift @@ -1,4 +1,5 @@ @@ -2856,7 +2980,85 @@ index d89c4c2..127acb3 100644 import UIKit import UniformTypeIdentifiers -@@ -485,7 +486,7 @@ final class NativeListView: UIView { +@@ -77,6 +78,7 @@ final class NativeListView: UIView { + target: self, + action: #selector(reorderLongPressChanged(_:)) + ) ++ private lazy var listBodyGestureGuard = UILongPressGestureRecognizer(target: nil, action: nil) + private var interactiveReorderSource: (key: String, index: Int)? + private weak var interactiveReorderCell: NativeListCell? + private var interactiveReorderCompactKey: String? +@@ -92,7 +94,10 @@ final class NativeListView: UIView { + private let actionAnchorInstanceID = UUID().uuidString + private var actionAnchorCounter = 0 + +- private static let sectionIndexGutter: CGFloat = 44 ++ private static let sectionIndexContentInset: CGFloat = 16 ++ private static let sectionIndexRailWidth: CGFloat = 32 ++ private static let sectionIndexPreviewSize: CGFloat = 48 ++ private static let sectionIndexPreviewEndMargin: CGFloat = 40 + + override init(frame: CGRect) { + super.init(frame: frame) +@@ -102,6 +107,14 @@ final class NativeListView: UIView { + collectionView.dragDelegate = self + collectionView.dropDelegate = self + collectionView.alwaysBounceVertical = true ++ // OneKey patch: keep list-body drags from being claimed by an ancestor modal sheet. ++ listBodyGestureGuard.minimumPressDuration = 0 ++ listBodyGestureGuard.allowableMovement = .greatestFiniteMagnitude ++ listBodyGestureGuard.cancelsTouchesInView = false ++ listBodyGestureGuard.delaysTouchesEnded = false ++ listBodyGestureGuard.isEnabled = false ++ listBodyGestureGuard.delegate = self ++ collectionView.addGestureRecognizer(listBodyGestureGuard) + reorderLongPress.minimumPressDuration = ReorderAnimation.longPressDuration + reorderLongPress.allowableMovement = ReorderAnimation.allowableMovement + reorderLongPress.delegate = self +@@ -136,11 +149,14 @@ final class NativeListView: UIView { + sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), + sectionIndexView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + sectionIndexView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), +- sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexGutter), +- sectionIndexPreview.centerXAnchor.constraint(equalTo: collectionView.centerXAnchor), ++ sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexRailWidth), ++ sectionIndexPreview.trailingAnchor.constraint( ++ equalTo: safeAreaLayoutGuide.trailingAnchor, ++ constant: -Self.sectionIndexPreviewEndMargin ++ ), + sectionIndexPreview.centerYAnchor.constraint(equalTo: collectionView.centerYAnchor), +- sectionIndexPreview.widthAnchor.constraint(equalToConstant: 72), +- sectionIndexPreview.heightAnchor.constraint(equalToConstant: 72), ++ sectionIndexPreview.widthAnchor.constraint(equalToConstant: Self.sectionIndexPreviewSize), ++ sectionIndexPreview.heightAnchor.constraint(equalToConstant: Self.sectionIndexPreviewSize), + ]) + + sectionIndexView.isHidden = true +@@ -152,11 +168,11 @@ final class NativeListView: UIView { + } + sectionIndexPreview.isHidden = true + sectionIndexPreview.alpha = 0 +- sectionIndexPreview.layer.cornerRadius = 16 ++ sectionIndexPreview.layer.cornerRadius = 14 + sectionIndexPreview.layer.masksToBounds = true + sectionIndexPreview.textAlignment = .center + sectionIndexPreview.adjustsFontForContentSizeCategory = true +- sectionIndexPreview.font = nativeListFont(ofSize: 28, weight: .semibold) ++ sectionIndexPreview.font = nativeListFont(ofSize: 22, weight: .semibold) + sectionIndexPreview.isAccessibilityElement = false + + footerCell.onAction = { [weak self] item, action, target, origin in +@@ -472,7 +488,8 @@ final class NativeListView: UIView { + + private func configureLayout(_ config: NativeListConfig) { + let isHorizontal = config.orientation == "horizontal" +- let indexGutter = sectionIndexEntries.isEmpty ? 0 : Self.sectionIndexGutter ++ // OneKey patch: the section index overlays rows and keeps only an accessory-safe inset. ++ let indexGutter = sectionIndexEntries.isEmpty ? 0 : Self.sectionIndexContentInset + let isRightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft + flowLayout.scrollDirection = isHorizontal ? .horizontal : .vertical + flowLayout.minimumLineSpacing = config.itemSpacing +@@ -485,7 +502,7 @@ final class NativeListView: UIView { ) flowLayout.stickyItemIndexes = config.stickyHeaders ? Set(config.items.enumerated().compactMap { @@ -2865,7 +3067,15 @@ index d89c4c2..127acb3 100644 ? $0.offset : nil }) -@@ -512,11 +513,12 @@ final class NativeListView: UIView { +@@ -493,6 +510,7 @@ final class NativeListView: UIView { + flowLayout.invalidateLayout() + collectionView.alwaysBounceHorizontal = isHorizontal + collectionView.alwaysBounceVertical = !isHorizontal ++ listBodyGestureGuard.isEnabled = !isHorizontal + collectionView.showsVerticalScrollIndicator = sectionIndexEntries.isEmpty + collectionView.dragInteractionEnabled = false + lastLayoutDirection = effectiveUserInterfaceLayoutDirection +@@ -512,11 +530,12 @@ final class NativeListView: UIView { interactiveReorderCell = nil return } @@ -2883,7 +3093,17 @@ index d89c4c2..127acb3 100644 interactiveReorderSource = (item.key, indexPath.item) interactiveReorderCell = cell interactiveReorderUsesAtomicTargeting = item.type == "identity" && -@@ -932,7 +934,7 @@ final class NativeListView: UIView { +@@ -838,7 +857,8 @@ final class NativeListView: UIView { + sectionIndexView.configure( + titles: sectionIndexEntries.map(\.title), + textColor: nativeListColor(config.theme, "secondaryText", "#646464"), +- activeColor: nativeListColor(config.theme, "accent", "#108303") ++ activeColor: nativeListColor(config.theme, "accent", "#108303"), ++ activeTextColor: nativeListColor(config.theme, "inverseText", "#FCFCFC") + ) + sectionIndexView.isHidden = sectionIndexEntries.isEmpty + sectionIndexPreview.backgroundColor = nativeListColor( +@@ -932,7 +952,7 @@ final class NativeListView: UIView { theme: config.theme, layout: config.layout, itemIndex: itemIndex, @@ -2892,7 +3112,7 @@ index d89c4c2..127acb3 100644 checkboxState: { [weak self] item, target, fallback in self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback } -@@ -943,7 +945,8 @@ final class NativeListView: UIView { +@@ -943,7 +963,8 @@ final class NativeListView: UIView { } private func handleRowPress(_ item: NativeListItem, origin: NativeListActionOrigin?) { @@ -2902,7 +3122,7 @@ index d89c4c2..127acb3 100644 if config.rowPressToggles && item.isSelectable && config.selectionMode != "none" { updateSelection(target: NativeSelectionTarget(scope: "row", key: item.key), sourceKey: item.key) return -@@ -1037,7 +1040,7 @@ final class NativeListView: UIView { +@@ -1037,7 +1058,7 @@ final class NativeListView: UIView { let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue } cell.updateSelection( item: item, @@ -2911,7 +3131,7 @@ index d89c4c2..127acb3 100644 checkboxState: checkboxState ) } -@@ -1080,19 +1083,73 @@ final class NativeListView: UIView { +@@ -1080,19 +1101,73 @@ final class NativeListView: UIView { return zip(current.items, next.items).allSatisfy { old, new in guard old.key == new.key, old.type == new.type else { return false } if old.content == new.content { return true } @@ -2994,7 +3214,7 @@ index d89c4c2..127acb3 100644 private func dictionariesEqual(_ lhs: [String: Any]?, _ rhs: [String: Any]?) -> Bool { switch (lhs, rhs) { case (nil, nil): return true -@@ -1107,16 +1164,36 @@ final class NativeListView: UIView { +@@ -1107,16 +1182,36 @@ final class NativeListView: UIView { } private func rowHeight(_ item: NativeListItem) -> CGFloat { @@ -3033,7 +3253,7 @@ index d89c4c2..127acb3 100644 } if item.type == "identity", item.data.string("presentation") == "networkSelector" { return 47 -@@ -1268,7 +1345,7 @@ final class NativeListView: UIView { +@@ -1268,7 +1363,7 @@ final class NativeListView: UIView { actionAnchorCounter &+= 1 let generation = config?.generation ?? 0 let token = "\(actionAnchorInstanceID):\(generation):\(actionAnchorCounter):\(origin.bindingEpoch)" @@ -3042,7 +3262,43 @@ index d89c4c2..127acb3 100644 let record = ActionAnchorRecord(token: token, origin: origin) actionAnchor = record var anchor: [String: Any] = [ -@@ -1604,7 +1681,9 @@ private struct NativeListSectionIndexEntry { +@@ -1375,6 +1470,7 @@ final class NativeListView: UIView { + + extension NativeListView: UIGestureRecognizerDelegate { + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { ++ if gestureRecognizer === listBodyGestureGuard { return true } + var view = touch.view + while let current = view, current !== footerCell { + if current is UIControl { return false } +@@ -1387,7 +1483,26 @@ extension NativeListView: UIGestureRecognizerDelegate { + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { +- gestureRecognizer.view === footerCell || otherGestureRecognizer.view === footerCell ++ if gestureRecognizer === listBodyGestureGuard { ++ guard let otherView = otherGestureRecognizer.view else { return false } ++ return otherView === collectionView || otherView.isDescendant(of: collectionView) ++ } ++ if otherGestureRecognizer === listBodyGestureGuard { ++ guard let gestureView = gestureRecognizer.view else { return false } ++ return gestureView === collectionView || gestureView.isDescendant(of: collectionView) ++ } ++ return gestureRecognizer.view === footerCell || otherGestureRecognizer.view === footerCell ++ } ++ ++ func gestureRecognizer( ++ _ gestureRecognizer: UIGestureRecognizer, ++ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer ++ ) -> Bool { ++ guard gestureRecognizer === listBodyGestureGuard, ++ otherGestureRecognizer is UIPanGestureRecognizer, ++ let otherView = otherGestureRecognizer.view, ++ otherView !== collectionView else { return false } ++ return collectionView.isDescendant(of: otherView) + } + } + +@@ -1604,13 +1719,16 @@ private struct NativeListSectionIndexEntry { let position: Int } @@ -3053,7 +3309,14 @@ index d89c4c2..127acb3 100644 var onSelect: ((Int, Bool) -> Void)? var onInteractionEnded: (() -> Void)? private var titles: [String] = [] -@@ -1620,6 +1699,27 @@ private final class NativeListSectionIndexView: UIControl { + private var labels: [UILabel] = [] + private var textColor: UIColor = .secondaryLabel + private var activeColor: UIColor = .tintColor ++ private var activeTextColor: UIColor = .white + private var lastTouchIndex: Int? + private(set) var activeIndex: Int? + +@@ -1620,16 +1738,43 @@ private final class NativeListSectionIndexView: UIControl { accessibilityLabel = "Section index" accessibilityTraits = [.adjustable] isExclusiveTouch = true @@ -3081,6 +3344,47 @@ index d89c4c2..127acb3 100644 } required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +- func configure(titles: [String], textColor: UIColor, activeColor: UIColor) { ++ func configure( ++ titles: [String], ++ textColor: UIColor, ++ activeColor: UIColor, ++ activeTextColor: UIColor ++ ) { + self.titles = titles + self.textColor = textColor + self.activeColor = activeColor ++ self.activeTextColor = activeTextColor + labels.forEach { $0.removeFromSuperview() } + labels = titles.map { title in + let label = UILabel() +@@ -1660,9 +1805,9 @@ private final class NativeListSectionIndexView: UIControl { + let originY = (bounds.height - height * CGFloat(labels.count)) / 2 + for (index, label) in labels.enumerated() { + label.frame = CGRect( +- x: 0, ++ x: 6, + y: originY + CGFloat(index) * height, +- width: bounds.width, ++ width: 20, + height: height + ) + } +@@ -1718,7 +1863,10 @@ private final class NativeListSectionIndexView: UIControl { + private func updateLabelStyles() { + for (index, label) in labels.enumerated() { + let active = index == activeIndex +- label.textColor = active ? activeColor : textColor ++ label.textColor = active ? activeTextColor : textColor ++ label.backgroundColor = active ? activeColor : .clear ++ label.layer.cornerRadius = 8 ++ label.layer.masksToBounds = active + label.font = nativeListFont(ofSize: 10, weight: active ? .semibold : .medium) + } + } diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json new file mode 100644 index 0000000..ae4d23e @@ -4572,19 +4876,25 @@ index 0000000..f896e62 + return cache.acquire(uri, resolve, reject, priority); +} diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -index 625a913..0e702f6 100644 +index 625a913..d742ddd 100644 --- a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js +++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -@@ -3,6 +3,8 @@ +@@ -3,7 +3,13 @@ import { checkboxStateForKeys, checkboxStateForSection, isSelectableRow, reduceSelection, selectionStateFromSnapshot } from "../selection.js"; import { calculateAlignedScrollOffset, resolveLocationIndex, scrollFailure, validateOffset } from "../scrolling.js"; import { applyRowPatches, validateSnapshot } from "../validation.js"; +-const SECTION_INDEX_GUTTER = 44; +import { avatarPrefetchWindow } from "../avatarPrefetch.js"; +import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from "./NativeListWebAvatarCache.js"; - const SECTION_INDEX_GUTTER = 44; ++const SECTION_INDEX_CONTENT_INSET = 16; ++const SECTION_INDEX_RAIL_WIDTH = 32; ++const SECTION_INDEX_EDGE_PADDING = 8; ++const SECTION_INDEX_MIN_LABEL_SPACING = 14; ++const SECTION_INDEX_MIN_HEIGHT = 120; const DEFAULT_VIEWPORT_WIDTH = 320; const DEFAULT_VIEWPORT_HEIGHT = 640; -@@ -147,9 +149,22 @@ function approximateMessageHeight(row, availableWidth) { + const OVERSCAN_VIEWPORTS = 1; +@@ -147,9 +153,22 @@ function approximateMessageHeight(row, availableWidth) { return 32 + titleLines * 20 + bodyLines * 20 + 22; } export function estimateWebRowHeight(row, snapshot, availableWidth) { @@ -4610,7 +4920,19 @@ index 625a913..0e702f6 100644 if (row.type === 'identity' && row.presentation === 'networkSelector') return 47; if (row.type === 'identity' && row.presentation === 'accountSelector') return 58; let base; -@@ -371,7 +386,10 @@ function rowWithoutSelectionState(row) { +@@ -208,9 +227,10 @@ export function computeWebListLayout(snapshot, viewportWidth, viewportHeight, co + const horizontal = snapshot.layout.orientation === 'horizontal'; + const spacing = snapshot.layout.itemSpacing ?? 0; + const padding = paddingValues(snapshot); +- const indexGutter = sectionIndexEnabled(snapshot) ? SECTION_INDEX_GUTTER : 0; + const width = Math.max(1, viewportWidth || DEFAULT_VIEWPORT_WIDTH); + const height = Math.max(1, viewportHeight || DEFAULT_VIEWPORT_HEIGHT); ++ // OneKey patch: the index overlays the content and only keeps a small accessory-safe inset. ++ const indexGutter = sectionIndexEnabled(snapshot) && height >= SECTION_INDEX_MIN_HEIGHT ? SECTION_INDEX_CONTENT_INSET : 0; + const availableWidth = Math.max(1, width - padding.horizontal * 2 - indexGutter); + const availableHeight = Math.max(1, height - padding.top - padding.bottom); + const items = []; +@@ -371,7 +391,10 @@ function rowWithoutSelectionState(row) { export function webRowRenderSignature(row) { return JSON.stringify(rowWithoutSelectionState(row)); } @@ -4621,9 +4943,12 @@ index 625a913..0e702f6 100644 .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -426,7 +444,27 @@ export const WEB_LIST_CSS = ` +@@ -424,9 +447,29 @@ export const WEB_LIST_CSS = ` + .ok-native-list-media{display:block;padding:0 5px;background:transparent;border-radius:16px}.ok-native-list-media-image{display:block;width:100%;aspect-ratio:1;border-radius:10px;background:var(--nl-strong);object-fit:cover}.ok-native-list-media-image[data-state="empty"]{background:transparent}.ok-native-list-media-image[data-state="error"]{display:flex;align-items:center;justify-content:center;color:var(--nl-icon-subdued);font-size:24px}.ok-native-list-media-meta{padding-top:7px}.ok-native-list-media-subtitle-row{display:flex;align-items:center;gap:6px}.ok-native-list-media-subtitle{flex:1;min-width:0;font-size:12px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-network{width:14px;height:14px;border-radius:50%}.ok-native-list-media-title{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-close{position:absolute;right:9px;top:4px;border:0;background:color-mix(in srgb,var(--nl-inverse) 72%,transparent);color:var(--nl-inverse-text);width:24px;height:24px;border-radius:50%;font:18px/20px inherit;cursor:pointer} + .ok-native-list-metric{display:flex;flex-direction:column;align-items:flex-start;padding:12px;border-radius:12px;gap:5px;background:var(--nl-row)}.ok-native-list-metric-value{font-size:22px;line-height:28px;font-weight:700}.ok-native-list-composite{display:flex;flex-direction:column;align-items:stretch;padding:14px;border-radius:12px;gap:12px;background:var(--nl-subdued)}.ok-native-list-composite-heading{font-size:14px;letter-spacing:1px;color:var(--nl-secondary)}.ok-native-list-composite-row{display:flex;gap:12px}.ok-native-list-composite-cell{flex:1;min-width:0}.ok-native-list-composite-cell[data-shaded="true"]{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--nl-primary) 5%,transparent)}.ok-native-list-composite-value{font-size:18px;font-weight:600}.ok-native-list-divider{height:1px;background:var(--nl-separator)}.ok-native-list-progress{height:4px;border-radius:2px;overflow:hidden;background:var(--nl-negative)}.ok-native-list-progress>span{display:block;height:100%;border-radius:2px;background:var(--nl-positive)} .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} - .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} +-.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} ++.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:6px;display:flex;width:20px;height:16px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:8px;background:transparent;color:var(--nl-secondary);font:600 10px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-accent);color:var(--nl-inverse-text)}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-accent);outline-offset:1px}.ok-native-list-index-preview{position:absolute;z-index:8;right:40px;top:50%;display:flex;width:48px;height:48px;align-items:center;justify-content:center;transform:translateY(-50%) scale(.92);border-radius:14px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:22px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translateY(-50%) scale(1)} .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} +.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} +.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} @@ -4649,7 +4974,7 @@ index 625a913..0e702f6 100644 `; function createElement(document, tag, className, text) { const element = document.createElement(tag); -@@ -442,12 +480,105 @@ function safeImageUri(uri) { +@@ -442,12 +485,105 @@ function safeImageUri(uri) { if (/^(https?:|data:image\/|blob:|file:)/i.test(trimmed) || trimmed.startsWith('/')) return trimmed; return undefined; } @@ -4757,7 +5082,7 @@ index 625a913..0e702f6 100644 image.alt = ''; image.draggable = false; image.loading = 'lazy'; -@@ -455,6 +586,247 @@ function createImage(context, source, className) { +@@ -455,6 +591,247 @@ function createImage(context, source, className) { image.style.objectFit = source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover'; return image; } @@ -5005,7 +5330,7 @@ index 625a913..0e702f6 100644 function iconGlyph(name) { const normalized = name.toLocaleLowerCase(); if (normalized.includes('chevron')) { -@@ -484,7 +856,7 @@ function visualFromRow(row) { +@@ -484,7 +861,7 @@ function visualFromRow(row) { if (row.type === 'metricCard') return row.visual; return undefined; } @@ -5014,7 +5339,7 @@ index 625a913..0e702f6 100644 if (!visual) return undefined; if (visual.kind === 'stackedImages') { const stack = createElement(context.document, 'span', 'ok-native-list-stacked'); -@@ -500,6 +872,7 @@ function createVisual(context, visual) { +@@ -500,6 +877,7 @@ function createVisual(context, visual) { if (visual.kind === 'icon') { frame.style.background = visual.backgroundColor ?? 'var(--nl-strong)'; const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback', iconGlyph(visual.name)); @@ -5022,7 +5347,7 @@ index 625a913..0e702f6 100644 if (visual.tintColor) fallback.style.color = visual.tintColor; frame.appendChild(fallback); return frame; -@@ -510,6 +883,7 @@ function createVisual(context, visual) { +@@ -510,6 +888,7 @@ function createVisual(context, visual) { if (image) { image.className = 'ok-native-list-visual-main'; frame.appendChild(image); @@ -5030,7 +5355,7 @@ index 625a913..0e702f6 100644 } else { frame.appendChild(createElement(context.document, 'span', 'ok-native-list-visual-fallback', 'fallbackText' in visual ? visual.fallbackText ?? '' : '')); } -@@ -520,8 +894,64 @@ function createVisual(context, visual) { +@@ -520,8 +899,64 @@ function createVisual(context, visual) { const corner = createElement(context.document, 'span', 'ok-native-list-visual-corner ok-native-list-visual-fallback', iconGlyph(visual.cornerIcon.name)); if (visual.cornerIcon.tintColor) corner.style.color = visual.cornerIcon.tintColor; if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; @@ -5095,7 +5420,7 @@ index 625a913..0e702f6 100644 return frame; } function toneColor(tone, fallback) { -@@ -567,6 +997,19 @@ function createCheckbox(context, rowKey, accessory) { +@@ -567,6 +1002,19 @@ function createCheckbox(context, rowKey, accessory) { setData(element, 'checkboxFallback', accessory.state); setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); @@ -5115,7 +5440,7 @@ index 625a913..0e702f6 100644 if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey);else if (accessory.target?.scope === 'row') setData(element, 'selectionKey', rowKey); return element; } -@@ -576,6 +1019,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { +@@ -576,6 +1024,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { element.setAttribute('type', 'button'); element.toggleAttribute('disabled', Boolean(disabled)); } @@ -5123,7 +5448,7 @@ index 625a913..0e702f6 100644 if (actionKey) setData(element, 'nativeListAction', actionKey); if (tintColor) element.style.color = tintColor; return element; -@@ -584,6 +1028,23 @@ function markActionAnchorSource(element, source, slot) { +@@ -584,6 +1033,23 @@ function markActionAnchorSource(element, source, slot) { setData(element, 'nativeListAnchorSource', source); if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } @@ -5147,7 +5472,7 @@ index 625a913..0e702f6 100644 function createAccessory(context, rowKey, accessory, slot) { if (accessory.kind === 'checkbox') { const element = createCheckbox(context, rowKey, accessory); -@@ -593,6 +1054,18 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -593,6 +1059,18 @@ function createAccessory(context, rowKey, accessory, slot) { if (accessory.kind === 'icon') { const element = createIconAction(context, accessory.name, accessory.actionKey, accessory.disabled, accessory.tintColor); markActionAnchorSource(element, 'trailingAccessory', slot); @@ -5166,7 +5491,7 @@ index 625a913..0e702f6 100644 return element; } if (accessory.kind === 'spinner') { -@@ -608,6 +1081,7 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -608,6 +1086,7 @@ function createAccessory(context, rowKey, accessory, slot) { switch (accessory.kind) { case 'value': element.textContent = accessory.text; @@ -5174,7 +5499,7 @@ index 625a913..0e702f6 100644 if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); break; case 'valuePair': -@@ -646,6 +1120,13 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -646,6 +1125,13 @@ function createAccessory(context, rowKey, accessory, slot) { function appendAccessories(parent, context, rowKey, accessories) { if (!accessories?.length) return; const container = createElement(context.document, 'span', 'ok-native-list-accessories'); @@ -5188,7 +5513,7 @@ index 625a913..0e702f6 100644 accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot))); parent.appendChild(container); } -@@ -673,9 +1154,83 @@ function createSectionHeader(context, row) { +@@ -673,9 +1159,83 @@ function createSectionHeader(context, row) { markActionAnchorSource(titleIcon, 'leadingAction'); body.appendChild(titleIcon); } @@ -5273,7 +5598,7 @@ index 625a913..0e702f6 100644 if (row.valueActionKey) { value.setAttribute('type', 'button'); setData(value, 'nativeListAction', row.valueActionKey); -@@ -696,6 +1251,7 @@ function createActionRow(context, row) { +@@ -696,6 +1256,7 @@ function createActionRow(context, row) { if (row.icon) body.appendChild(createVisual(context, row.icon)); const title = createElement(context.document, 'span', 'ok-native-list-action-title', row.title); setData(title, 'tone', row.tone); @@ -5281,7 +5606,7 @@ index 625a913..0e702f6 100644 body.appendChild(title); if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); appendAccessories(body, context, row.key, row.trailing); -@@ -704,6 +1260,13 @@ function createActionRow(context, row) { +@@ -704,6 +1265,13 @@ function createActionRow(context, row) { function createSystemRow(context, row) { const body = createElement(context.document, 'div', 'ok-native-list-row ok-native-list-system'); setData(body, 'variant', row.variant); @@ -5295,7 +5620,7 @@ index 625a913..0e702f6 100644 if (row.variant === 'loading') body.appendChild(createElement(context.document, 'span', 'ok-native-list-spinner')); const message = row.variant === 'spacer' ? '' : row.message ?? (row.variant === 'end' ? 'End' : ''); if (message) body.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', message)); -@@ -852,12 +1415,31 @@ function createDataRow(context, row) { +@@ -852,12 +1420,31 @@ function createDataRow(context, row) { function createIdentityActivityOrMessageRow(context, row) { const presentation = row.type === 'identity' ? row.presentation : undefined; const body = createElement(context.document, 'div', ['ok-native-list-row', 'ok-native-list-standard', row.type === 'identity' && !presentation ? 'ok-native-list-identity-row' : '', presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', presentation === 'accountSelector' ? 'ok-native-list-account-row' : ''].filter(Boolean).join(' ')); @@ -5328,7 +5653,7 @@ index 625a913..0e702f6 100644 if (visual) body.appendChild(visual); if (row.type === 'activity' && row.secondaryLeading) { const secondVisual = createVisual(context, row.secondaryLeading); -@@ -866,7 +1448,46 @@ function createIdentityActivityOrMessageRow(context, row) { +@@ -866,7 +1453,46 @@ function createIdentityActivityOrMessageRow(context, row) { if (row.type === 'message' && row.unread) body.appendChild(createElement(context.document, 'span', 'ok-native-list-unread')); const title = row.title; const subtitle = row.type === 'identity' ? row.subtitle : row.type === 'activity' ? row.description : row.body; @@ -5376,7 +5701,7 @@ index 625a913..0e702f6 100644 if (row.type === 'activity' && row.status) column.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', row.status)); if (row.type === 'activity' && row.footerActions?.length) { const actions = createElement(context.document, 'span', 'ok-native-list-actions'); -@@ -898,14 +1519,32 @@ function createIdentityActivityOrMessageRow(context, row) { +@@ -898,14 +1524,32 @@ function createIdentityActivityOrMessageRow(context, row) { } return body; } @@ -5410,7 +5735,7 @@ index 625a913..0e702f6 100644 body.appendChild(memberElement); }); return body; -@@ -945,6 +1584,10 @@ export class NativeListWebEngine { +@@ -945,6 +1589,11 @@ export class NativeListWebEngine { }; mounted = new Map(); pool = []; @@ -5418,10 +5743,11 @@ index 625a913..0e702f6 100644 + avatarLeases = new Map(); + avatarOffset = 0; + avatarDirection = 1; ++ sectionIndexEntries = []; suppressClickUntil = 0; pullDistance = 0; actionAnchorInstanceId = String(++webNativeListInstanceCounter); -@@ -953,6 +1596,8 @@ export class NativeListWebEngine { +@@ -953,6 +1602,8 @@ export class NativeListWebEngine { lastViewportWidth = -1; lastViewportHeight = -1; destroyed = false; @@ -5430,7 +5756,7 @@ index 625a913..0e702f6 100644 constructor(host, snapshot, callbacks, virtualizationEnabled = true) { this.document = host.ownerDocument; this.snapshot = validateSnapshot(snapshot); -@@ -991,6 +1636,9 @@ export class NativeListWebEngine { +@@ -991,6 +1642,9 @@ export class NativeListWebEngine { passive: true }); this.root.addEventListener('click', this.handleClick); @@ -5440,7 +5766,7 @@ index 625a913..0e702f6 100644 this.root.addEventListener('keydown', this.handleKeyDown); this.viewport.addEventListener('pointerdown', this.handleReorderPointerDown, { passive: false -@@ -1125,7 +1773,7 @@ export class NativeListWebEngine { +@@ -1125,7 +1779,7 @@ export class NativeListWebEngine { scrollToLocation(params, scroll) { const index = resolveLocationIndex(this.snapshot.rows, params); if (index === undefined) { @@ -5449,7 +5775,7 @@ index 625a913..0e702f6 100644 this.emitScrollFailure(params.itemIndex, params.sectionIndex >= sectionCount ? 'section-out-of-range' : 'item-out-of-range'); return; } -@@ -1172,6 +1820,8 @@ export class NativeListWebEngine { +@@ -1172,6 +1826,8 @@ export class NativeListWebEngine { this.document.defaultView?.removeEventListener('resize', this.handleWindowResize); this.viewport.removeEventListener('scroll', this.handleScroll); this.root.removeEventListener('click', this.handleClick); @@ -5458,7 +5784,7 @@ index 625a913..0e702f6 100644 this.root.removeEventListener('keydown', this.handleKeyDown); this.cancelPointerReorder(true); this.viewport.removeEventListener('pointerdown', this.handleReorderPointerDown); -@@ -1189,14 +1839,19 @@ export class NativeListWebEngine { +@@ -1189,19 +1845,23 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointerup', this.handlePullEnd); this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; @@ -5478,7 +5804,12 @@ index 625a913..0e702f6 100644 this.snapshot = snapshot; this.rows = effectiveRows(snapshot); this.selectedKeys = selectedKeys ?? selectionStateFromSnapshot(snapshot).selectedKeys; -@@ -1219,6 +1874,11 @@ export class NativeListWebEngine { + if (this.reachedGeneration !== snapshot.generation) this.reachedGeneration = undefined; +- this.renderSectionIndex(); + this.applyTheme(); + this.recomputeLayout(); + this.renderFooter(); +@@ -1219,6 +1879,11 @@ export class NativeListWebEngine { '--nl-primary': theme.primaryText, '--nl-secondary': theme.secondaryText, '--nl-disabled': theme.disabledText, @@ -5490,7 +5821,7 @@ index 625a913..0e702f6 100644 '--nl-icon': theme.icon, '--nl-icon-subdued': theme.iconSubdued, '--nl-separator': theme.separator, -@@ -1242,11 +1902,19 @@ export class NativeListWebEngine { +@@ -1242,13 +1907,22 @@ export class NativeListWebEngine { const viewportHeight = this.viewport.clientHeight; if (this.lastViewportWidth >= 0 && (viewportWidth !== this.lastViewportWidth || viewportHeight !== this.lastViewportHeight)) { this.invalidateActionAnchor('layout'); @@ -5510,8 +5841,11 @@ index 625a913..0e702f6 100644 + this.layout = computeWebListLayout(measuredSnapshot, viewportWidth, viewportHeight, this.reorderCompactKey); this.content.style.width = String(this.layout.contentWidth) + 'px'; this.content.style.height = String(this.layout.contentHeight) + 'px'; ++ this.renderSectionIndex(viewportHeight || DEFAULT_VIEWPORT_HEIGHT); if (previousHorizontal !== this.layout.horizontal) { -@@ -1256,7 +1924,44 @@ export class NativeListWebEngine { + this.viewport.scrollLeft = 0; + this.viewport.scrollTop = 0; +@@ -1256,7 +1930,44 @@ export class NativeListWebEngine { this.renderWindow(); this.performPendingScroll(); }; @@ -5556,7 +5890,7 @@ index 625a913..0e702f6 100644 const viewportLength = this.viewportLength(); const visible = webLayoutItemsForMount(this.layout, this.currentOffset(), viewportLength, this.virtualizationEnabled, viewportLength * OVERSCAN_VIEWPORTS); const desired = new Set(visible.map(item => item.index)); -@@ -1264,6 +1969,7 @@ export class NativeListWebEngine { +@@ -1264,6 +1975,7 @@ export class NativeListWebEngine { if (!desired.has(index)) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); @@ -5564,7 +5898,7 @@ index 625a913..0e702f6 100644 element.remove(); this.pool.push(element); } -@@ -1285,6 +1991,20 @@ export class NativeListWebEngine { +@@ -1285,6 +1997,20 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); @@ -5585,7 +5919,7 @@ index 625a913..0e702f6 100644 this.updateVisibleSelection(); this.updateVisibleState(); } -@@ -1295,12 +2015,16 @@ export class NativeListWebEngine { +@@ -1295,12 +2021,16 @@ export class NativeListWebEngine { } renderElement(element, index, row, overlay = false) { this.invalidateActionAnchorForElement(element); @@ -5602,7 +5936,7 @@ index 625a913..0e702f6 100644 setData(element, 'nativeListReorderable', this.isReorderable(row)); setData(element, 'nativeListDragging', this.pointerReorder?.active && this.pointerReorder.sourceKey === row.key); setData(element, 'separator', row.separator); -@@ -1320,11 +2044,67 @@ export class NativeListWebEngine { +@@ -1320,11 +2050,67 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index }; @@ -5671,7 +6005,67 @@ index 625a913..0e702f6 100644 this.footer.replaceChildren(); if (!row) return; const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -1357,7 +2137,9 @@ export class NativeListWebEngine { +@@ -1335,20 +2121,52 @@ export class NativeListWebEngine { + element.style.height = String(estimateWebRowHeight(row, this.snapshot, this.viewport.clientWidth || DEFAULT_VIEWPORT_WIDTH)) + 'px'; + this.footer.appendChild(element); + } +- renderSectionIndex() { ++ sectionIndexVisibleEntryIndices(viewportHeight) { ++ const entryCount = this.sectionIndexEntries.length; ++ if (entryCount <= 1) return entryCount ? [0] : []; ++ const availableHeight = Math.max(0, viewportHeight - SECTION_INDEX_EDGE_PADDING * 2); ++ const maxVisible = Math.max(2, Math.floor(availableHeight / SECTION_INDEX_MIN_LABEL_SPACING) + 1); ++ if (entryCount <= maxVisible) { ++ return Array.from({ ++ length: entryCount ++ }, (_, index) => index); ++ } ++ const result = new Set(); ++ for (let slot = 0; slot < maxVisible; slot += 1) { ++ result.add(Math.round(slot * (entryCount - 1) / (maxVisible - 1))); ++ } ++ return [...result].sort((left, right) => left - right); ++ } ++ renderSectionIndex(viewportHeight) { + this.indexRail.replaceChildren(); + if (!sectionIndexEnabled(this.snapshot)) { ++ this.sectionIndexEntries = []; ++ this.indexRail.hidden = true; ++ return; ++ } ++ this.sectionIndexEntries = this.snapshot.rows.flatMap((row, position) => row.type === 'sectionHeader' && row.indexTitle ? [{ ++ key: row.key, ++ title: row.indexTitle, ++ position ++ }] : []); ++ if (this.sectionIndexEntries.length === 0 || viewportHeight < SECTION_INDEX_MIN_HEIGHT) { + this.indexRail.hidden = true; + return; + } ++ const visibleEntryIndices = this.sectionIndexVisibleEntryIndices(viewportHeight); ++ setData(this.indexRail, 'compact', visibleEntryIndices.length < this.sectionIndexEntries.length); + const fragment = this.document.createDocumentFragment(); +- this.snapshot.rows.forEach((row, index) => { +- if (row.type !== 'sectionHeader' || !row.indexTitle) return; +- const button = createElement(this.document, 'button', 'ok-native-list-index-button', row.indexTitle); ++ visibleEntryIndices.forEach(entryIndex => { ++ const entry = this.sectionIndexEntries[entryIndex]; ++ if (!entry) return; ++ const button = createElement(this.document, 'button', 'ok-native-list-index-button', entry.title); + button.setAttribute('type', 'button'); +- button.setAttribute('aria-label', 'Jump to ' + row.indexTitle); +- setData(button, 'sectionPosition', index); +- setData(button, 'sectionKey', row.key); ++ button.setAttribute('aria-label', 'Jump to ' + entry.title); ++ setData(button, 'sectionEntryIndex', entryIndex); ++ setData(button, 'sectionPosition', entry.position); ++ setData(button, 'sectionKey', entry.key); ++ const progress = this.sectionIndexEntries.length === 1 ? 0.5 : entryIndex / (this.sectionIndexEntries.length - 1); ++ button.style.top = String(SECTION_INDEX_EDGE_PADDING + progress * (viewportHeight - SECTION_INDEX_EDGE_PADDING * 2)) + 'px'; + fragment.appendChild(button); + }); + this.indexRail.appendChild(fragment); +@@ -1357,7 +2175,9 @@ export class NativeListWebEngine { updateVisibleSelection() { const update = (element, row) => { if (!row) return; @@ -5682,7 +6076,7 @@ index 625a913..0e702f6 100644 setData(element, 'nativeListSelected', selected); element.setAttribute('aria-selected', String(selected)); element.querySelectorAll('.ok-native-list-checkbox').forEach(checkbox => { -@@ -1396,7 +2178,9 @@ export class NativeListWebEngine { +@@ -1396,7 +2216,9 @@ export class NativeListWebEngine { } this.checkEndReached(last?.index ?? -1); this.updateStickyHeader(first?.index ?? -1); @@ -5693,7 +6087,7 @@ index 625a913..0e702f6 100644 } updateStickyHeader(firstVisibleIndex) { if (!this.snapshot.layout.stickyHeaders || this.layout.horizontal || firstVisibleIndex < 0) { -@@ -1407,7 +2191,7 @@ export class NativeListWebEngine { +@@ -1407,7 +2229,7 @@ export class NativeListWebEngine { let index = -1; for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { const row = this.rows[cursor]; @@ -5702,7 +6096,7 @@ index 625a913..0e702f6 100644 index = cursor; break; } -@@ -1433,7 +2217,7 @@ export class NativeListWebEngine { +@@ -1433,7 +2255,7 @@ export class NativeListWebEngine { let nextIndex = -1; for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { const candidate = this.rows[cursor]; @@ -5711,7 +6105,7 @@ index 625a913..0e702f6 100644 nextIndex = cursor; break; } -@@ -1443,10 +2227,15 @@ export class NativeListWebEngine { +@@ -1443,10 +2265,15 @@ export class NativeListWebEngine { this.sticky.style.transform = 'translate3d(0,' + String(translate) + 'px,0)'; this.updateVisibleSelection(); } @@ -5729,7 +6123,7 @@ index 625a913..0e702f6 100644 }); this.indexRail.querySelectorAll('[data-section-key]').forEach(button => setData(button, 'active', button.dataset.sectionKey === activeKey)); } -@@ -1539,7 +2328,14 @@ export class NativeListWebEngine { +@@ -1539,7 +2366,14 @@ export class NativeListWebEngine { const bindingEpoch = rowElement.dataset.nativeListBindingEpoch; if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; this.invalidateActionAnchor('rebind'); @@ -5745,7 +6139,7 @@ index 625a913..0e702f6 100644 const token = [this.actionAnchorInstanceId, this.snapshot.generation, ++this.actionAnchorCounter, bindingEpoch].join(':'); const slotValue = actionElement.dataset.nativeListAnchorSlot; const direction = this.document.defaultView?.getComputedStyle(actionElement).direction === 'rtl' || actionElement.closest('[dir="rtl"]') ? 'rtl' : 'ltr'; -@@ -1577,7 +2373,9 @@ export class NativeListWebEngine { +@@ -1577,7 +2411,9 @@ export class NativeListWebEngine { }); } handleRowPress(row, rowElement, sourceElement = rowElement) { @@ -5756,7 +6150,7 @@ index 625a913..0e702f6 100644 if (this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && isSelectableRow(row)) { this.activateSelection({ scope: 'row' -@@ -1597,6 +2395,27 @@ export class NativeListWebEngine { +@@ -1597,6 +2433,27 @@ export class NativeListWebEngine { this.emitRowAction(row, actionKey); } } @@ -5784,7 +6178,7 @@ index 625a913..0e702f6 100644 handleClick = event => { if (Date.now() < this.suppressClickUntil) { event.preventDefault(); -@@ -1627,6 +2446,12 @@ export class NativeListWebEngine { +@@ -1627,6 +2484,12 @@ export class NativeListWebEngine { this.handleRowPress(sourceRow, rowElement ?? undefined, memberElement ?? rowElement ?? undefined); }; handleKeyDown = event => { @@ -5797,7 +6191,7 @@ index 625a913..0e702f6 100644 if (event.key === 'Escape') { if (this.pointerReorder?.active) { event.preventDefault(); -@@ -1729,7 +2554,10 @@ export class NativeListWebEngine { +@@ -1729,7 +2592,10 @@ export class NativeListWebEngine { if (this.frameHandle !== undefined || this.destroyed) return; this.frameHandle = this.requestFrame(() => { this.frameHandle = undefined; @@ -5809,7 +6203,79 @@ index 625a913..0e702f6 100644 }); } requestFrame(callback) { -@@ -1816,7 +2644,14 @@ export class NativeListWebEngine { +@@ -1740,13 +2606,20 @@ export class NativeListWebEngine { + const view = this.document.defaultView; + if (view?.cancelAnimationFrame) view.cancelAnimationFrame(handle);else view?.clearTimeout(handle); + } +- selectIndexPosition(position, title) { ++ selectIndexPosition(position, title, previewClientY) { + this.scrollToIndex(position, { + animated: false, + alignment: 'start', + viewPosition: 0, + viewOffset: 0 + }); ++ const frame = this.viewportFrame.getBoundingClientRect(); ++ if (previewClientY !== undefined && frame.height > 0) { ++ const previewY = Math.min(frame.height - 24, Math.max(24, previewClientY - frame.top)); ++ this.indexPreview.style.top = String(previewY) + 'px'; ++ } else { ++ this.indexPreview.style.top = '50%'; ++ } + this.indexPreview.textContent = title; + setData(this.indexPreview, 'visible', true); + if (this.previewTimer !== undefined) this.document.defaultView?.clearTimeout(this.previewTimer); +@@ -1754,24 +2627,40 @@ export class NativeListWebEngine { + setData(this.indexPreview, 'visible', false); + }, 180); + } +- indexButtonAtEvent(event) { +- const direct = event.target?.closest('[data-section-position]'); +- if (direct) return direct; +- return this.document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-section-position]') ?? undefined; ++ sectionIndexEntryAtEvent(event) { ++ const rail = this.indexRail.getBoundingClientRect(); ++ if (this.sectionIndexEntries.length === 0 || rail.height <= 0) { ++ return undefined; ++ } ++ const availableHeight = Math.max(1, rail.height - SECTION_INDEX_EDGE_PADDING * 2); ++ const progress = Math.min(1, Math.max(0, (event.clientY - rail.top - SECTION_INDEX_EDGE_PADDING) / availableHeight)); ++ const entryIndex = Math.round(progress * (this.sectionIndexEntries.length - 1)); ++ const entry = this.sectionIndexEntries[entryIndex]; ++ return entry ? { ++ entry, ++ previewClientY: event.clientY ++ } : undefined; + } + handleIndexPointer = event => { + if (event.type === 'pointermove' && event.buttons === 0) return; +- const button = this.indexButtonAtEvent(event); +- if (!button) return; ++ const selection = this.sectionIndexEntryAtEvent(event); ++ if (!selection) return; + event.preventDefault(); +- this.selectIndexPosition(Number(button.dataset.sectionPosition), button.textContent ?? ''); ++ if (event.type === 'pointerdown') { ++ this.indexRail.setPointerCapture?.(event.pointerId); ++ } ++ this.selectIndexPosition(selection.entry.position, selection.entry.title, selection.previewClientY); + }; + handleIndexClick = event => { ++ if ('detail' in event && event.detail > 0) return; + const target = event.target; + if (!(target instanceof Element)) return; +- const button = target.closest('[data-section-position]'); ++ const button = target.closest('[data-section-entry-index]'); + if (!button) return; +- this.selectIndexPosition(Number(button.dataset.sectionPosition), button.textContent ?? ''); ++ const entry = this.sectionIndexEntries[Number(button.dataset.sectionEntryIndex)]; ++ if (!entry) return; ++ const rect = button.getBoundingClientRect(); ++ this.selectIndexPosition(entry.position, entry.title, rect.top + rect.height / 2); + }; + handlePullStart = event => { + if (this.pointerReorder?.pointerId === event.pointerId || !this.snapshot.capabilities?.pullToRefresh || this.viewport.scrollTop > 0 || event.pointerType !== 'touch' && event.pointerType !== 'pen') return; +@@ -1816,7 +2705,14 @@ export class NativeListWebEngine { const index = Number(rowElement?.dataset.nativeListRowIndex); const row = this.rows[index]; if (!row || !this.isReorderable(row)) return; @@ -5825,7 +6291,7 @@ index 625a913..0e702f6 100644 const view = this.document.defaultView; const state = { pointerId: event.pointerId, -@@ -1832,9 +2667,8 @@ export class NativeListWebEngine { +@@ -1832,9 +2728,8 @@ export class NativeListWebEngine { active: false }; this.pointerReorder = state; @@ -5837,7 +6303,7 @@ index 625a913..0e702f6 100644 state.longPressTimer = view?.setTimeout(() => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS); } }; -@@ -1935,6 +2769,20 @@ export class NativeListWebEngine { +@@ -1935,6 +2830,20 @@ export class NativeListWebEngine { state.previewOffsetX = state.startX - rect.left; state.previewOffsetY = Math.min(previewHeight, Math.max(0, state.startY - rect.top)); this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); @@ -5858,7 +6324,7 @@ index 625a913..0e702f6 100644 const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; if (badgeText) { -@@ -1970,6 +2818,7 @@ export class NativeListWebEngine { +@@ -1970,6 +2879,7 @@ export class NativeListWebEngine { } clearReorderPreviewVisual() { this.reorderPreview.hidden = true; @@ -5866,7 +6332,7 @@ index 625a913..0e702f6 100644 this.reorderPreview.replaceChildren(); this.reorderPreview.style.removeProperty('transform'); this.reorderPreview.style.removeProperty('transition'); -@@ -2235,4 +3084,3 @@ export class NativeListWebEngine { +@@ -2235,4 +3145,3 @@ export class NativeListWebEngine { }); } } @@ -6538,10 +7004,10 @@ index 0000000..3510bc4 +}); diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs new file mode 100644 -index 0000000..3937d88 +index 0000000..c6c3400 --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs -@@ -0,0 +1,461 @@ +@@ -0,0 +1,554 @@ +// OneKey patch: focused regression checks for the serialized selector adapter contract. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); @@ -6602,6 +7068,99 @@ index 0000000..3937d88 + page.close(); + } +}); ++test('compact web index keeps every section reachable without overflowing short viewports', () => { ++ const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); ++ const rows = [ ++ ...letters.map((letter) => ({ ++ type: 'sectionHeader', ++ key: letter, ++ sectionKey: letter, ++ title: letter, ++ indexTitle: letter, ++ height: 36, ++ })), ++ { type: 'system', variant: 'spacer', key: 'tail', height: 500 }, ++ ]; ++ const page = mount(rows, { ++ capabilities: { sectionIndex: { enabled: true } }, ++ }); ++ try { ++ const viewport = page.document.querySelector('.ok-native-list-viewport'); ++ const frame = page.document.querySelector('.ok-native-list-viewport-frame'); ++ const rail = page.document.querySelector('.ok-native-list-index-rail'); ++ Object.defineProperty(viewport, 'clientHeight', { ++ configurable: true, ++ value: 160, ++ }); ++ Object.defineProperty(viewport, 'clientWidth', { ++ configurable: true, ++ value: 320, ++ }); ++ rail.getBoundingClientRect = () => ({ ++ x: 288, ++ y: 20, ++ left: 288, ++ top: 20, ++ right: 320, ++ bottom: 180, ++ width: 32, ++ height: 160, ++ }); ++ frame.getBoundingClientRect = () => ({ ++ x: 0, ++ y: 20, ++ left: 0, ++ top: 20, ++ right: 320, ++ bottom: 180, ++ width: 320, ++ height: 160, ++ }); ++ page.engine.recomputeLayout(); ++ ++ const buttons = [...rail.querySelectorAll('[data-section-entry-index]')]; ++ assert.equal(rail.dataset.compact, 'true'); ++ assert(buttons.length < letters.length); ++ assert.equal(page.document.querySelector('[aria-label="Jump to H"]'), null); ++ assert.equal(page.engine.layout.items[0].width, 304); ++ ++ const targetIndex = 7; ++ const targetY = 20 + 8 + (targetIndex / (letters.length - 1)) * 144; ++ const overlappingVisibleButton = rail.querySelector( ++ '[data-section-entry-index="8"]', ++ ); ++ assert(overlappingVisibleButton); ++ overlappingVisibleButton.dispatchEvent( ++ new page.view.MouseEvent('pointerdown', { ++ bubbles: true, ++ buttons: 1, ++ clientX: 304, ++ clientY: targetY, ++ }), ++ ); ++ overlappingVisibleButton.dispatchEvent( ++ new page.view.MouseEvent('click', { ++ bubbles: true, ++ detail: 1, ++ }), ++ ); ++ assert.equal( ++ page.document.querySelector('.ok-native-list-index-preview').textContent, ++ 'H', ++ ); ++ assert.equal(viewport.scrollTop, targetIndex * 36); ++ ++ Object.defineProperty(viewport, 'clientHeight', { ++ configurable: true, ++ value: 100, ++ }); ++ page.engine.recomputeLayout(); ++ assert.equal(rail.hidden, true); ++ assert.equal(page.engine.layout.items[0].width, 320); ++ } finally { ++ page.close(); ++ } ++}); +test('selector explicit dimensions override presets without changing existing defaults', () => { + const normal = identity('n', { presentation: 'networkSelector' }); + assert.equal(estimateWebRowHeight(normal, snapshot([normal]), 400), 47); @@ -8175,19 +8734,27 @@ index 0000000..d921a4c + return cache.acquire(uri, resolve, reject, priority); +} diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -index 11d57d1..80b8871 100644 +index 11d57d1..3a4e04b 100644 --- a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -@@ -37,6 +37,8 @@ import { +@@ -37,8 +37,14 @@ import { type NormalizedPositionScroll, } from '../scrolling'; import { applyRowPatches, validateSnapshot } from '../validation'; +- +-const SECTION_INDEX_GUTTER = 44; +import { avatarPrefetchWindow } from '../avatarPrefetch'; +import { acquireNativeListAvatar, canonicalNativeListAvatarUri, type AvatarLease } from './NativeListWebAvatarCache'; - - const SECTION_INDEX_GUTTER = 44; ++ ++const SECTION_INDEX_CONTENT_INSET = 16; ++const SECTION_INDEX_RAIL_WIDTH = 32; ++const SECTION_INDEX_EDGE_PADDING = 8; ++const SECTION_INDEX_MIN_LABEL_SPACING = 14; ++const SECTION_INDEX_MIN_HEIGHT = 120; const DEFAULT_VIEWPORT_WIDTH = 320; -@@ -354,11 +356,23 @@ export function estimateWebRowHeight( + const DEFAULT_VIEWPORT_HEIGHT = 640; + const OVERSCAN_VIEWPORTS = 1; +@@ -354,11 +360,23 @@ export function estimateWebRowHeight( snapshot: NativeListSnapshot, availableWidth: number ): number { @@ -8214,7 +8781,22 @@ index 11d57d1..80b8871 100644 if (row.type === 'identity' && row.presentation === 'networkSelector') return 47; if (row.type === 'identity' && row.presentation === 'accountSelector') -@@ -699,7 +713,9 @@ export function webRowRenderSignature(row: RowModel): string { +@@ -471,9 +489,13 @@ export function computeWebListLayout( + const horizontal = snapshot.layout.orientation === 'horizontal'; + const spacing = snapshot.layout.itemSpacing ?? 0; + const padding = paddingValues(snapshot); +- const indexGutter = sectionIndexEnabled(snapshot) ? SECTION_INDEX_GUTTER : 0; + const width = Math.max(1, viewportWidth || DEFAULT_VIEWPORT_WIDTH); + const height = Math.max(1, viewportHeight || DEFAULT_VIEWPORT_HEIGHT); ++ // OneKey patch: the index overlays the content and only keeps a small accessory-safe inset. ++ const indexGutter = ++ sectionIndexEnabled(snapshot) && height >= SECTION_INDEX_MIN_HEIGHT ++ ? SECTION_INDEX_CONTENT_INSET ++ : 0; + const availableWidth = Math.max( + 1, + width - padding.horizontal * 2 - indexGutter +@@ -699,7 +721,9 @@ export function webRowRenderSignature(row: RowModel): string { return JSON.stringify(rowWithoutSelectionState(row)); } @@ -8224,9 +8806,12 @@ index 11d57d1..80b8871 100644 .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -754,7 +770,27 @@ export const WEB_LIST_CSS = ` +@@ -752,9 +776,29 @@ export const WEB_LIST_CSS = ` + .ok-native-list-media{display:block;padding:0 5px;background:transparent;border-radius:16px}.ok-native-list-media-image{display:block;width:100%;aspect-ratio:1;border-radius:10px;background:var(--nl-strong);object-fit:cover}.ok-native-list-media-image[data-state="empty"]{background:transparent}.ok-native-list-media-image[data-state="error"]{display:flex;align-items:center;justify-content:center;color:var(--nl-icon-subdued);font-size:24px}.ok-native-list-media-meta{padding-top:7px}.ok-native-list-media-subtitle-row{display:flex;align-items:center;gap:6px}.ok-native-list-media-subtitle{flex:1;min-width:0;font-size:12px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-network{width:14px;height:14px;border-radius:50%}.ok-native-list-media-title{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-close{position:absolute;right:9px;top:4px;border:0;background:color-mix(in srgb,var(--nl-inverse) 72%,transparent);color:var(--nl-inverse-text);width:24px;height:24px;border-radius:50%;font:18px/20px inherit;cursor:pointer} + .ok-native-list-metric{display:flex;flex-direction:column;align-items:flex-start;padding:12px;border-radius:12px;gap:5px;background:var(--nl-row)}.ok-native-list-metric-value{font-size:22px;line-height:28px;font-weight:700}.ok-native-list-composite{display:flex;flex-direction:column;align-items:stretch;padding:14px;border-radius:12px;gap:12px;background:var(--nl-subdued)}.ok-native-list-composite-heading{font-size:14px;letter-spacing:1px;color:var(--nl-secondary)}.ok-native-list-composite-row{display:flex;gap:12px}.ok-native-list-composite-cell{flex:1;min-width:0}.ok-native-list-composite-cell[data-shaded="true"]{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--nl-primary) 5%,transparent)}.ok-native-list-composite-value{font-size:18px;font-weight:600}.ok-native-list-divider{height:1px;background:var(--nl-separator)}.ok-native-list-progress{height:4px;border-radius:2px;overflow:hidden;background:var(--nl-negative)}.ok-native-list-progress>span{display:block;height:100%;border-radius:2px;background:var(--nl-positive)} .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} - .ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} +-.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} ++.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:6px;display:flex;width:20px;height:16px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:8px;background:transparent;color:var(--nl-secondary);font:600 10px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-accent);color:var(--nl-inverse-text)}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-accent);outline-offset:1px}.ok-native-list-index-preview{position:absolute;z-index:8;right:40px;top:50%;display:flex;width:48px;height:48px;align-items:center;justify-content:center;transform:translateY(-50%) scale(.92);border-radius:14px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:22px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translateY(-50%) scale(1)} .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} +.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} +.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} @@ -8252,7 +8837,7 @@ index 11d57d1..80b8871 100644 `; function createElement( -@@ -788,16 +824,114 @@ function safeImageUri(uri: string): string | undefined { +@@ -788,16 +832,114 @@ function safeImageUri(uri: string): string | undefined { return undefined; } @@ -8369,7 +8954,7 @@ index 11d57d1..80b8871 100644 image.alt = ''; image.draggable = false; image.loading = 'lazy'; -@@ -807,6 +941,43 @@ function createImage( +@@ -807,6 +949,43 @@ function createImage( return image; } @@ -8413,7 +8998,7 @@ index 11d57d1..80b8871 100644 function iconGlyph(name: string): string { const normalized = name.toLocaleLowerCase(); if (normalized.includes('chevron')) { -@@ -841,7 +1012,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { +@@ -841,7 +1020,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { function createVisual( context: RenderContext, @@ -8423,7 +9008,7 @@ index 11d57d1..80b8871 100644 ): HTMLElement | undefined { if (!visual) return undefined; if (visual.kind === 'stackedImages') { -@@ -873,6 +1045,7 @@ function createVisual( +@@ -873,6 +1053,7 @@ function createVisual( 'ok-native-list-visual-fallback', iconGlyph(visual.name) ); @@ -8431,7 +9016,7 @@ index 11d57d1..80b8871 100644 if (visual.tintColor) fallback.style.color = visual.tintColor; frame.appendChild(fallback); return frame; -@@ -885,6 +1058,7 @@ function createVisual( +@@ -885,6 +1066,7 @@ function createVisual( if (image) { image.className = 'ok-native-list-visual-main'; frame.appendChild(image); @@ -8439,7 +9024,7 @@ index 11d57d1..80b8871 100644 } else { frame.appendChild( createElement( -@@ -913,8 +1087,51 @@ function createVisual( +@@ -913,8 +1095,51 @@ function createVisual( corner.style.color = visual.cornerIcon.tintColor; if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; @@ -8491,7 +9076,7 @@ index 11d57d1..80b8871 100644 return frame; } -@@ -1022,6 +1239,16 @@ function createCheckbox( +@@ -1022,6 +1247,16 @@ function createCheckbox( setData(element, 'checkboxFallback', accessory.state); setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); @@ -8508,7 +9093,7 @@ index 11d57d1..80b8871 100644 if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey); else if (accessory.target?.scope === 'row') -@@ -1046,6 +1273,7 @@ function createIconAction( +@@ -1046,6 +1281,7 @@ function createIconAction( element.setAttribute('type', 'button'); element.toggleAttribute('disabled', Boolean(disabled)); } @@ -8516,7 +9101,7 @@ index 11d57d1..80b8871 100644 if (actionKey) setData(element, 'nativeListAction', actionKey); if (tintColor) element.style.color = tintColor; return element; -@@ -1060,6 +1288,20 @@ function markActionAnchorSource( +@@ -1060,6 +1296,20 @@ function markActionAnchorSource( if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } @@ -8537,7 +9122,7 @@ index 11d57d1..80b8871 100644 function createAccessory( context: RenderContext, rowKey: string, -@@ -1080,6 +1322,18 @@ function createAccessory( +@@ -1080,6 +1330,18 @@ function createAccessory( accessory.tintColor ); markActionAnchorSource(element, 'trailingAccessory', slot); @@ -8556,7 +9141,7 @@ index 11d57d1..80b8871 100644 return element; } if (accessory.kind === 'spinner') { -@@ -1099,6 +1353,7 @@ function createAccessory( +@@ -1099,6 +1361,7 @@ function createAccessory( switch (accessory.kind) { case 'value': element.textContent = accessory.text; @@ -8564,7 +9149,7 @@ index 11d57d1..80b8871 100644 if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); break; -@@ -1157,6 +1412,13 @@ function appendAccessories( +@@ -1157,6 +1420,13 @@ function appendAccessories( 'span', 'ok-native-list-accessories' ); @@ -8578,7 +9163,7 @@ index 11d57d1..80b8871 100644 accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot)) ); -@@ -1234,7 +1496,59 @@ function createSectionHeader( +@@ -1234,7 +1504,59 @@ function createSectionHeader( markActionAnchorSource(titleIcon, 'leadingAction'); body.appendChild(titleIcon); } @@ -8639,7 +9224,7 @@ index 11d57d1..80b8871 100644 if (row.value) { const value = createElement( context.document, -@@ -1244,6 +1558,15 @@ function createSectionHeader( +@@ -1244,6 +1566,15 @@ function createSectionHeader( : 'ok-native-list-value ok-native-list-section-value', row.value ); @@ -8655,7 +9240,7 @@ index 11d57d1..80b8871 100644 if (row.valueActionKey) { value.setAttribute('type', 'button'); setData(value, 'nativeListAction', row.valueActionKey); -@@ -1292,6 +1615,7 @@ function createActionRow( +@@ -1292,6 +1623,7 @@ function createActionRow( row.title ); setData(title, 'tone', row.tone); @@ -8663,7 +9248,7 @@ index 11d57d1..80b8871 100644 body.appendChild(title); if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); -@@ -1309,6 +1633,13 @@ function createSystemRow( +@@ -1309,6 +1641,13 @@ function createSystemRow( 'ok-native-list-row ok-native-list-system' ); setData(body, 'variant', row.variant); @@ -8677,7 +9262,7 @@ index 11d57d1..80b8871 100644 if (row.variant === 'loading') body.appendChild( createElement(context.document, 'span', 'ok-native-list-spinner') -@@ -1703,6 +2034,11 @@ function createIdentityActivityOrMessageRow( +@@ -1703,6 +2042,11 @@ function createIdentityActivityOrMessageRow( .filter(Boolean) .join(' ') ); @@ -8689,7 +9274,7 @@ index 11d57d1..80b8871 100644 if (row.type === 'identity' && row.leadingAction) { const action = createIconAction( context, -@@ -1714,7 +2050,15 @@ function createIdentityActivityOrMessageRow( +@@ -1714,7 +2058,15 @@ function createIdentityActivityOrMessageRow( markActionAnchorSource(action, 'leadingAction'); body.appendChild(action); } @@ -8706,7 +9291,7 @@ index 11d57d1..80b8871 100644 if (visual) body.appendChild(visual); if (row.type === 'activity' && row.secondaryLeading) { const secondVisual = createVisual(context, row.secondaryLeading); -@@ -1737,8 +2081,44 @@ function createIdentityActivityOrMessageRow( +@@ -1737,8 +2089,44 @@ function createIdentityActivityOrMessageRow( subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, @@ -8752,7 +9337,7 @@ index 11d57d1..80b8871 100644 if (row.type === 'activity' && row.status) column.appendChild( createElement( -@@ -1814,6 +2194,13 @@ function createIdentityActivityOrMessageRow( +@@ -1814,6 +2202,13 @@ function createIdentityActivityOrMessageRow( return body; } @@ -8766,7 +9351,7 @@ index 11d57d1..80b8871 100644 function createWalletGroupRow( context: RenderContext, row: Extract -@@ -1830,11 +2217,18 @@ function createWalletGroupRow( +@@ -1830,11 +2225,18 @@ function createWalletGroupRow( 'ok-native-list-wallet-member' ); setData(memberElement, 'nativeListGroupMemberKey', member.key); @@ -8788,7 +9373,7 @@ index 11d57d1..80b8871 100644 body.appendChild(memberElement); }); return body; -@@ -1894,6 +2288,10 @@ export class NativeListWebEngine { +@@ -1894,9 +2296,18 @@ export class NativeListWebEngine { private resizeObserver: ResizeObserver | undefined; private pendingScroll: PendingScroll | undefined; private lastVisibleSignature: string | undefined; @@ -8799,7 +9384,15 @@ index 11d57d1..80b8871 100644 private reachedGeneration: number | undefined; private stickyKey: string | undefined; private previewTimer: number | undefined; -@@ -1919,6 +2317,8 @@ export class NativeListWebEngine { ++ private sectionIndexEntries: readonly Readonly<{ ++ key: string; ++ title: string; ++ position: number; ++ }>[] = []; + private pointerReorder: PointerReorderState | undefined; + private keyboardReorder: KeyboardReorderState | undefined; + private reorderMoveFrame: number | undefined; +@@ -1919,6 +2330,8 @@ export class NativeListWebEngine { private lastViewportWidth = -1; private lastViewportHeight = -1; private destroyed = false; @@ -8808,7 +9401,7 @@ index 11d57d1..80b8871 100644 constructor( host: HTMLElement, -@@ -2004,6 +2404,9 @@ export class NativeListWebEngine { +@@ -2004,6 +2417,9 @@ export class NativeListWebEngine { passive: true, }); this.root.addEventListener('click', this.handleClick); @@ -8818,7 +9411,7 @@ index 11d57d1..80b8871 100644 this.root.addEventListener('keydown', this.handleKeyDown); this.viewport.addEventListener( 'pointerdown', -@@ -2160,7 +2563,7 @@ export class NativeListWebEngine { +@@ -2160,7 +2576,7 @@ export class NativeListWebEngine { const index = resolveLocationIndex(this.snapshot.rows, params); if (index === undefined) { const sectionCount = this.snapshot.rows.filter( @@ -8827,7 +9420,7 @@ index 11d57d1..80b8871 100644 ).length; this.emitScrollFailure( params.itemIndex, -@@ -2219,6 +2622,8 @@ export class NativeListWebEngine { +@@ -2219,6 +2635,8 @@ export class NativeListWebEngine { ); this.viewport.removeEventListener('scroll', this.handleScroll); this.root.removeEventListener('click', this.handleClick); @@ -8836,7 +9429,7 @@ index 11d57d1..80b8871 100644 this.root.removeEventListener('keydown', this.handleKeyDown); this.cancelPointerReorder(true); this.viewport.removeEventListener( -@@ -2251,18 +2656,23 @@ export class NativeListWebEngine { +@@ -2251,25 +2669,29 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointerup', this.handlePullEnd); this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; @@ -8860,7 +9453,14 @@ index 11d57d1..80b8871 100644 this.snapshot = snapshot; this.rows = effectiveRows(snapshot); this.selectedKeys = -@@ -2288,6 +2698,11 @@ export class NativeListWebEngine { + selectedKeys ?? selectionStateFromSnapshot(snapshot).selectedKeys; + if (this.reachedGeneration !== snapshot.generation) + this.reachedGeneration = undefined; +- this.renderSectionIndex(); + this.applyTheme(); + this.recomputeLayout(); + this.renderFooter(); +@@ -2288,6 +2710,11 @@ export class NativeListWebEngine { '--nl-primary': theme.primaryText, '--nl-secondary': theme.secondaryText, '--nl-disabled': theme.disabledText, @@ -8872,7 +9472,7 @@ index 11d57d1..80b8871 100644 '--nl-icon': theme.icon, '--nl-icon-subdued': theme.iconSubdued, '--nl-separator': theme.separator, -@@ -2316,12 +2731,17 @@ export class NativeListWebEngine { +@@ -2316,18 +2743,24 @@ export class NativeListWebEngine { viewportHeight !== this.lastViewportHeight) ) { this.invalidateActionAnchor('layout'); @@ -8891,7 +9491,14 @@ index 11d57d1..80b8871 100644 viewportWidth, viewportHeight, this.reorderCompactKey -@@ -2336,7 +2756,38 @@ export class NativeListWebEngine { + ); + this.content.style.width = String(this.layout.contentWidth) + 'px'; + this.content.style.height = String(this.layout.contentHeight) + 'px'; ++ this.renderSectionIndex(viewportHeight || DEFAULT_VIEWPORT_HEIGHT); + if (previousHorizontal !== this.layout.horizontal) { + this.viewport.scrollLeft = 0; + this.viewport.scrollTop = 0; +@@ -2336,7 +2769,38 @@ export class NativeListWebEngine { this.performPendingScroll(); }; @@ -8930,7 +9537,7 @@ index 11d57d1..80b8871 100644 const viewportLength = this.viewportLength(); const visible = webLayoutItemsForMount( this.layout, -@@ -2350,6 +2801,7 @@ export class NativeListWebEngine { +@@ -2350,6 +2814,7 @@ export class NativeListWebEngine { if (!desired.has(index)) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); @@ -8938,7 +9545,7 @@ index 11d57d1..80b8871 100644 element.remove(); this.pool.push(element); } -@@ -2377,6 +2829,17 @@ export class NativeListWebEngine { +@@ -2377,6 +2842,17 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); @@ -8956,7 +9563,7 @@ index 11d57d1..80b8871 100644 this.updateVisibleSelection(); this.updateVisibleState(); } -@@ -2395,6 +2858,7 @@ export class NativeListWebEngine { +@@ -2395,6 +2871,7 @@ export class NativeListWebEngine { overlay = false ) { this.invalidateActionAnchorForElement(element); @@ -8964,7 +9571,7 @@ index 11d57d1..80b8871 100644 const bindingEpoch = String(++this.bindingEpochCounter); element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' -@@ -2403,6 +2867,9 @@ export class NativeListWebEngine { +@@ -2403,6 +2880,9 @@ export class NativeListWebEngine { setData(element, 'nativeListBindingEpoch', bindingEpoch); setData(element, 'nativeListRowIndex', index); setData(element, 'nativeListDisabled', Boolean(row.disabled)); @@ -8974,7 +9581,7 @@ index 11d57d1..80b8871 100644 setData(element, 'nativeListReorderable', this.isReorderable(row)); setData( element, -@@ -2435,12 +2902,48 @@ export class NativeListWebEngine { +@@ -2435,12 +2915,48 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index, }; @@ -9024,7 +9631,93 @@ index 11d57d1..80b8871 100644 this.footer.replaceChildren(); if (!row) return; const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -2487,7 +2990,9 @@ export class NativeListWebEngine { +@@ -2459,25 +2975,78 @@ export class NativeListWebEngine { + this.footer.appendChild(element); + } + +- private renderSectionIndex() { ++ private sectionIndexVisibleEntryIndices(viewportHeight: number): readonly number[] { ++ const entryCount = this.sectionIndexEntries.length; ++ if (entryCount <= 1) return entryCount ? [0] : []; ++ const availableHeight = Math.max( ++ 0, ++ viewportHeight - SECTION_INDEX_EDGE_PADDING * 2 ++ ); ++ const maxVisible = Math.max( ++ 2, ++ Math.floor(availableHeight / SECTION_INDEX_MIN_LABEL_SPACING) + 1 ++ ); ++ if (entryCount <= maxVisible) { ++ return Array.from({ length: entryCount }, (_, index) => index); ++ } ++ const result = new Set(); ++ for (let slot = 0; slot < maxVisible; slot += 1) { ++ result.add(Math.round((slot * (entryCount - 1)) / (maxVisible - 1))); ++ } ++ return [...result].sort((left, right) => left - right); ++ } ++ ++ private renderSectionIndex(viewportHeight: number) { + this.indexRail.replaceChildren(); + if (!sectionIndexEnabled(this.snapshot)) { ++ this.sectionIndexEntries = []; ++ this.indexRail.hidden = true; ++ return; ++ } ++ this.sectionIndexEntries = this.snapshot.rows.flatMap((row, position) => ++ row.type === 'sectionHeader' && row.indexTitle ++ ? [{ key: row.key, title: row.indexTitle, position }] ++ : [] ++ ); ++ if ( ++ this.sectionIndexEntries.length === 0 || ++ viewportHeight < SECTION_INDEX_MIN_HEIGHT ++ ) { + this.indexRail.hidden = true; + return; + } ++ const visibleEntryIndices = ++ this.sectionIndexVisibleEntryIndices(viewportHeight); ++ setData( ++ this.indexRail, ++ 'compact', ++ visibleEntryIndices.length < this.sectionIndexEntries.length ++ ); + const fragment = this.document.createDocumentFragment(); +- this.snapshot.rows.forEach((row, index) => { +- if (row.type !== 'sectionHeader' || !row.indexTitle) return; ++ visibleEntryIndices.forEach((entryIndex) => { ++ const entry = this.sectionIndexEntries[entryIndex]; ++ if (!entry) return; + const button = createElement( + this.document, + 'button', + 'ok-native-list-index-button', +- row.indexTitle ++ entry.title + ); + button.setAttribute('type', 'button'); +- button.setAttribute('aria-label', 'Jump to ' + row.indexTitle); +- setData(button, 'sectionPosition', index); +- setData(button, 'sectionKey', row.key); ++ button.setAttribute('aria-label', 'Jump to ' + entry.title); ++ setData(button, 'sectionEntryIndex', entryIndex); ++ setData(button, 'sectionPosition', entry.position); ++ setData(button, 'sectionKey', entry.key); ++ const progress = ++ this.sectionIndexEntries.length === 1 ++ ? 0.5 ++ : entryIndex / (this.sectionIndexEntries.length - 1); ++ button.style.top = ++ String( ++ SECTION_INDEX_EDGE_PADDING + ++ progress * ++ (viewportHeight - SECTION_INDEX_EDGE_PADDING * 2) ++ ) + 'px'; + fragment.appendChild(button); + }); + this.indexRail.appendChild(fragment); +@@ -2487,7 +3056,9 @@ export class NativeListWebEngine { private updateVisibleSelection() { const update = (element: HTMLElement, row: RowModel | undefined) => { if (!row) return; @@ -9035,7 +9728,7 @@ index 11d57d1..80b8871 100644 setData(element, 'nativeListSelected', selected); element.setAttribute('aria-selected', String(selected)); element -@@ -2548,7 +3053,9 @@ export class NativeListWebEngine { +@@ -2548,7 +3119,9 @@ export class NativeListWebEngine { } this.checkEndReached(last?.index ?? -1); this.updateStickyHeader(first?.index ?? -1); @@ -9046,7 +9739,7 @@ index 11d57d1..80b8871 100644 } private updateStickyHeader(firstVisibleIndex: number) { -@@ -2564,7 +3071,7 @@ export class NativeListWebEngine { +@@ -2564,7 +3137,7 @@ export class NativeListWebEngine { let index = -1; for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { const row = this.rows[cursor]; @@ -9055,7 +9748,7 @@ index 11d57d1..80b8871 100644 index = cursor; break; } -@@ -2594,7 +3101,7 @@ export class NativeListWebEngine { +@@ -2594,7 +3167,7 @@ export class NativeListWebEngine { for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { const candidate = this.rows[cursor]; if ( @@ -9064,7 +9757,7 @@ index 11d57d1..80b8871 100644 candidate.variant !== 'summary' ) { nextIndex = cursor; -@@ -2610,12 +3117,15 @@ export class NativeListWebEngine { +@@ -2610,12 +3183,15 @@ export class NativeListWebEngine { this.updateVisibleSelection(); } @@ -9083,7 +9776,7 @@ index 11d57d1..80b8871 100644 row.indexTitle ) activeKey = row.key; -@@ -2793,7 +3303,9 @@ export class NativeListWebEngine { +@@ -2793,7 +3369,9 @@ export class NativeListWebEngine { if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; this.invalidateActionAnchor('rebind'); @@ -9094,7 +9787,7 @@ index 11d57d1..80b8871 100644 const token = [ this.actionAnchorInstanceId, this.snapshot.generation, -@@ -2866,7 +3378,9 @@ export class NativeListWebEngine { +@@ -2866,7 +3444,9 @@ export class NativeListWebEngine { rowElement?: HTMLElement, sourceElement = rowElement ) { @@ -9105,7 +9798,7 @@ index 11d57d1..80b8871 100644 if ( this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && -@@ -2894,6 +3408,28 @@ export class NativeListWebEngine { +@@ -2894,6 +3474,28 @@ export class NativeListWebEngine { } } @@ -9134,7 +9827,7 @@ index 11d57d1..80b8871 100644 private handleClick = (event: Event) => { if (Date.now() < this.suppressClickUntil) { event.preventDefault(); -@@ -2944,6 +3480,12 @@ export class NativeListWebEngine { +@@ -2944,6 +3546,12 @@ export class NativeListWebEngine { }; private handleKeyDown = (event: KeyboardEvent) => { @@ -9147,7 +9840,7 @@ index 11d57d1..80b8871 100644 if (event.key === 'Escape') { if (this.pointerReorder?.active) { event.preventDefault(); -@@ -3073,7 +3615,7 @@ export class NativeListWebEngine { +@@ -3073,7 +3681,7 @@ export class NativeListWebEngine { this.frameHandle = this.requestFrame(() => { this.frameHandle = undefined; if (this.virtualizationEnabled) this.renderWindow(); @@ -9156,7 +9849,121 @@ index 11d57d1..80b8871 100644 }); } -@@ -3203,12 +3745,13 @@ export class NativeListWebEngine { +@@ -3090,13 +3698,27 @@ export class NativeListWebEngine { + else view?.clearTimeout(handle); + } + +- private selectIndexPosition(position: number, title: string) { ++ private selectIndexPosition( ++ position: number, ++ title: string, ++ previewClientY?: number ++ ) { + this.scrollToIndex(position, { + animated: false, + alignment: 'start', + viewPosition: 0, + viewOffset: 0, + }); ++ const frame = this.viewportFrame.getBoundingClientRect(); ++ if (previewClientY !== undefined && frame.height > 0) { ++ const previewY = Math.min( ++ frame.height - 24, ++ Math.max(24, previewClientY - frame.top) ++ ); ++ this.indexPreview.style.top = String(previewY) + 'px'; ++ } else { ++ this.indexPreview.style.top = '50%'; ++ } + this.indexPreview.textContent = title; + setData(this.indexPreview, 'visible', true); + if (this.previewTimer !== undefined) +@@ -3106,37 +3728,69 @@ export class NativeListWebEngine { + }, 180); + } + +- private indexButtonAtEvent(event: PointerEvent): HTMLElement | undefined { +- const direct = (event.target as Element | null)?.closest( +- '[data-section-position]' ++ private sectionIndexEntryAtEvent(event: PointerEvent): ++ | Readonly<{ ++ entry: (typeof this.sectionIndexEntries)[number]; ++ previewClientY: number; ++ }> ++ | undefined { ++ const rail = this.indexRail.getBoundingClientRect(); ++ if (this.sectionIndexEntries.length === 0 || rail.height <= 0) { ++ return undefined; ++ } ++ const availableHeight = Math.max( ++ 1, ++ rail.height - SECTION_INDEX_EDGE_PADDING * 2 + ); +- if (direct) return direct; +- return ( +- this.document +- .elementFromPoint(event.clientX, event.clientY) +- ?.closest('[data-section-position]') ?? undefined ++ const progress = Math.min( ++ 1, ++ Math.max( ++ 0, ++ (event.clientY - rail.top - SECTION_INDEX_EDGE_PADDING) / ++ availableHeight ++ ) + ); ++ const entryIndex = Math.round( ++ progress * (this.sectionIndexEntries.length - 1) ++ ); ++ const entry = this.sectionIndexEntries[entryIndex]; ++ return entry ? { entry, previewClientY: event.clientY } : undefined; + } + + private handleIndexPointer = (event: PointerEvent) => { + if (event.type === 'pointermove' && event.buttons === 0) return; +- const button = this.indexButtonAtEvent(event); +- if (!button) return; ++ const selection = this.sectionIndexEntryAtEvent(event); ++ if (!selection) return; + event.preventDefault(); ++ if (event.type === 'pointerdown') { ++ this.indexRail.setPointerCapture?.(event.pointerId); ++ } + this.selectIndexPosition( +- Number(button.dataset.sectionPosition), +- button.textContent ?? '' ++ selection.entry.position, ++ selection.entry.title, ++ selection.previewClientY + ); + }; + + private handleIndexClick = (event: Event) => { ++ if ( ++ 'detail' in event && ++ typeof event.detail === 'number' && ++ event.detail > 0 ++ ) ++ return; + const target = event.target; + if (!(target instanceof Element)) return; +- const button = target.closest('[data-section-position]'); ++ const button = target.closest('[data-section-entry-index]'); + if (!button) return; ++ const entry = ++ this.sectionIndexEntries[Number(button.dataset.sectionEntryIndex)]; ++ if (!entry) return; ++ const rect = button.getBoundingClientRect(); + this.selectIndexPosition( +- Number(button.dataset.sectionPosition), +- button.textContent ?? '' ++ entry.position, ++ entry.title, ++ rect.top + rect.height / 2 + ); + }; + +@@ -3203,12 +3857,13 @@ export class NativeListWebEngine { const index = Number(rowElement?.dataset.nativeListRowIndex); const row = this.rows[index]; if (!row || !this.isReorderable(row)) return; @@ -9176,7 +9983,7 @@ index 11d57d1..80b8871 100644 const view = this.document.defaultView; const state: PointerReorderState = { -@@ -3225,9 +3768,8 @@ export class NativeListWebEngine { +@@ -3225,9 +3880,8 @@ export class NativeListWebEngine { active: false, }; this.pointerReorder = state; @@ -9188,7 +9995,7 @@ index 11d57d1..80b8871 100644 state.longPressTimer = view?.setTimeout( () => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS -@@ -3363,6 +3905,18 @@ export class NativeListWebEngine { +@@ -3363,6 +4017,18 @@ export class NativeListWebEngine { Math.max(0, state.startY - rect.top) ); this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); @@ -9207,7 +10014,7 @@ index 11d57d1..80b8871 100644 const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) -@@ -3430,6 +3984,7 @@ export class NativeListWebEngine { +@@ -3430,6 +4096,7 @@ export class NativeListWebEngine { private clearReorderPreviewVisual() { this.reorderPreview.hidden = true; From 143ee7f86e50153aba39e573952ad7467217f23e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Mon, 7 Sep 2026 23:37:51 +0800 Subject: [PATCH 11/18] feat: add HD wallet stress progress page --- packages/kit-bg/src/services/ServiceDemo.ts | 133 +++++++++++---- .../DevLargeWalletDataCreation/index.tsx | 159 ++++++++++++++++++ .../pages/Tab/DevSettingsSection/index.tsx | 25 +-- .../Setting/router/basicModalSettingRouter.ts | 9 + packages/shared/src/eventBus/appEventBus.ts | 10 ++ .../shared/src/eventBus/appEventBusNames.ts | 1 + packages/shared/src/routes/setting.ts | 2 + 7 files changed, 280 insertions(+), 59 deletions(-) create mode 100644 packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx diff --git a/packages/kit-bg/src/services/ServiceDemo.ts b/packages/kit-bg/src/services/ServiceDemo.ts index b4b749a94fb0..0f4899cbc979 100644 --- a/packages/kit-bg/src/services/ServiceDemo.ts +++ b/packages/kit-bg/src/services/ServiceDemo.ts @@ -13,7 +13,6 @@ import { backgroundMethodForDev, toastIfError, } from '@onekeyhq/shared/src/background/backgroundDecorators'; -import type { IBackgroundMethodWithDevOnlyPassword } from '@onekeyhq/shared/src/background/backgroundDecorators'; import { getNetworkIdsMap } from '@onekeyhq/shared/src/config/networkIds'; import { DB_MAIN_CONTEXT_ID } from '@onekeyhq/shared/src/consts/dbConsts'; import { @@ -38,7 +37,6 @@ import { generateUUID } from '@onekeyhq/shared/src/utils/miscUtils'; import timerUtils from '@onekeyhq/shared/src/utils/timerUtils'; import type { IWalletConnectChainString } from '@onekeyhq/shared/src/walletConnect/types'; import { EMessageTypesEth } from '@onekeyhq/shared/types/message'; -import { EReasonForNeedPassword } from '@onekeyhq/shared/types/setting'; import type { IDecodedTx } from '@onekeyhq/shared/types/tx'; import { EDecodedTxActionType, @@ -48,7 +46,10 @@ import { import localDb from '../dbs/local/localDb'; import { ELocalDBStoreNames } from '../dbs/local/localDBStoreNames'; import { EIndexedDBBucketNames } from '../dbs/local/types'; -import { settingsPersistAtom } from '../states/jotai/atoms'; +import { + devSettingsPersistAtom, + settingsPersistAtom, +} from '../states/jotai/atoms'; import { vaultFactory } from '../vaults/factory'; import ServiceBase from './ServiceBase'; @@ -59,9 +60,12 @@ import type { AllNetworkAddressParams } from '@onekeyfe/hd-core'; const LARGE_WALLET_DATA_WALLET_COUNT = 1000; const LARGE_WALLET_DATA_ACCOUNT_COUNT = 1000; +const LARGE_WALLET_DATA_ACCOUNT_BATCH_SIZE = 100; @backgroundClass() class ServiceDemo extends ServiceBase { + private isCreatingLargeWalletData = false; + constructor({ backgroundApi }: { backgroundApi: any }) { super({ backgroundApi }); } @@ -140,31 +144,64 @@ class ServiceDemo extends ServiceBase { return c; } - @backgroundMethodForDev() - async createLargeWalletsAndAccounts( - _params: IBackgroundMethodWithDevOnlyPassword, - ) { + @backgroundMethod() + async createLargeWalletsAndAccounts() { + const devSettings = await devSettingsPersistAtom.get(); + if (!devSettings.enabled) { + throw new OneKeyLocalError('Developer mode is required'); + } + 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 accountIndexes = range(0, LARGE_WALLET_DATA_ACCOUNT_COUNT); + const accountsTotal = + LARGE_WALLET_DATA_WALLET_COUNT * LARGE_WALLET_DATA_ACCOUNT_COUNT; + const emitProgress = () => { + appEventBus.emit(EAppEventBusNames.DevLargeWalletDataCreationProgress, { + isRunning: this.isCreatingLargeWalletData, + walletIndex: currentWalletIndex, + walletsCreated, + walletsTotal: LARGE_WALLET_DATA_WALLET_COUNT, + accountsCreatedInWallet: accountsCreatedInCurrentWallet, + accountsPerWallet: LARGE_WALLET_DATA_ACCOUNT_COUNT, + accountsCreated, + accountsTotal, + }); + }; try { - ({ password } = - await this.backgroundApi.servicePassword.promptPasswordVerify({ - reason: EReasonForNeedPassword.CreateOrRemoveWallet, - skipPostVerifyBackgroundTasks: true, - })); + emitProgress(); for ( let walletIndex = 0; walletIndex < LARGE_WALLET_DATA_WALLET_COUNT; 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 = ''; @@ -180,42 +217,64 @@ class ServiceDemo extends ServiceBase { }, ); walletsCreated += 1; - - const indexedAccounts = - await this.backgroundApi.serviceAccount.addIndexedAccount({ - walletId: wallet.id, - indexes: accountIndexes, - skipIfExists: false, - }); - accountsCreated += indexedAccounts.length; - - await localDb.withTransaction( - EIndexedDBBucketNames.account, - async (tx) => { - await localDb.txUpdateWallet({ - tx, + 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, - updater: (walletRecord) => { - if (!walletRecord.nextIds) { - walletRecord.nextIds = {}; - } - walletRecord.nextIds.accountHdIndex = - LARGE_WALLET_DATA_ACCOUNT_COUNT; - return walletRecord; - }, + 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); 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..c1d4896677ef --- /dev/null +++ b/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx @@ -0,0 +1,159 @@ +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: 1000, + accountsCreatedInWallet: 0, + accountsPerWallet: 1000, + accountsCreated: 0, + accountsTotal: 1_000_000, +}; + +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 () => { + if (isRunning) { + return; + } + + setProgress(INITIAL_PROGRESS); + setIsRunning(true); + try { + const result = + await backgroundApiProxy.serviceDemo.createLargeWalletsAndAccounts(); + 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 1,000 Real HD Wallets × 1,000 Accounts + + + Creates 1,000 independent HD wallets with encrypted recovery + phrases and 1,000 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()} + + + + + + + + + ); +} 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 3a5f5717e14e..983c539cce67 100644 --- a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx @@ -2494,28 +2494,9 @@ const BaseDevSettingsSection = () => { searchKeywords="large data wallet account NativeList performance stress test 大数据 性能 压力测试" testID="create-large-wallet-account-data" onPress={() => { - showDevOnlyPasswordDialog({ - title: - 'Danger Zone: Create 1,000 Real HD Wallets × 1,000 Accounts', - description: - 'This generates and encrypts 1,000 independent recovery phrases, then creates 1,000 usable HD wallets, 1,000,000 indexed accounts, credentials, and standard cloud-sync records. Recovery phrases are never displayed or logged, and the wallets are marked as not backed up. Network addresses are derived normally when used. This may take a long time and make the app temporarily unresponsive. Each run creates another complete data set.', - confirmButtonProps: { - testID: - 'create-large-wallet-account-data-confirm', - }, - onConfirm: async (params) => { - const result = - await backgroundApiProxy.serviceDemo.createLargeWalletsAndAccounts( - params, - ); - 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`, - }); - }, - }); + navigation.push( + EModalSettingRoutes.SettingDevLargeWalletDataCreation, + ); }} /> diff --git a/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts b/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts index 46d58cc50449..ec2ea5f8dcd0 100644 --- a/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts +++ b/packages/kit/src/views/Setting/router/basicModalSettingRouter.ts @@ -208,6 +208,11 @@ const DevDrawingOrderStress = LazyLoadPage( () => 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; From a96fecae0243ef759a2f5f1dc2a14ea9e70dcce8 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 07:26:08 +0800 Subject: [PATCH 12/18] fix: stabilize native list touch interactions --- ...yfe+react-native-native-list+3.0.105.patch | 671 +++++++++++------- 1 file changed, 410 insertions(+), 261 deletions(-) diff --git a/patches/@onekeyfe+react-native-native-list+3.0.105.patch b/patches/@onekeyfe+react-native-native-list+3.0.105.patch index a7bafe2d07d0..49587d1ec333 100644 --- a/patches/@onekeyfe+react-native-native-list+3.0.105.patch +++ b/patches/@onekeyfe+react-native-native-list+3.0.105.patch @@ -76,7 +76,7 @@ index d8300e2..a129837 100644 val isSelectable: Boolean get() = !json.optBoolean("disabled", false) && type in SELECTABLE_TYPES diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt -index db47e5e..4ef24aa 100644 +index db47e5e..94a87ef 100644 --- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt +++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt @@ -29,6 +29,12 @@ import android.widget.LinearLayout @@ -188,7 +188,7 @@ index db47e5e..4ef24aa 100644 private val leadingImages = List(3) { OneKeyImageReusableView(reactContext) } private val leadingOverlayBackground = View(context) private val leadingCornerIconFrame = FrameLayout(context) -@@ -334,6 +392,8 @@ internal class NativeListRowView( +@@ -334,16 +392,22 @@ internal class NativeListRowView( private var walletGroupExpandAnimator: ValueAnimator? = null private var isMediaTile = false private var boundKey: String? = null @@ -197,7 +197,12 @@ index db47e5e..4ef24aa 100644 var bindingEpoch: Long = 0 private set private var boundCheckboxData: JSONObject? = null -@@ -344,6 +404,8 @@ internal class NativeListRowView( + private var currentLayout = "linear" + private var restingRowBackground: Drawable? = null + private var pressedRowBackground: Drawable? = null ++ // OneKey patch: preserve a held row independently from RecyclerView snapshot rebinding. ++ private var touchPressed = false + private var reorderActive = false private var checkboxCheckedColor = Color.rgb(32, 32, 32) private var checkboxUncheckedColor = Color.rgb(252, 252, 252) private var checkboxBorderColor = Color.rgb(206, 206, 206) @@ -206,7 +211,28 @@ index db47e5e..4ef24aa 100644 private var iconSubduedColor = Color.rgb(141, 141, 141) private var visualBackdropColor = Color.WHITE private val circleOutlineProvider = object : ViewOutlineProvider() { -@@ -462,7 +524,8 @@ internal class NativeListRowView( +@@ -445,6 +509,7 @@ internal class NativeListRowView( + setOnTouchListener { _, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> if (isEnabled) { ++ touchPressed = true + if ((tag as? NativeListItem)?.type == "mediaTile") { + leadingFrame.alpha = 0.8f + } else { +@@ -454,15 +519,20 @@ internal class NativeListRowView( + MotionEvent.ACTION_MOVE -> if ( + event.x < 0 || event.y < 0 || event.x >= width || event.y >= height + ) { ++ touchPressed = false ++ restoreRestingBackground() ++ } ++ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { ++ touchPressed = false + restoreRestingBackground() + } +- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> restoreRestingBackground() + } + false } setOnClickListener { view -> (view.tag as? NativeListItem)?.let { item -> @@ -216,7 +242,7 @@ index db47e5e..4ef24aa 100644 } } setWillNotDraw(false) -@@ -509,13 +572,64 @@ internal class NativeListRowView( +@@ -509,13 +579,64 @@ internal class NativeListRowView( } } @@ -282,22 +308,24 @@ index db47e5e..4ef24aa 100644 } fun bind( -@@ -526,12 +640,14 @@ internal class NativeListRowView( +@@ -526,12 +647,16 @@ internal class NativeListRowView( itemIndex: Int?, selected: Boolean, checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, + useSourceScale: Boolean = false, ) { ++ val shouldRestorePressed = touchPressed && boundKey == item.key invalidateCurrentBinding() bindingEpoch += 1 boundKey = item.key ++ touchPressed = shouldRestorePressed currentLayout = layout tag = item + selectorUsesSourceScale = item.usesSelectorSourceScale || useSourceScale reorderActive = false leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) secondaryImage.prepareForReuse() -@@ -544,12 +660,21 @@ internal class NativeListRowView( +@@ -544,12 +669,21 @@ internal class NativeListRowView( val accent = color(theme, "accent", "#0D8200FC") checkboxCheckedColor = primary checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") @@ -319,11 +347,22 @@ index db47e5e..4ef24aa 100644 iconSubduedColor = color(theme, "iconSubdued", "#00000072") visualBackdropColor = color(theme, "rowBackground", "#FFFFFF") unreadDot.background = roundedFill( -@@ -592,8 +717,12 @@ internal class NativeListRowView( +@@ -569,7 +703,7 @@ internal class NativeListRowView( + if (item.type == "rail") "#0000000F" else "#00000017", + ), + ) +- background = restingRowBackground ++ background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground + if (layout == "table") { + if (item.type == "dataRow") { + setPadding(dp(20), dp(10), dp(20), dp(10)) +@@ -592,8 +726,14 @@ internal class NativeListRowView( invalidate() trailingViews.forEach { it.setTextColor(primary) } isEnabled = !item.json.optBoolean("disabled", false) - alpha = if (isEnabled) 1f else 0.5f ++ if (!isEnabled) touchPressed = false ++ background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground + // OneKey patch: deprecation dims the row without disabling menu controls. + // alpha = if (isEnabled) 1f else 0.5f + alpha = item.json.optDouble("opacity", 1.0).toFloat() * (if (isEnabled) 1f else 0.5f) @@ -333,7 +372,7 @@ index db47e5e..4ef24aa 100644 when (item.type) { "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) -@@ -609,7 +738,30 @@ internal class NativeListRowView( +@@ -609,10 +749,34 @@ internal class NativeListRowView( "system" -> bindSystem(item, theme) } applySize(item) @@ -364,7 +403,11 @@ index db47e5e..4ef24aa 100644 } fun recycle() { -@@ -625,6 +777,7 @@ internal class NativeListRowView( ++ touchPressed = false + restoreRestingBackground() + invalidateCurrentBinding() + boundKey = null +@@ -625,6 +789,7 @@ internal class NativeListRowView( row.alpha = 1f row.recycle() } @@ -372,7 +415,7 @@ index db47e5e..4ef24aa 100644 } fun bindSelection( -@@ -636,6 +789,8 @@ internal class NativeListRowView( +@@ -636,6 +801,8 @@ internal class NativeListRowView( checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, ) { if (boundKey != item.key) return @@ -381,7 +424,7 @@ index db47e5e..4ef24aa 100644 if (item.type == "walletGroup") { val members = buildList { add(item.json.getJSONObject("parent")) -@@ -669,7 +824,18 @@ internal class NativeListRowView( +@@ -669,7 +836,18 @@ internal class NativeListRowView( ), ) } @@ -401,7 +444,7 @@ index db47e5e..4ef24aa 100644 } fun bindStableSummary(item: NativeListItem) { -@@ -684,10 +850,14 @@ internal class NativeListRowView( +@@ -684,10 +862,14 @@ internal class NativeListRowView( contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) title.text = item.json.optString("title") trailingViews[0].text = item.json.optString("value") @@ -416,7 +459,7 @@ index db47e5e..4ef24aa 100644 leadingImages.forEach(OneKeyImageReusableView::dispose) secondaryImage.dispose() mediaNetworkImage.dispose() -@@ -699,7 +869,8 @@ internal class NativeListRowView( +@@ -699,7 +881,8 @@ internal class NativeListRowView( sourceView: View, source: String, slot: Int? = null, @@ -426,7 +469,7 @@ index db47e5e..4ef24aa 100644 private fun emitAction( item: NativeListItem, -@@ -712,13 +883,65 @@ internal class NativeListRowView( +@@ -712,13 +895,65 @@ internal class NativeListRowView( onAction?.invoke(item, actionKey, target, actionOrigin(sourceView, source, slot)) } @@ -492,7 +535,7 @@ index db47e5e..4ef24aa 100644 walletGroupRows.forEach { it.invalidateCurrentBinding() } walletGroupExpandAnimator?.removeAllListeners() walletGroupExpandAnimator?.cancel() -@@ -736,6 +959,11 @@ internal class NativeListRowView( +@@ -736,6 +971,11 @@ internal class NativeListRowView( activityContentRow.removeAllViews() (actionLine.parent as? ViewGroup)?.removeView(actionLine) removeAllViews() @@ -504,7 +547,7 @@ index db47e5e..4ef24aa 100644 orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL minimumHeight = 0 -@@ -801,6 +1029,7 @@ internal class NativeListRowView( +@@ -801,6 +1041,7 @@ internal class NativeListRowView( trailingColumn.layoutParams = wrap() trailingViews.forEach { it.visibility = GONE @@ -512,7 +555,7 @@ index db47e5e..4ef24aa 100644 it.gravity = Gravity.END it.maxLines = 1 it.layoutParams = wrap() -@@ -879,7 +1108,8 @@ internal class NativeListRowView( +@@ -879,7 +1120,8 @@ internal class NativeListRowView( mainColumn.removeView(skeletonSecondary) setOnClickListener { view -> (view.tag as? NativeListItem)?.let { item -> @@ -522,7 +565,7 @@ index db47e5e..4ef24aa 100644 } } } -@@ -1003,9 +1233,12 @@ internal class NativeListRowView( +@@ -1003,9 +1245,12 @@ internal class NativeListRowView( members.add(children.getJSONObject(index)) } } @@ -536,7 +579,7 @@ index db47e5e..4ef24aa 100644 walletGroupDragBadgeBackgroundPaint.color = color( theme, "inverseBackground", -@@ -1048,8 +1281,11 @@ internal class NativeListRowView( +@@ -1048,8 +1293,11 @@ internal class NativeListRowView( null, memberJson.optBoolean("selected", false), checkboxState, @@ -549,7 +592,7 @@ index db47e5e..4ef24aa 100644 if (index > 0) topMargin = dp(12) } addView(memberRow) -@@ -1082,8 +1318,11 @@ internal class NativeListRowView( +@@ -1082,8 +1330,11 @@ internal class NativeListRowView( titleLine.gravity = Gravity.CENTER titleLine.packsChildrenAtStart = false titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) @@ -562,7 +605,7 @@ index db47e5e..4ef24aa 100644 showText(title, item.json.optString("title"), 1) title.setTextColor( color( -@@ -1095,13 +1334,41 @@ internal class NativeListRowView( +@@ -1095,13 +1346,41 @@ internal class NativeListRowView( addView( mainColumn, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { @@ -606,7 +649,7 @@ index db47e5e..4ef24aa 100644 } val leading = item.json.optJSONObject("leading") item.json.optJSONObject("leadingAction")?.let { action -> -@@ -1136,6 +1403,13 @@ internal class NativeListRowView( +@@ -1136,6 +1415,13 @@ internal class NativeListRowView( item.json.optString("presentation") == "accountSelector" ) 32 else 40, ) @@ -620,7 +663,7 @@ index db47e5e..4ef24aa 100644 addView(mainColumn, weighted()) titleLine.packsChildrenAtStart = true title.ellipsize = TextUtils.TruncateAt.END -@@ -1144,6 +1418,43 @@ internal class NativeListRowView( +@@ -1144,6 +1430,43 @@ internal class NativeListRowView( title.typeface = NativeListFonts.regular(context) } showText(subtitle, item.json.optString("subtitle"), item.json.optInt("subtitleLines", 2)) @@ -664,7 +707,7 @@ index db47e5e..4ef24aa 100644 showText(tertiary, item.json.optString("tertiary"), 1) tertiary.setTextColor( color( -@@ -1164,6 +1475,10 @@ internal class NativeListRowView( +@@ -1164,6 +1487,10 @@ internal class NativeListRowView( } addView(trailingColumn, wrap()) val accessories = item.json.optJSONArray("trailing") @@ -675,7 +718,7 @@ index db47e5e..4ef24aa 100644 if (accessories.hasAccessory("checkbox") && accessories.hasAccessory("value")) { trailingColumn.orientation = HORIZONTAL trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL -@@ -1847,10 +2162,15 @@ internal class NativeListRowView( +@@ -1847,10 +2174,15 @@ internal class NativeListRowView( val isSummary = variant == "summary" val isGallery = variant == "gallery" val isTable = currentLayout == "table" @@ -692,7 +735,7 @@ index db47e5e..4ef24aa 100644 (item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null) // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView // level, so header-local insets below are source px minus that outer inset. -@@ -1870,7 +2190,8 @@ internal class NativeListRowView( +@@ -1870,7 +2202,8 @@ internal class NativeListRowView( ), ) if (isNetworkSelector) { @@ -702,7 +745,7 @@ index db47e5e..4ef24aa 100644 title.textSize = sp(14f) title.typeface = NativeListFonts.medium(context) TextViewCompat.setLineHeight(title, dp(20)) -@@ -1924,14 +2245,22 @@ internal class NativeListRowView( +@@ -1924,14 +2257,22 @@ internal class NativeListRowView( item.json.optString("valueActionKey"), color(theme, "secondaryText", "#0000009B"), ) @@ -726,7 +769,7 @@ index db47e5e..4ef24aa 100644 if (checkboxData != null && value.isNotEmpty()) { // Value and checkbox share the trailing edge as one compound accessory. trailingColumn.orientation = HORIZONTAL -@@ -1966,6 +2295,27 @@ internal class NativeListRowView( +@@ -1966,6 +2307,27 @@ internal class NativeListRowView( } checkboxData?.let { bindCheckbox(item, it, checkboxState) } } @@ -754,7 +797,7 @@ index db47e5e..4ef24aa 100644 } private fun bindAction( -@@ -1978,12 +2328,13 @@ internal class NativeListRowView( +@@ -1978,12 +2340,13 @@ internal class NativeListRowView( addLeading(icon, if (isAccountSelector) 32 else 40) leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) if (!icon.has("backgroundColor")) leadingFrame.background = null @@ -770,7 +813,7 @@ index db47e5e..4ef24aa 100644 } else if (item.json.optString("tone") == "danger") { title.setTextColor(color(theme, "negative", "#C40006D3")) } -@@ -2003,6 +2354,17 @@ internal class NativeListRowView( +@@ -2003,6 +2366,17 @@ internal class NativeListRowView( private fun bindSystem(item: NativeListItem, theme: JSONObject?) { val variant = item.json.optString("variant") @@ -788,7 +831,7 @@ index db47e5e..4ef24aa 100644 if (variant == "spacer") { minimumHeight = dp(item.json.optInt("height", 0)) return -@@ -2072,7 +2434,11 @@ internal class NativeListRowView( +@@ -2072,7 +2446,11 @@ internal class NativeListRowView( spacingDp: Int = 12, ) { leadingFrame.visibility = VISIBLE @@ -801,7 +844,7 @@ index db47e5e..4ef24aa 100644 addView(leadingFrame) leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) if (visual == null) return -@@ -2104,7 +2470,7 @@ internal class NativeListRowView( +@@ -2104,7 +2482,7 @@ internal class NativeListRowView( leadingFrame.background = GradientDrawable().apply { setColor(visualBackground) setStroke(1, parseNativeListColor("#0000001F")) @@ -810,7 +853,7 @@ index db47e5e..4ef24aa 100644 } leadingIcon.iconName = visual.optString("name") leadingIcon.tintColor = safeColor( -@@ -2161,7 +2527,89 @@ internal class NativeListRowView( +@@ -2161,7 +2539,89 @@ internal class NativeListRowView( else -> leadingOutlineProvider(shape) } image.clipToOutline = true @@ -901,7 +944,7 @@ index db47e5e..4ef24aa 100644 } } -@@ -2223,7 +2671,11 @@ internal class NativeListRowView( +@@ -2223,7 +2683,11 @@ internal class NativeListRowView( for (index in 0 until minOf(2, accessories.length())) { val accessory = accessories.getJSONObject(index) when (accessory.optString("kind")) { @@ -914,7 +957,7 @@ index db47e5e..4ef24aa 100644 "valuePair" -> showTrailingValuePair(textIndex++, accessory, theme) "checkbox" -> bindCheckbox(item, accessory, checkboxState) "radio" -> showTrailing( -@@ -2285,11 +2737,25 @@ internal class NativeListRowView( +@@ -2285,11 +2749,25 @@ internal class NativeListRowView( return } checkbox.visibility = VISIBLE @@ -944,7 +987,7 @@ index db47e5e..4ef24aa 100644 } // Row-level disabled opacity already applies to this child. Only apply a // local 0.5 when the accessory alone is disabled, never 0.5 * 0.5. -@@ -2330,6 +2796,8 @@ internal class NativeListRowView( +@@ -2330,6 +2808,8 @@ internal class NativeListRowView( } val groupPosition = when { item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> "single" @@ -953,7 +996,7 @@ index db47e5e..4ef24aa 100644 else -> when (item.type) { "metricCard" -> "single" "rail" -> "rail" -@@ -2337,7 +2805,7 @@ internal class NativeListRowView( +@@ -2337,7 +2817,7 @@ internal class NativeListRowView( } } var backgroundGroupPosition = if (item.type == "mediaTile") "mediaTile" else groupPosition @@ -962,7 +1005,7 @@ index db47e5e..4ef24aa 100644 // Selection in sectioned lists is represented by the OneKey checkbox, // matching iOS and the app-monorepo network selector. rowBackground = color(theme, "rowBackground", "#FFFFFF") -@@ -2351,6 +2819,8 @@ internal class NativeListRowView( +@@ -2351,6 +2831,8 @@ internal class NativeListRowView( rowBackground = color(theme, "subduedBackground", "#F9F9F9") backgroundGroupPosition = "" } @@ -971,7 +1014,7 @@ index db47e5e..4ef24aa 100644 restingRowBackground = groupedBackground(backgroundGroupPosition, rowBackground) background = restingRowBackground } -@@ -2466,7 +2936,26 @@ internal class NativeListRowView( +@@ -2466,7 +2948,26 @@ internal class NativeListRowView( val icon = trailingIcons[index] icon.iconName = data.optString("name") icon.tintColor = safeColor(data.optString("tintColor"), iconSubduedColor) @@ -999,7 +1042,7 @@ index db47e5e..4ef24aa 100644 icon.glyphSizeDp = null // ListItem.DrillIn is a 24dp icon with mx=-6, for a 12dp layout footprint. icon.layoutParams = LayoutParams(dp(24), dp(24)).apply { -@@ -2516,7 +3005,7 @@ internal class NativeListRowView( +@@ -2516,7 +3017,7 @@ internal class NativeListRowView( when (item.type) { "message" -> 14f "sectionHeader" -> when { @@ -1008,7 +1051,7 @@ index db47e5e..4ef24aa 100644 item.json.optString("variant") == "gallery" -> 18f item.json.optString("variant") == "summary" -> 16f currentLayout == "table" -> 11f -@@ -2530,11 +3019,14 @@ internal class NativeListRowView( +@@ -2530,11 +3031,14 @@ internal class NativeListRowView( } }, ) @@ -1024,7 +1067,7 @@ index db47e5e..4ef24aa 100644 isNetworkSelectorSection -> NativeListFonts.medium(context) currentLayout == "table" -> NativeListFonts.regular(context) item.json.optString("variant") == "summary" -> NativeListFonts.medium(context) -@@ -2552,7 +3044,7 @@ internal class NativeListRowView( +@@ -2552,7 +3056,7 @@ internal class NativeListRowView( else -> 14f }) tertiary.textSize = sp(14f) @@ -1033,7 +1076,7 @@ index db47e5e..4ef24aa 100644 TextViewCompat.setLineHeight(title, dp(20)) } else if (item.type == "sectionHeader" && item.json.optString("variant") == "gallery") { TextViewCompat.setLineHeight(title, dp(24)) -@@ -2589,11 +3081,42 @@ internal class NativeListRowView( +@@ -2589,11 +3093,42 @@ internal class NativeListRowView( column.typeface = NativeListFonts.medium(context) column.fontFeatureSettings = "tnum" } @@ -1077,7 +1120,7 @@ index db47e5e..4ef24aa 100644 } isNetworkSelectorIdentity -> 47 else -> when (item.type) { -@@ -2617,6 +3140,7 @@ internal class NativeListRowView( +@@ -2617,6 +3152,7 @@ internal class NativeListRowView( else -> 36 } "system" -> when (item.json.optString("variant")) { @@ -1085,7 +1128,7 @@ index db47e5e..4ef24aa 100644 "noMatch", "end" -> 36 "retry" -> 44 else -> 56 -@@ -2636,14 +3160,14 @@ internal class NativeListRowView( +@@ -2636,14 +3172,14 @@ internal class NativeListRowView( 56 } else -> when { @@ -1102,7 +1145,7 @@ index db47e5e..4ef24aa 100644 0 } else if ( item.type == "sectionHeader" && item.json.optString("variant") in listOf("summary", "gallery") -@@ -2713,7 +3237,12 @@ internal class NativeListRowView( +@@ -2713,7 +3249,12 @@ internal class NativeListRowView( override fun getOutline(view: View, outline: Outline) { when (shape) { "square" -> outline.setRect(0, 0, view.width, view.height) @@ -1116,7 +1159,7 @@ index db47e5e..4ef24aa 100644 else -> outline.setOval(0, 0, view.width, view.height) } } -@@ -2742,7 +3271,13 @@ internal class NativeListRowView( +@@ -2742,7 +3283,13 @@ internal class NativeListRowView( token: String, slot: Int, variant: String, @@ -1130,7 +1173,7 @@ index db47e5e..4ef24aa 100644 val uri = source.optString("uri").trim().takeIf(String::isNotEmpty) imageView.configure( sourceUri = uri, -@@ -2751,17 +3286,39 @@ internal class NativeListRowView( +@@ -2751,17 +3298,39 @@ internal class NativeListRowView( contentFit = source.optString("contentFit", "cover"), cachePolicy = source.optString("cachePolicy", "memory-disk"), autoplay = source.optBoolean("autoplay", false), @@ -1174,7 +1217,7 @@ index db47e5e..4ef24aa 100644 private fun color(theme: JSONObject?, key: String, fallback: String): Int = safeColor(theme?.optString(key, fallback), parseNativeListColor(fallback)) -@@ -2774,30 +3331,30 @@ internal class NativeListRowView( +@@ -2774,30 +3343,30 @@ internal class NativeListRowView( private fun roundedFill(color: Int, radiusDp: Float) = GradientDrawable().apply { setColor(color) @@ -1211,7 +1254,7 @@ index db47e5e..4ef24aa 100644 else -> FloatArray(8) } } -@@ -2805,6 +3362,7 @@ internal class NativeListRowView( +@@ -2805,6 +3374,7 @@ internal class NativeListRowView( } private class OneKeyIconView(context: android.content.Context) : View(context) { @@ -1219,7 +1262,7 @@ index db47e5e..4ef24aa 100644 var iconName: String = "" set(value) { field = value -@@ -2828,9 +3386,11 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2828,9 +3398,11 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { val pathData = iconPaths[iconName] ?: return val drawSize = minOf( minOf(width, height).toFloat(), @@ -1233,7 +1276,7 @@ index db47e5e..4ef24aa 100644 fill.color = tintColor canvas.save() canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) -@@ -2839,6 +3399,8 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2839,6 +3411,8 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { pathData.forEachIndexed { index, data -> PathParser.createPathFromPathData(data)?.let { path -> path.fillType = sourceFillTypes?.getOrNull(index) ?: Path.FillType.EVEN_ODD @@ -1242,7 +1285,7 @@ index db47e5e..4ef24aa 100644 canvas.drawPath(path, fill) } } -@@ -2846,10 +3408,42 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2846,10 +3420,42 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { } companion object { @@ -1285,7 +1328,7 @@ index db47e5e..4ef24aa 100644 "ChevronRightSmallOutline" to listOf(Path.FillType.WINDING), "MinusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), "PlusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), -@@ -2867,6 +3461,16 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2867,6 +3473,16 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { // Exact 24x24 paths from app-monorepo packages/components Icon sources. private val iconPaths = mapOf( @@ -1302,7 +1345,7 @@ index db47e5e..4ef24aa 100644 "ArrowBottomOutline" to listOf("m13 17.586 5-5L19.414 14 12 21.414 4.586 14 6 12.586l5 5V3h2z"), "ArrowTopOutline" to listOf("M19.414 10 18 11.414l-5-5V21h-2V6.414l-5 5L4.586 10 12 2.586z"), "ChartTrendingUpOutline" to listOf("M22 13h-2V9.414l-7 7-4-4-6 6L1.586 17 9 9.586l4 4L18.586 8H15V6h7z"), -@@ -2910,6 +3514,7 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { +@@ -2910,6 +3526,7 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { } private class OneKeyCheckboxView(context: android.content.Context) : View(context) { @@ -1310,7 +1353,7 @@ index db47e5e..4ef24aa 100644 private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } private var state = "unchecked" -@@ -2926,9 +3531,11 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex +@@ -2926,9 +3543,11 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex "indeterminate" -> "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1" else -> return } @@ -1325,10 +1368,63 @@ index db47e5e..4ef24aa 100644 PathParser.createPathFromPathData(pathData)?.let { canvas.drawPath(it, glyphPaint) } canvas.restore() diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt -index f0af895..1294235 100644 +index f0af895..9629eee 100644 --- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt +++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt -@@ -102,6 +102,7 @@ class NativeListView( +@@ -14,6 +14,7 @@ import android.view.Choreographer + import android.view.Gravity + import android.view.MotionEvent + import android.view.View ++import android.view.ViewConfiguration + import android.view.accessibility.AccessibilityEvent + import android.view.accessibility.AccessibilityNodeInfo + import android.widget.FrameLayout +@@ -31,6 +32,7 @@ import org.json.JSONArray + import org.json.JSONObject + import java.lang.ref.WeakReference + import java.util.UUID ++import kotlin.math.abs + import kotlin.math.ceil + import kotlin.math.cos + import kotlin.math.exp +@@ -87,6 +89,36 @@ class NativeListView( + private val contentContainer = FrameLayout(context) + private val adapter = NativeListAdapter(reactContext) + private val layoutManager = GridLayoutManager(context, 1) ++ // OneKey patch: vertical lists retain vertical drags and leave horizontal drags to a parent pager. ++ private val pagerGestureTouchSlop = ViewConfiguration.get(context).scaledTouchSlop ++ private val pagerGestureTouchListener = object : RecyclerView.SimpleOnItemTouchListener() { ++ private var downX = 0f ++ private var downY = 0f ++ private var directionResolved = false ++ ++ override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean { ++ if (layoutManager.orientation != RecyclerView.VERTICAL) return false ++ when (event.actionMasked) { ++ MotionEvent.ACTION_DOWN -> { ++ downX = event.x ++ downY = event.y ++ directionResolved = false ++ } ++ MotionEvent.ACTION_MOVE -> if (!directionResolved) { ++ val deltaX = abs(event.x - downX) ++ val deltaY = abs(event.y - downY) ++ if (max(deltaX, deltaY) > pagerGestureTouchSlop) { ++ directionResolved = true ++ recyclerView.parent?.requestDisallowInterceptTouchEvent(deltaY >= deltaX) ++ } ++ } ++ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { ++ recyclerView.parent?.requestDisallowInterceptTouchEvent(false) ++ } ++ } ++ return false ++ } ++ } + private val reorderPlaceholderDecoration = ReorderPlaceholderDecoration( + adapter = adapter, + insetPx = dp(REORDER_PLACEHOLDER_INSET_DP), +@@ -102,6 +134,7 @@ class NativeListView( private val sectionIndexView = NativeListSectionIndexView(context) private val sectionIndexPreview = TextView(context) private var config: NativeListConfig? = null @@ -1336,16 +1432,18 @@ index f0af895..1294235 100644 private var stickyDecoration: StickySectionHeaderDecoration? = null private var spacingDecoration: ItemSpacingDecoration? = null private var itemTouchHelper: ItemTouchHelper? = null -@@ -133,6 +134,8 @@ class NativeListView( +@@ -132,7 +165,10 @@ class NativeListView( + recyclerView.adapter = adapter recyclerView.layoutManager = layoutManager recyclerView.itemAnimator = null ++ recyclerView.addOnItemTouchListener(pagerGestureTouchListener) recyclerView.addItemDecoration(reorderPlaceholderDecoration) + // OneKey patch: full-width selector header backgrounds do not change row content insets. + recyclerView.addItemDecoration(SelectorBackgroundDecoration(adapter)) recyclerView.setHasFixedSize(false) layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int { -@@ -158,11 +161,15 @@ class NativeListView( +@@ -158,11 +194,15 @@ class NativeListView( ) contentContainer.addView( sectionIndexView, @@ -1363,7 +1461,7 @@ index f0af895..1294235 100644 typeface = NativeListFonts.semibold(context) visibility = GONE alpha = 0f -@@ -170,7 +177,13 @@ class NativeListView( +@@ -170,7 +210,13 @@ class NativeListView( } contentContainer.addView( sectionIndexPreview, @@ -1378,7 +1476,7 @@ index f0af895..1294235 100644 ) addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) footerView.visibility = GONE -@@ -244,17 +257,29 @@ class NativeListView( +@@ -244,17 +290,29 @@ class NativeListView( invalidateActionAnchor("snapshot") val previous = config if (previous != null && canApplyStableContentUpdate(previous, next)) { @@ -1408,7 +1506,7 @@ index f0af895..1294235 100644 recyclerView.post { bindVisibleSelection(changedSummaryKeys) bindFooterSelection() -@@ -264,6 +289,8 @@ class NativeListView( +@@ -264,6 +322,8 @@ class NativeListView( return } config = next @@ -1417,7 +1515,7 @@ index f0af895..1294235 100644 endReachedGeneration = null pendingReorder = null adapter.theme = next.theme -@@ -327,30 +354,98 @@ class NativeListView( +@@ -327,30 +387,98 @@ class NativeListView( val newItem = next.items[index] oldItem.key == newItem.key && oldItem.type == newItem.type && @@ -1531,7 +1629,7 @@ index f0af895..1294235 100644 } fun applyPatches(patchesJson: String) { -@@ -389,6 +484,8 @@ class NativeListView( +@@ -389,6 +517,8 @@ class NativeListView( invalidateActionAnchor("snapshot") val next = current.copy(items = nextItems, selectedKeys = selected) config = next @@ -1540,7 +1638,15 @@ index f0af895..1294235 100644 adapter.selectedKeys = selected adapter.submitList(nextItems) { relayoutContents() } bindFooter(next) -@@ -731,7 +828,8 @@ class NativeListView( +@@ -714,6 +844,7 @@ class NativeListView( + reorderTouchHandler = null + reorderTouchListener?.let(recyclerView::removeOnItemTouchListener) + reorderTouchListener = null ++ recyclerView.removeOnItemTouchListener(pagerGestureTouchListener) + itemTouchHelper?.attachToRecyclerView(null) + itemTouchHelper = null + footerView.recycle() +@@ -731,7 +862,8 @@ class NativeListView( val horizontalPadding = next.contentPaddingHorizontal ?: defaultPadding val topPadding = next.contentPaddingTop ?: defaultPadding val bottomPadding = next.contentPaddingBottom ?: defaultPadding @@ -1550,7 +1656,7 @@ index f0af895..1294235 100644 recyclerView.setPaddingRelative( dp(horizontalPadding), dp(topPadding), -@@ -742,7 +840,7 @@ class NativeListView( +@@ -742,7 +874,7 @@ class NativeListView( recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty() spacingDecoration?.let(recyclerView::removeItemDecoration) @@ -1559,7 +1665,7 @@ index f0af895..1294235 100644 stickyDecoration?.let(recyclerView::removeItemDecoration) stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) { StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration) -@@ -769,12 +867,13 @@ class NativeListView( +@@ -769,12 +901,13 @@ class NativeListView( sectionIndexEntries.map { it.title }, themeColor(next.theme, "secondaryText", "#646464"), themeColor(next.theme, "accent", "#108303"), @@ -1574,7 +1680,7 @@ index f0af895..1294235 100644 } sectionIndexView.setActiveIndex( previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } }, -@@ -846,6 +945,7 @@ class NativeListView( +@@ -846,6 +979,7 @@ class NativeListView( null, next.selectedKeys.contains(footer.key), ::resolveCheckboxState, @@ -1582,7 +1688,7 @@ index f0af895..1294235 100644 ) } } -@@ -898,15 +998,17 @@ class NativeListView( +@@ -898,15 +1032,17 @@ class NativeListView( origin.sourceView.getLocationInWindow(location) val record = ActionAnchorRecord(token, origin) actionAnchor = record @@ -1604,7 +1710,7 @@ index f0af895..1294235 100644 ) .put("source", origin.source) .put("generation", generation) -@@ -1037,7 +1139,9 @@ class NativeListView( +@@ -1037,7 +1173,9 @@ class NativeListView( current.theme, current.layout, position, @@ -1615,7 +1721,7 @@ index f0af895..1294235 100644 ::resolveCheckboxState, ) } -@@ -1297,12 +1401,14 @@ class NativeListView( +@@ -1297,12 +1435,14 @@ class NativeListView( ?.let(recyclerView::getChildViewHolder) ?.takeIf { holder -> adapter.itemAt(holder.bindingAdapterPosition)?.let { item -> @@ -1636,7 +1742,7 @@ index f0af895..1294235 100644 } == true } if (candidate != null) handler.postDelayed(startDrag, REORDER_LONG_PRESS_MS) -@@ -1460,9 +1566,13 @@ class NativeListView( +@@ -1460,9 +1600,13 @@ class NativeListView( ) } @@ -1651,7 +1757,7 @@ index f0af895..1294235 100644 private const val REORDER_LONG_PRESS_MS = 200L private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10 private const val REORDER_PLACEHOLDER_INSET_DP = 8 -@@ -1546,7 +1656,9 @@ private class NativeListSectionIndexView( +@@ -1546,7 +1690,9 @@ private class NativeListSectionIndexView( private var titles: List = emptyList() private var normalColor = Color.GRAY private var activeColor = Color.BLACK @@ -1661,7 +1767,7 @@ index f0af895..1294235 100644 private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER typeface = NativeListFonts.medium(context) -@@ -1563,10 +1675,16 @@ private class NativeListSectionIndexView( +@@ -1563,10 +1709,16 @@ private class NativeListSectionIndexView( contentDescription = ACCESSIBILITY_LABEL } @@ -1679,7 +1785,7 @@ index f0af895..1294235 100644 activeIndex = null updateContentDescription() invalidate() -@@ -1590,8 +1708,30 @@ private class NativeListSectionIndexView( +@@ -1590,8 +1742,30 @@ private class NativeListSectionIndexView( activePaint.color = activeColor activePaint.textSize = textSize titles.forEachIndexed { index, title -> @@ -1711,7 +1817,7 @@ index f0af895..1294235 100644 val baseline = centerY - (paint.descent() + paint.ascent()) / 2f canvas.drawText(title, width / 2f, baseline, paint) } -@@ -1697,7 +1837,11 @@ private class NativeListSectionIndexView( +@@ -1697,7 +1871,11 @@ private class NativeListSectionIndexView( } } @@ -1724,7 +1830,7 @@ index f0af895..1294235 100644 override fun getItemOffsets( outRect: android.graphics.Rect, view: View, -@@ -1706,7 +1850,14 @@ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.Ite +@@ -1706,7 +1884,14 @@ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.Ite ) { if (spacing <= 0) return val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL @@ -1740,7 +1846,7 @@ index f0af895..1294235 100644 } } -@@ -1733,22 +1884,32 @@ private class StickySectionHeaderDecoration( +@@ -1733,22 +1918,32 @@ private class StickySectionHeaderDecoration( for (index in first downTo 0) { val candidate = adapter.itemAt(index) if (candidate?.type == "sectionHeader") { @@ -1779,7 +1885,7 @@ index f0af895..1294235 100644 val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it } val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL val textWidth = if (isHistory) { -@@ -1789,6 +1950,29 @@ private class StickySectionHeaderDecoration( +@@ -1789,6 +1984,29 @@ private class StickySectionHeaderDecoration( internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean = item.type == "sectionHeader" && @@ -1810,7 +1916,7 @@ index f0af895..1294235 100644 + } +} diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift -index e0bf60b..33bebb6 100644 +index e0bf60b..81836fa 100644 --- a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift +++ b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift @@ -1,4 +1,6 @@ @@ -2025,7 +2131,16 @@ index e0bf60b..33bebb6 100644 leadingImages.forEach { $0.prepareForReuse() } secondaryImage.prepareForReuse() mediaNetworkImage.prepareForReuse() -@@ -664,6 +771,14 @@ final class NativeListCell: UICollectionViewCell { +@@ -645,6 +752,8 @@ final class NativeListCell: UICollectionViewCell { + selected: Bool, + checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String + ) { ++ // OneKey patch: a same-row snapshot refresh must not clear a touch that is still held. ++ let shouldRestoreHighlight = isHighlighted && currentItem?.key == item.key + invalidateCurrentBinding() + bindingEpoch &+= 1 + currentLayout = layout +@@ -664,6 +773,14 @@ final class NativeListCell: UICollectionViewCell { // Checkbox uses the literal neutral7 alpha token. Applying opacity to the // opaque primary text color produces a different RGB result. checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) @@ -2040,7 +2155,7 @@ index e0bf60b..33bebb6 100644 visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF") titleLabel.textColor = primary subtitleLabel.textColor = secondary -@@ -693,6 +808,11 @@ final class NativeListCell: UICollectionViewCell { +@@ -693,6 +810,11 @@ final class NativeListCell: UICollectionViewCell { pressedBackgroundColor = restingBackgroundColor } updateBackgroundColor() @@ -2052,7 +2167,7 @@ index e0bf60b..33bebb6 100644 if layout == "table" { if item.type == "dataRow" { rootLeadingConstraint.constant = 20 -@@ -707,8 +827,12 @@ final class NativeListCell: UICollectionViewCell { +@@ -707,8 +829,12 @@ final class NativeListCell: UICollectionViewCell { } applyGroupPosition(item.data.string("groupPosition")) isUserInteractionEnabled = !item.data.bool("disabled") @@ -2066,11 +2181,14 @@ index e0bf60b..33bebb6 100644 switch item.type { case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) -@@ -724,6 +848,14 @@ final class NativeListCell: UICollectionViewCell { +@@ -724,6 +850,17 @@ final class NativeListCell: UICollectionViewCell { case "system": bindSystem(item, theme: theme) default: break } + applySelectorTypography(item) ++ if shouldRestoreHighlight && isUserInteractionEnabled { ++ isHighlighted = true ++ } + if item.type == "sectionHeader", item.data.string("presentation") == "networkSelector", item.data["height"] != nil, item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty { + // OneKey patch: UIKit must reserve only the total's intrinsic width before the checkbox. + let valueWidth = accessoryButtons[0].intrinsicContentSize.width @@ -2081,7 +2199,7 @@ index e0bf60b..33bebb6 100644 } func updateSelection( -@@ -732,6 +864,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -732,6 +869,8 @@ final class NativeListCell: UICollectionViewCell { checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String ) { guard currentItem?.key == item.key else { return } @@ -2090,7 +2208,7 @@ index e0bf60b..33bebb6 100644 if item.type == "walletGroup" { currentItem = item let memberData = [item.data.dictionary("parent")].compactMap { $0 } -@@ -777,6 +911,13 @@ final class NativeListCell: UICollectionViewCell { +@@ -777,6 +916,13 @@ final class NativeListCell: UICollectionViewCell { selected ? "#FFFFFFED" : "#FFFFFFAF" ) } @@ -2104,7 +2222,7 @@ index e0bf60b..33bebb6 100644 guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return } updateCheckboxPresentation( item, -@@ -786,12 +927,55 @@ final class NativeListCell: UICollectionViewCell { +@@ -786,12 +932,55 @@ final class NativeListCell: UICollectionViewCell { ) } @@ -2160,7 +2278,7 @@ index e0bf60b..33bebb6 100644 let valueButton = accessoryButtons[0] valueButton.isHidden = value.isEmpty if value.isEmpty { -@@ -801,7 +985,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -801,7 +990,7 @@ final class NativeListCell: UICollectionViewCell { setButtonLine( valueButton, text: value, @@ -2169,7 +2287,7 @@ index e0bf60b..33bebb6 100644 color: nativeListColor(currentTheme, "secondaryText", "#646464"), lineHeight: 24 ) -@@ -826,7 +1010,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -826,7 +1015,7 @@ final class NativeListCell: UICollectionViewCell { // Selection is communicated by the destination state for these source // components; neither has a persistent selected tile background. color = nativeListColor(theme, "rowBackground", "#FFFFFF") @@ -2178,7 +2296,7 @@ index e0bf60b..33bebb6 100644 // Checkbox-backed section lists in app-monorepo keep rows on $bg; // selection is represented by the checkbox itself. color = nativeListColor(theme, "rowBackground", "#FFFFFF") -@@ -836,10 +1020,27 @@ final class NativeListCell: UICollectionViewCell { +@@ -836,10 +1025,27 @@ final class NativeListCell: UICollectionViewCell { !selected { color = nativeListColor(theme, "subduedBackground", "#F9F9F9") } @@ -2206,7 +2324,7 @@ index e0bf60b..33bebb6 100644 walletGroupCompactCell?.invalidateCurrentBinding() walletGroupCells.forEach { $0.invalidateCurrentBinding() } isHighlighted = false -@@ -848,6 +1049,10 @@ final class NativeListCell: UICollectionViewCell { +@@ -848,6 +1054,10 @@ final class NativeListCell: UICollectionViewCell { $0.removeFromSuperview() } walletGroupMembers.removeAll() @@ -2217,7 +2335,7 @@ index e0bf60b..33bebb6 100644 walletGroupCompactAppearanceActive = false walletGroupCompactContainer.isHidden = true walletGroupCompactContainer.alpha = 1 -@@ -978,11 +1183,13 @@ final class NativeListCell: UICollectionViewCell { +@@ -978,11 +1188,13 @@ final class NativeListCell: UICollectionViewCell { $0.backgroundColor = .clear } accessoryButtons.enumerated().forEach { index, button in @@ -2231,7 +2349,7 @@ index e0bf60b..33bebb6 100644 button.setTitle(nil, for: .normal) button.setAttributedTitle(nil, for: .normal) button.setImage(nil, for: .normal) -@@ -994,6 +1201,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -994,6 +1206,7 @@ final class NativeListCell: UICollectionViewCell { button.backgroundColor = .clear button.layer.cornerRadius = 0 button.contentEdgeInsets = .zero @@ -2239,7 +2357,7 @@ index e0bf60b..33bebb6 100644 } checkboxButton.isHidden = true checkboxButton.alpha = 1 -@@ -1028,6 +1236,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -1028,6 +1241,7 @@ final class NativeListCell: UICollectionViewCell { contentView.clipsToBounds = false leadingContainer.alpha = 1 titleLabel.showsDottedUnderline = false @@ -2247,7 +2365,7 @@ index e0bf60b..33bebb6 100644 titleLabel.dottedUnderlineVerticalOffset = 0 } -@@ -1073,7 +1282,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -1073,7 +1287,8 @@ final class NativeListCell: UICollectionViewCell { while walletGroupCells.count < walletGroupMembers.count { let memberCell = NativeListCell(frame: .zero) memberCell.translatesAutoresizingMaskIntoConstraints = false @@ -2257,7 +2375,7 @@ index e0bf60b..33bebb6 100644 walletGroupCells.append(memberCell) } rootStack.axis = .vertical -@@ -1083,8 +1293,18 @@ final class NativeListCell: UICollectionViewCell { +@@ -1083,8 +1298,18 @@ final class NativeListCell: UICollectionViewCell { rootTrailingConstraint.constant = 0 rootTopConstraint.constant = 0 rootBottomConstraint.constant = 0 @@ -2276,7 +2394,7 @@ index e0bf60b..33bebb6 100644 memberCell.onAction = { [weak self] source, action, target, origin in self?.onAction?(source, action, target, origin) } -@@ -1140,7 +1360,10 @@ final class NativeListCell: UICollectionViewCell { +@@ -1140,7 +1365,10 @@ final class NativeListCell: UICollectionViewCell { let point = gesture.location(in: rootStack) for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() where cell.frame.contains(point) { @@ -2288,7 +2406,7 @@ index e0bf60b..33bebb6 100644 return } } -@@ -1269,8 +1492,14 @@ final class NativeListCell: UICollectionViewCell { +@@ -1269,8 +1497,14 @@ final class NativeListCell: UICollectionViewCell { contentView.clipsToBounds = true } else { applyGroupPosition(currentItem?.data.string("groupPosition") ?? "") @@ -2304,7 +2422,7 @@ index e0bf60b..33bebb6 100644 contentView.clipsToBounds = restingRadius > 0 } } -@@ -1299,6 +1528,17 @@ final class NativeListCell: UICollectionViewCell { +@@ -1299,6 +1533,17 @@ final class NativeListCell: UICollectionViewCell { fallbackLabel.font = nativeListFont(ofSize: 28) addLeading(item.data.dictionary("leading"), key: item.key) rootStack.addArrangedSubview(mainStack) @@ -2322,7 +2440,7 @@ index e0bf60b..33bebb6 100644 show(titleLabel, item.data.string("title"), lines: 1) setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) titleLabel.textColor = nativeListColor( -@@ -1306,6 +1546,32 @@ final class NativeListCell: UICollectionViewCell { +@@ -1306,6 +1551,32 @@ final class NativeListCell: UICollectionViewCell { selected ? "primaryText" : "secondaryText", selected ? "#FFFFFFED" : "#FFFFFFAF" ) @@ -2355,7 +2473,7 @@ index e0bf60b..33bebb6 100644 return } if item.data.string("presentation") == "accountSelector" { -@@ -1344,6 +1610,12 @@ final class NativeListCell: UICollectionViewCell { +@@ -1344,6 +1615,12 @@ final class NativeListCell: UICollectionViewCell { rootStack.setCustomSpacing(5, after: leadingActionButton) } addLeading(item.data.dictionary("leading"), key: item.key) @@ -2368,7 +2486,7 @@ index e0bf60b..33bebb6 100644 rootStack.addArrangedSubview(mainStack) show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1)) show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1)) -@@ -1353,6 +1625,77 @@ final class NativeListCell: UICollectionViewCell { +@@ -1353,6 +1630,77 @@ final class NativeListCell: UICollectionViewCell { setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20) } @@ -2446,7 +2564,7 @@ index e0bf60b..33bebb6 100644 tertiaryLabel.textColor = nativeListColor( theme, item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText", -@@ -1997,15 +2340,29 @@ final class NativeListCell: UICollectionViewCell { +@@ -1997,15 +2345,29 @@ final class NativeListCell: UICollectionViewCell { ) { rootStack.addArrangedSubview(mainStack) let variant = item.data.string("variant") @@ -2476,7 +2594,7 @@ index e0bf60b..33bebb6 100644 : isNetworkSelector ? .medium : isGallery || layout == "sectioned" ? .semibold : .regular titleLabel.font = nativeListFont( -@@ -2019,8 +2376,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -2019,8 +2381,8 @@ final class NativeListCell: UICollectionViewCell { ) show(titleLabel, item.data.string("title"), lines: 1) if isNetworkSelector { @@ -2487,7 +2605,7 @@ index e0bf60b..33bebb6 100644 titleLabel.dottedUnderlineColor = nativeListColor( theme, "secondaryText", -@@ -2028,8 +2385,9 @@ final class NativeListCell: UICollectionViewCell { +@@ -2028,8 +2390,9 @@ final class NativeListCell: UICollectionViewCell { ) rootLeadingConstraint.constant = 12 rootTrailingConstraint.constant = -12 @@ -2499,7 +2617,7 @@ index e0bf60b..33bebb6 100644 setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) } else if isHistory { rootLeadingConstraint.constant = 0 -@@ -2095,6 +2453,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -2095,6 +2458,7 @@ final class NativeListCell: UICollectionViewCell { rootTopConstraint.constant = 24 rootBottomConstraint.constant = -20 setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) @@ -2507,7 +2625,7 @@ index e0bf60b..33bebb6 100644 let valueActionKey = item.data.string("valueActionKey") let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty ? nil -@@ -2105,11 +2464,11 @@ final class NativeListCell: UICollectionViewCell { +@@ -2105,11 +2469,11 @@ final class NativeListCell: UICollectionViewCell { action: action, color: nativeListColor(theme, "secondaryText", "#646464") ) @@ -2521,7 +2639,7 @@ index e0bf60b..33bebb6 100644 color: nativeListColor(theme, "secondaryText", "#646464"), lineHeight: 24 ) -@@ -2169,6 +2528,28 @@ final class NativeListCell: UICollectionViewCell { +@@ -2169,6 +2533,28 @@ final class NativeListCell: UICollectionViewCell { ) } } @@ -2550,7 +2668,7 @@ index e0bf60b..33bebb6 100644 } private func bindAction( -@@ -2185,6 +2566,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -2185,6 +2571,8 @@ final class NativeListCell: UICollectionViewCell { addLeading(icon, key: item.key) if isAccountSelector { leadingContainer.layer.cornerCurve = .continuous @@ -2559,7 +2677,7 @@ index e0bf60b..33bebb6 100644 } if icon["backgroundColor"] == nil { leadingContainer.backgroundColor = .clear -@@ -2195,8 +2578,9 @@ final class NativeListCell: UICollectionViewCell { +@@ -2195,8 +2583,9 @@ final class NativeListCell: UICollectionViewCell { rootStack.addArrangedSubview(mainStack) show(titleLabel, item.data.string("title"), lines: 1) if isAccountSelector { @@ -2571,7 +2689,7 @@ index e0bf60b..33bebb6 100644 } else if item.data.string("tone") == "danger" { titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") } -@@ -2217,8 +2601,19 @@ final class NativeListCell: UICollectionViewCell { +@@ -2217,8 +2606,19 @@ final class NativeListCell: UICollectionViewCell { } } @@ -2591,7 +2709,7 @@ index e0bf60b..33bebb6 100644 case "secondary": return nativeListColor(theme, "secondaryText", "#646464") case "positive": return nativeListColor(theme, "positive", "#218358") case "negative": return nativeListColor(theme, "negative", "#CE2C31") -@@ -2230,6 +2625,36 @@ final class NativeListCell: UICollectionViewCell { +@@ -2230,6 +2630,36 @@ final class NativeListCell: UICollectionViewCell { rootStack.alignment = .center rootStack.distribution = .fill let variant = item.data.string("variant") @@ -2628,7 +2746,7 @@ index e0bf60b..33bebb6 100644 if variant == "loading" { leadingWidth.constant = 40 leadingHeight.constant = 40 -@@ -2357,7 +2782,105 @@ final class NativeListCell: UICollectionViewCell { +@@ -2357,7 +2787,105 @@ final class NativeListCell: UICollectionViewCell { tokenPair: tokenPair, shape: shape )) @@ -2735,7 +2853,7 @@ index e0bf60b..33bebb6 100644 } NSLayoutConstraint.activate(leadingSlotConstraints) } -@@ -2439,6 +2962,11 @@ final class NativeListCell: UICollectionViewCell { +@@ -2439,6 +2967,11 @@ final class NativeListCell: UICollectionViewCell { switch accessory.string("kind") { case "value": showAccessory(textIndex, accessory.string("text")) @@ -2747,7 +2865,7 @@ index e0bf60b..33bebb6 100644 textIndex += 1 case "valuePair": showValuePairAccessory(textIndex, accessory, theme: theme) -@@ -2519,7 +3047,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -2519,7 +3052,7 @@ final class NativeListCell: UICollectionViewCell { state == "unchecked" ? nil : nativeListIcon(named: glyphName), for: .normal ) @@ -2756,7 +2874,7 @@ index e0bf60b..33bebb6 100644 // A disabled ListItem already applies 0.5 to its complete content. Avoid // multiplying that opacity on the nested control a second time. checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 -@@ -2543,11 +3071,24 @@ final class NativeListCell: UICollectionViewCell { +@@ -2543,11 +3076,24 @@ final class NativeListCell: UICollectionViewCell { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight @@ -2781,7 +2899,7 @@ index e0bf60b..33bebb6 100644 if letterSpacing != 0 { attributes[.kern] = letterSpacing } label.attributedText = NSAttributedString(string: text, attributes: attributes) } -@@ -2563,7 +3104,20 @@ final class NativeListCell: UICollectionViewCell { +@@ -2563,7 +3109,20 @@ final class NativeListCell: UICollectionViewCell { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.minimumLineHeight = lineHeight paragraphStyle.maximumLineHeight = lineHeight @@ -2803,7 +2921,7 @@ index e0bf60b..33bebb6 100644 button.setAttributedTitle( NSAttributedString( string: text, -@@ -2571,6 +3125,7 @@ final class NativeListCell: UICollectionViewCell { +@@ -2571,6 +3130,7 @@ final class NativeListCell: UICollectionViewCell { .font: font, .foregroundColor: color, .paragraphStyle: paragraphStyle, @@ -2811,7 +2929,7 @@ index e0bf60b..33bebb6 100644 ] ), for: .normal -@@ -2671,6 +3226,9 @@ final class NativeListCell: UICollectionViewCell { +@@ -2671,6 +3231,9 @@ final class NativeListCell: UICollectionViewCell { switch tone.isEmpty ? defaultTone : tone { case "positive": return nativeListColor(theme, "positive", "#218358") case "negative": return nativeListColor(theme, "negative", "#CE2C31") @@ -2821,7 +2939,7 @@ index e0bf60b..33bebb6 100644 case "secondary": return nativeListColor(theme, "secondaryText", "#646464") default: return nativeListColor(theme, "primaryText", "#202020") } -@@ -2682,10 +3240,10 @@ final class NativeListCell: UICollectionViewCell { +@@ -2682,10 +3245,10 @@ final class NativeListCell: UICollectionViewCell { button.isHidden = false button.isEnabled = !data.bool("disabled") button.alpha = button.isEnabled ? 1 : 0.4 @@ -2836,7 +2954,7 @@ index e0bf60b..33bebb6 100644 button.tintColor = tintColor if let image = nativeListIcon(named: data.string("name")) { button.setImage(image, for: .normal) -@@ -2695,7 +3253,12 @@ final class NativeListCell: UICollectionViewCell { +@@ -2695,7 +3258,12 @@ final class NativeListCell: UICollectionViewCell { ) } let isDrillIn = data.string("kind") == "chevron" @@ -2850,7 +2968,7 @@ index e0bf60b..33bebb6 100644 if !isDrillIn, !data.string("actionKey").isEmpty { // Reproduce the trailing edge of IconButton's m=-7 while keeping its // full 36-point frame for padding/highlight behavior. -@@ -2726,8 +3289,15 @@ final class NativeListCell: UICollectionViewCell { +@@ -2726,8 +3294,15 @@ final class NativeListCell: UICollectionViewCell { into imageView: OneKeyImageReusableView, token: String, slot: Int, @@ -2867,7 +2985,7 @@ index e0bf60b..33bebb6 100644 let headersJson: String? if let headers = source.dictionary("headers"), JSONSerialization.isValidJSONObject(headers), -@@ -2743,10 +3313,27 @@ final class NativeListCell: UICollectionViewCell { +@@ -2743,10 +3318,27 @@ final class NativeListCell: UICollectionViewCell { contentFit: source.string("contentFit", default: "cover"), cachePolicy: source.string("cachePolicy", default: "memory-disk"), autoplay: source.bool("autoplay"), @@ -2898,7 +3016,7 @@ index e0bf60b..33bebb6 100644 ) } -@@ -2837,12 +3424,17 @@ final class NativeListCell: UICollectionViewCell { +@@ -2837,12 +3429,17 @@ final class NativeListCell: UICollectionViewCell { source: String, slot: Int? = nil ) -> NativeListActionOrigin { @@ -2918,7 +3036,7 @@ index e0bf60b..33bebb6 100644 ) } -@@ -2851,6 +3443,8 @@ final class NativeListCell: UICollectionViewCell { +@@ -2851,6 +3448,8 @@ final class NativeListCell: UICollectionViewCell { } private func invalidateCurrentBinding() { @@ -2971,7 +3089,7 @@ index 7dd7368..3f3b90a 100644 + .withRenderingMode(["GoogleIllus", "BotIllus", "AccountErrorCustom"].contains(name) ? .alwaysOriginal : .alwaysTemplate) } diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift -index d89c4c2..c852266 100644 +index d89c4c2..8d13e87 100644 --- a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift +++ b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift @@ -1,4 +1,5 @@ @@ -2980,15 +3098,16 @@ index d89c4c2..c852266 100644 import UIKit import UniformTypeIdentifiers -@@ -77,6 +78,7 @@ final class NativeListView: UIView { +@@ -77,6 +78,8 @@ final class NativeListView: UIView { target: self, action: #selector(reorderLongPressChanged(_:)) ) -+ private lazy var listBodyGestureGuard = UILongPressGestureRecognizer(target: nil, action: nil) ++ // OneKey patch: claim only vertical drags so held rows and ancestor pagers stay responsive. ++ private lazy var listBodyGestureGuard = UIPanGestureRecognizer(target: nil, action: nil) private var interactiveReorderSource: (key: String, index: Int)? private weak var interactiveReorderCell: NativeListCell? private var interactiveReorderCompactKey: String? -@@ -92,7 +94,10 @@ final class NativeListView: UIView { +@@ -92,7 +95,10 @@ final class NativeListView: UIView { private let actionAnchorInstanceID = UUID().uuidString private var actionAnchorCounter = 0 @@ -3000,22 +3119,19 @@ index d89c4c2..c852266 100644 override init(frame: CGRect) { super.init(frame: frame) -@@ -102,6 +107,14 @@ final class NativeListView: UIView { +@@ -102,6 +108,11 @@ final class NativeListView: UIView { collectionView.dragDelegate = self collectionView.dropDelegate = self collectionView.alwaysBounceVertical = true + // OneKey patch: keep list-body drags from being claimed by an ancestor modal sheet. -+ listBodyGestureGuard.minimumPressDuration = 0 -+ listBodyGestureGuard.allowableMovement = .greatestFiniteMagnitude + listBodyGestureGuard.cancelsTouchesInView = false -+ listBodyGestureGuard.delaysTouchesEnded = false + listBodyGestureGuard.isEnabled = false + listBodyGestureGuard.delegate = self + collectionView.addGestureRecognizer(listBodyGestureGuard) reorderLongPress.minimumPressDuration = ReorderAnimation.longPressDuration reorderLongPress.allowableMovement = ReorderAnimation.allowableMovement reorderLongPress.delegate = self -@@ -136,11 +149,14 @@ final class NativeListView: UIView { +@@ -136,11 +147,14 @@ final class NativeListView: UIView { sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), sectionIndexView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), sectionIndexView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), @@ -3034,7 +3150,7 @@ index d89c4c2..c852266 100644 ]) sectionIndexView.isHidden = true -@@ -152,11 +168,11 @@ final class NativeListView: UIView { +@@ -152,11 +166,11 @@ final class NativeListView: UIView { } sectionIndexPreview.isHidden = true sectionIndexPreview.alpha = 0 @@ -3048,7 +3164,7 @@ index d89c4c2..c852266 100644 sectionIndexPreview.isAccessibilityElement = false footerCell.onAction = { [weak self] item, action, target, origin in -@@ -472,7 +488,8 @@ final class NativeListView: UIView { +@@ -472,7 +486,8 @@ final class NativeListView: UIView { private func configureLayout(_ config: NativeListConfig) { let isHorizontal = config.orientation == "horizontal" @@ -3058,7 +3174,7 @@ index d89c4c2..c852266 100644 let isRightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft flowLayout.scrollDirection = isHorizontal ? .horizontal : .vertical flowLayout.minimumLineSpacing = config.itemSpacing -@@ -485,7 +502,7 @@ final class NativeListView: UIView { +@@ -485,7 +500,7 @@ final class NativeListView: UIView { ) flowLayout.stickyItemIndexes = config.stickyHeaders ? Set(config.items.enumerated().compactMap { @@ -3067,7 +3183,7 @@ index d89c4c2..c852266 100644 ? $0.offset : nil }) -@@ -493,6 +510,7 @@ final class NativeListView: UIView { +@@ -493,6 +508,7 @@ final class NativeListView: UIView { flowLayout.invalidateLayout() collectionView.alwaysBounceHorizontal = isHorizontal collectionView.alwaysBounceVertical = !isHorizontal @@ -3075,7 +3191,7 @@ index d89c4c2..c852266 100644 collectionView.showsVerticalScrollIndicator = sectionIndexEntries.isEmpty collectionView.dragInteractionEnabled = false lastLayoutDirection = effectiveUserInterfaceLayoutDirection -@@ -512,11 +530,12 @@ final class NativeListView: UIView { +@@ -512,11 +528,12 @@ final class NativeListView: UIView { interactiveReorderCell = nil return } @@ -3093,7 +3209,7 @@ index d89c4c2..c852266 100644 interactiveReorderSource = (item.key, indexPath.item) interactiveReorderCell = cell interactiveReorderUsesAtomicTargeting = item.type == "identity" && -@@ -838,7 +857,8 @@ final class NativeListView: UIView { +@@ -838,7 +855,8 @@ final class NativeListView: UIView { sectionIndexView.configure( titles: sectionIndexEntries.map(\.title), textColor: nativeListColor(config.theme, "secondaryText", "#646464"), @@ -3103,7 +3219,7 @@ index d89c4c2..c852266 100644 ) sectionIndexView.isHidden = sectionIndexEntries.isEmpty sectionIndexPreview.backgroundColor = nativeListColor( -@@ -932,7 +952,7 @@ final class NativeListView: UIView { +@@ -932,7 +950,7 @@ final class NativeListView: UIView { theme: config.theme, layout: config.layout, itemIndex: itemIndex, @@ -3112,7 +3228,7 @@ index d89c4c2..c852266 100644 checkboxState: { [weak self] item, target, fallback in self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback } -@@ -943,7 +963,8 @@ final class NativeListView: UIView { +@@ -943,7 +961,8 @@ final class NativeListView: UIView { } private func handleRowPress(_ item: NativeListItem, origin: NativeListActionOrigin?) { @@ -3122,7 +3238,7 @@ index d89c4c2..c852266 100644 if config.rowPressToggles && item.isSelectable && config.selectionMode != "none" { updateSelection(target: NativeSelectionTarget(scope: "row", key: item.key), sourceKey: item.key) return -@@ -1037,7 +1058,7 @@ final class NativeListView: UIView { +@@ -1037,7 +1056,7 @@ final class NativeListView: UIView { let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue } cell.updateSelection( item: item, @@ -3131,7 +3247,7 @@ index d89c4c2..c852266 100644 checkboxState: checkboxState ) } -@@ -1080,19 +1101,73 @@ final class NativeListView: UIView { +@@ -1080,19 +1099,73 @@ final class NativeListView: UIView { return zip(current.items, next.items).allSatisfy { old, new in guard old.key == new.key, old.type == new.type else { return false } if old.content == new.content { return true } @@ -3214,7 +3330,7 @@ index d89c4c2..c852266 100644 private func dictionariesEqual(_ lhs: [String: Any]?, _ rhs: [String: Any]?) -> Bool { switch (lhs, rhs) { case (nil, nil): return true -@@ -1107,16 +1182,36 @@ final class NativeListView: UIView { +@@ -1107,16 +1180,36 @@ final class NativeListView: UIView { } private func rowHeight(_ item: NativeListItem) -> CGFloat { @@ -3253,7 +3369,7 @@ index d89c4c2..c852266 100644 } if item.type == "identity", item.data.string("presentation") == "networkSelector" { return 47 -@@ -1268,7 +1363,7 @@ final class NativeListView: UIView { +@@ -1268,7 +1361,7 @@ final class NativeListView: UIView { actionAnchorCounter &+= 1 let generation = config?.generation ?? 0 let token = "\(actionAnchorInstanceID):\(generation):\(actionAnchorCounter):\(origin.bindingEpoch)" @@ -3262,7 +3378,18 @@ index d89c4c2..c852266 100644 let record = ActionAnchorRecord(token: token, origin: origin) actionAnchor = record var anchor: [String: Any] = [ -@@ -1375,6 +1470,7 @@ final class NativeListView: UIView { +@@ -1371,10 +1464,18 @@ final class NativeListView: UIView { + if let lastKey = config.items.last?.key { payload["lastKey"] = lastKey } + emit(onEndReached, payload) + } ++ ++ override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { ++ guard gestureRecognizer === listBodyGestureGuard, ++ let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } ++ let velocity = pan.velocity(in: collectionView) ++ return abs(velocity.y) > abs(velocity.x) ++ } + } extension NativeListView: UIGestureRecognizerDelegate { func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { @@ -3270,7 +3397,7 @@ index d89c4c2..c852266 100644 var view = touch.view while let current = view, current !== footerCell { if current is UIControl { return false } -@@ -1387,7 +1483,26 @@ extension NativeListView: UIGestureRecognizerDelegate { +@@ -1387,7 +1488,26 @@ extension NativeListView: UIGestureRecognizerDelegate { _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { @@ -3298,7 +3425,7 @@ index d89c4c2..c852266 100644 } } -@@ -1604,13 +1719,16 @@ private struct NativeListSectionIndexEntry { +@@ -1604,13 +1724,16 @@ private struct NativeListSectionIndexEntry { let position: Int } @@ -3316,7 +3443,7 @@ index d89c4c2..c852266 100644 private var lastTouchIndex: Int? private(set) var activeIndex: Int? -@@ -1620,16 +1738,43 @@ private final class NativeListSectionIndexView: UIControl { +@@ -1620,16 +1743,43 @@ private final class NativeListSectionIndexView: UIControl { accessibilityLabel = "Section index" accessibilityTraits = [.adjustable] isExclusiveTouch = true @@ -3361,7 +3488,7 @@ index d89c4c2..c852266 100644 labels.forEach { $0.removeFromSuperview() } labels = titles.map { title in let label = UILabel() -@@ -1660,9 +1805,9 @@ private final class NativeListSectionIndexView: UIControl { +@@ -1660,9 +1810,9 @@ private final class NativeListSectionIndexView: UIControl { let originY = (bounds.height - height * CGFloat(labels.count)) / 2 for (index, label) in labels.enumerated() { label.frame = CGRect( @@ -3373,7 +3500,7 @@ index d89c4c2..c852266 100644 height: height ) } -@@ -1718,7 +1863,10 @@ private final class NativeListSectionIndexView: UIControl { +@@ -1718,7 +1868,10 @@ private final class NativeListSectionIndexView: UIControl { private func updateLabelStyles() { for (index, label) in labels.enumerated() { let active = index == activeIndex @@ -4876,7 +5003,7 @@ index 0000000..f896e62 + return cache.acquire(uri, resolve, reject, priority); +} diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -index 625a913..d742ddd 100644 +index 625a913..d93e221 100644 --- a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js +++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js @@ -3,7 +3,13 @@ @@ -4943,7 +5070,15 @@ index 625a913..d742ddd 100644 .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -424,9 +447,29 @@ export const WEB_LIST_CSS = ` +@@ -382,6 +405,7 @@ export const WEB_LIST_CSS = ` + .ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} + .ok-native-list-item[data-native-list-disabled="true"]>.ok-native-list-row{opacity:.5;cursor:default} + .ok-native-list-item:not([data-native-list-disabled="true"]):hover>.ok-native-list-row{background:var(--nl-pressed)} ++.ok-native-list-item:not([data-native-list-disabled="true"]):active>.ok-native-list-row{background:var(--nl-pressed)} + .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):hover>.ok-native-list-wallet-row{background:var(--nl-strong)} + .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):active>.ok-native-list-wallet-row{background:var(--nl-pressed)} + .ok-native-list-item[data-native-list-selected="true"]:hover>.ok-native-list-wallet-row{background:var(--nl-selected)} +@@ -424,9 +448,29 @@ export const WEB_LIST_CSS = ` .ok-native-list-media{display:block;padding:0 5px;background:transparent;border-radius:16px}.ok-native-list-media-image{display:block;width:100%;aspect-ratio:1;border-radius:10px;background:var(--nl-strong);object-fit:cover}.ok-native-list-media-image[data-state="empty"]{background:transparent}.ok-native-list-media-image[data-state="error"]{display:flex;align-items:center;justify-content:center;color:var(--nl-icon-subdued);font-size:24px}.ok-native-list-media-meta{padding-top:7px}.ok-native-list-media-subtitle-row{display:flex;align-items:center;gap:6px}.ok-native-list-media-subtitle{flex:1;min-width:0;font-size:12px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-network{width:14px;height:14px;border-radius:50%}.ok-native-list-media-title{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-close{position:absolute;right:9px;top:4px;border:0;background:color-mix(in srgb,var(--nl-inverse) 72%,transparent);color:var(--nl-inverse-text);width:24px;height:24px;border-radius:50%;font:18px/20px inherit;cursor:pointer} .ok-native-list-metric{display:flex;flex-direction:column;align-items:flex-start;padding:12px;border-radius:12px;gap:5px;background:var(--nl-row)}.ok-native-list-metric-value{font-size:22px;line-height:28px;font-weight:700}.ok-native-list-composite{display:flex;flex-direction:column;align-items:stretch;padding:14px;border-radius:12px;gap:12px;background:var(--nl-subdued)}.ok-native-list-composite-heading{font-size:14px;letter-spacing:1px;color:var(--nl-secondary)}.ok-native-list-composite-row{display:flex;gap:12px}.ok-native-list-composite-cell{flex:1;min-width:0}.ok-native-list-composite-cell[data-shaded="true"]{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--nl-primary) 5%,transparent)}.ok-native-list-composite-value{font-size:18px;font-weight:600}.ok-native-list-divider{height:1px;background:var(--nl-separator)}.ok-native-list-progress{height:4px;border-radius:2px;overflow:hidden;background:var(--nl-negative)}.ok-native-list-progress>span{display:block;height:100%;border-radius:2px;background:var(--nl-positive)} .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} @@ -4974,7 +5109,7 @@ index 625a913..d742ddd 100644 `; function createElement(document, tag, className, text) { const element = document.createElement(tag); -@@ -442,12 +485,105 @@ function safeImageUri(uri) { +@@ -442,12 +486,105 @@ function safeImageUri(uri) { if (/^(https?:|data:image\/|blob:|file:)/i.test(trimmed) || trimmed.startsWith('/')) return trimmed; return undefined; } @@ -5082,7 +5217,7 @@ index 625a913..d742ddd 100644 image.alt = ''; image.draggable = false; image.loading = 'lazy'; -@@ -455,6 +591,247 @@ function createImage(context, source, className) { +@@ -455,6 +592,247 @@ function createImage(context, source, className) { image.style.objectFit = source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover'; return image; } @@ -5330,7 +5465,7 @@ index 625a913..d742ddd 100644 function iconGlyph(name) { const normalized = name.toLocaleLowerCase(); if (normalized.includes('chevron')) { -@@ -484,7 +861,7 @@ function visualFromRow(row) { +@@ -484,7 +862,7 @@ function visualFromRow(row) { if (row.type === 'metricCard') return row.visual; return undefined; } @@ -5339,7 +5474,7 @@ index 625a913..d742ddd 100644 if (!visual) return undefined; if (visual.kind === 'stackedImages') { const stack = createElement(context.document, 'span', 'ok-native-list-stacked'); -@@ -500,6 +877,7 @@ function createVisual(context, visual) { +@@ -500,6 +878,7 @@ function createVisual(context, visual) { if (visual.kind === 'icon') { frame.style.background = visual.backgroundColor ?? 'var(--nl-strong)'; const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback', iconGlyph(visual.name)); @@ -5347,7 +5482,7 @@ index 625a913..d742ddd 100644 if (visual.tintColor) fallback.style.color = visual.tintColor; frame.appendChild(fallback); return frame; -@@ -510,6 +888,7 @@ function createVisual(context, visual) { +@@ -510,6 +889,7 @@ function createVisual(context, visual) { if (image) { image.className = 'ok-native-list-visual-main'; frame.appendChild(image); @@ -5355,7 +5490,7 @@ index 625a913..d742ddd 100644 } else { frame.appendChild(createElement(context.document, 'span', 'ok-native-list-visual-fallback', 'fallbackText' in visual ? visual.fallbackText ?? '' : '')); } -@@ -520,8 +899,64 @@ function createVisual(context, visual) { +@@ -520,8 +900,64 @@ function createVisual(context, visual) { const corner = createElement(context.document, 'span', 'ok-native-list-visual-corner ok-native-list-visual-fallback', iconGlyph(visual.cornerIcon.name)); if (visual.cornerIcon.tintColor) corner.style.color = visual.cornerIcon.tintColor; if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; @@ -5420,7 +5555,7 @@ index 625a913..d742ddd 100644 return frame; } function toneColor(tone, fallback) { -@@ -567,6 +1002,19 @@ function createCheckbox(context, rowKey, accessory) { +@@ -567,6 +1003,19 @@ function createCheckbox(context, rowKey, accessory) { setData(element, 'checkboxFallback', accessory.state); setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); @@ -5440,7 +5575,7 @@ index 625a913..d742ddd 100644 if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey);else if (accessory.target?.scope === 'row') setData(element, 'selectionKey', rowKey); return element; } -@@ -576,6 +1024,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { +@@ -576,6 +1025,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { element.setAttribute('type', 'button'); element.toggleAttribute('disabled', Boolean(disabled)); } @@ -5448,7 +5583,7 @@ index 625a913..d742ddd 100644 if (actionKey) setData(element, 'nativeListAction', actionKey); if (tintColor) element.style.color = tintColor; return element; -@@ -584,6 +1033,23 @@ function markActionAnchorSource(element, source, slot) { +@@ -584,6 +1034,23 @@ function markActionAnchorSource(element, source, slot) { setData(element, 'nativeListAnchorSource', source); if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } @@ -5472,7 +5607,7 @@ index 625a913..d742ddd 100644 function createAccessory(context, rowKey, accessory, slot) { if (accessory.kind === 'checkbox') { const element = createCheckbox(context, rowKey, accessory); -@@ -593,6 +1059,18 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -593,6 +1060,18 @@ function createAccessory(context, rowKey, accessory, slot) { if (accessory.kind === 'icon') { const element = createIconAction(context, accessory.name, accessory.actionKey, accessory.disabled, accessory.tintColor); markActionAnchorSource(element, 'trailingAccessory', slot); @@ -5491,7 +5626,7 @@ index 625a913..d742ddd 100644 return element; } if (accessory.kind === 'spinner') { -@@ -608,6 +1086,7 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -608,6 +1087,7 @@ function createAccessory(context, rowKey, accessory, slot) { switch (accessory.kind) { case 'value': element.textContent = accessory.text; @@ -5499,7 +5634,7 @@ index 625a913..d742ddd 100644 if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); break; case 'valuePair': -@@ -646,6 +1125,13 @@ function createAccessory(context, rowKey, accessory, slot) { +@@ -646,6 +1126,13 @@ function createAccessory(context, rowKey, accessory, slot) { function appendAccessories(parent, context, rowKey, accessories) { if (!accessories?.length) return; const container = createElement(context.document, 'span', 'ok-native-list-accessories'); @@ -5513,7 +5648,7 @@ index 625a913..d742ddd 100644 accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot))); parent.appendChild(container); } -@@ -673,9 +1159,83 @@ function createSectionHeader(context, row) { +@@ -673,9 +1160,83 @@ function createSectionHeader(context, row) { markActionAnchorSource(titleIcon, 'leadingAction'); body.appendChild(titleIcon); } @@ -5598,7 +5733,7 @@ index 625a913..d742ddd 100644 if (row.valueActionKey) { value.setAttribute('type', 'button'); setData(value, 'nativeListAction', row.valueActionKey); -@@ -696,6 +1256,7 @@ function createActionRow(context, row) { +@@ -696,6 +1257,7 @@ function createActionRow(context, row) { if (row.icon) body.appendChild(createVisual(context, row.icon)); const title = createElement(context.document, 'span', 'ok-native-list-action-title', row.title); setData(title, 'tone', row.tone); @@ -5606,7 +5741,7 @@ index 625a913..d742ddd 100644 body.appendChild(title); if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); appendAccessories(body, context, row.key, row.trailing); -@@ -704,6 +1265,13 @@ function createActionRow(context, row) { +@@ -704,6 +1266,13 @@ function createActionRow(context, row) { function createSystemRow(context, row) { const body = createElement(context.document, 'div', 'ok-native-list-row ok-native-list-system'); setData(body, 'variant', row.variant); @@ -5620,7 +5755,7 @@ index 625a913..d742ddd 100644 if (row.variant === 'loading') body.appendChild(createElement(context.document, 'span', 'ok-native-list-spinner')); const message = row.variant === 'spacer' ? '' : row.message ?? (row.variant === 'end' ? 'End' : ''); if (message) body.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', message)); -@@ -852,12 +1420,31 @@ function createDataRow(context, row) { +@@ -852,12 +1421,31 @@ function createDataRow(context, row) { function createIdentityActivityOrMessageRow(context, row) { const presentation = row.type === 'identity' ? row.presentation : undefined; const body = createElement(context.document, 'div', ['ok-native-list-row', 'ok-native-list-standard', row.type === 'identity' && !presentation ? 'ok-native-list-identity-row' : '', presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', presentation === 'accountSelector' ? 'ok-native-list-account-row' : ''].filter(Boolean).join(' ')); @@ -5653,7 +5788,7 @@ index 625a913..d742ddd 100644 if (visual) body.appendChild(visual); if (row.type === 'activity' && row.secondaryLeading) { const secondVisual = createVisual(context, row.secondaryLeading); -@@ -866,7 +1453,46 @@ function createIdentityActivityOrMessageRow(context, row) { +@@ -866,7 +1454,46 @@ function createIdentityActivityOrMessageRow(context, row) { if (row.type === 'message' && row.unread) body.appendChild(createElement(context.document, 'span', 'ok-native-list-unread')); const title = row.title; const subtitle = row.type === 'identity' ? row.subtitle : row.type === 'activity' ? row.description : row.body; @@ -5701,7 +5836,7 @@ index 625a913..d742ddd 100644 if (row.type === 'activity' && row.status) column.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', row.status)); if (row.type === 'activity' && row.footerActions?.length) { const actions = createElement(context.document, 'span', 'ok-native-list-actions'); -@@ -898,14 +1524,32 @@ function createIdentityActivityOrMessageRow(context, row) { +@@ -898,14 +1525,32 @@ function createIdentityActivityOrMessageRow(context, row) { } return body; } @@ -5735,7 +5870,7 @@ index 625a913..d742ddd 100644 body.appendChild(memberElement); }); return body; -@@ -945,6 +1589,11 @@ export class NativeListWebEngine { +@@ -945,6 +1590,11 @@ export class NativeListWebEngine { }; mounted = new Map(); pool = []; @@ -5747,7 +5882,7 @@ index 625a913..d742ddd 100644 suppressClickUntil = 0; pullDistance = 0; actionAnchorInstanceId = String(++webNativeListInstanceCounter); -@@ -953,6 +1602,8 @@ export class NativeListWebEngine { +@@ -953,6 +1603,8 @@ export class NativeListWebEngine { lastViewportWidth = -1; lastViewportHeight = -1; destroyed = false; @@ -5756,7 +5891,7 @@ index 625a913..d742ddd 100644 constructor(host, snapshot, callbacks, virtualizationEnabled = true) { this.document = host.ownerDocument; this.snapshot = validateSnapshot(snapshot); -@@ -991,6 +1642,9 @@ export class NativeListWebEngine { +@@ -991,6 +1643,9 @@ export class NativeListWebEngine { passive: true }); this.root.addEventListener('click', this.handleClick); @@ -5766,7 +5901,7 @@ index 625a913..d742ddd 100644 this.root.addEventListener('keydown', this.handleKeyDown); this.viewport.addEventListener('pointerdown', this.handleReorderPointerDown, { passive: false -@@ -1125,7 +1779,7 @@ export class NativeListWebEngine { +@@ -1125,7 +1780,7 @@ export class NativeListWebEngine { scrollToLocation(params, scroll) { const index = resolveLocationIndex(this.snapshot.rows, params); if (index === undefined) { @@ -5775,7 +5910,7 @@ index 625a913..d742ddd 100644 this.emitScrollFailure(params.itemIndex, params.sectionIndex >= sectionCount ? 'section-out-of-range' : 'item-out-of-range'); return; } -@@ -1172,6 +1826,8 @@ export class NativeListWebEngine { +@@ -1172,6 +1827,8 @@ export class NativeListWebEngine { this.document.defaultView?.removeEventListener('resize', this.handleWindowResize); this.viewport.removeEventListener('scroll', this.handleScroll); this.root.removeEventListener('click', this.handleClick); @@ -5784,7 +5919,7 @@ index 625a913..d742ddd 100644 this.root.removeEventListener('keydown', this.handleKeyDown); this.cancelPointerReorder(true); this.viewport.removeEventListener('pointerdown', this.handleReorderPointerDown); -@@ -1189,19 +1845,23 @@ export class NativeListWebEngine { +@@ -1189,19 +1846,23 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointerup', this.handlePullEnd); this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; @@ -5809,7 +5944,7 @@ index 625a913..d742ddd 100644 this.applyTheme(); this.recomputeLayout(); this.renderFooter(); -@@ -1219,6 +1879,11 @@ export class NativeListWebEngine { +@@ -1219,6 +1880,11 @@ export class NativeListWebEngine { '--nl-primary': theme.primaryText, '--nl-secondary': theme.secondaryText, '--nl-disabled': theme.disabledText, @@ -5821,7 +5956,7 @@ index 625a913..d742ddd 100644 '--nl-icon': theme.icon, '--nl-icon-subdued': theme.iconSubdued, '--nl-separator': theme.separator, -@@ -1242,13 +1907,22 @@ export class NativeListWebEngine { +@@ -1242,13 +1908,22 @@ export class NativeListWebEngine { const viewportHeight = this.viewport.clientHeight; if (this.lastViewportWidth >= 0 && (viewportWidth !== this.lastViewportWidth || viewportHeight !== this.lastViewportHeight)) { this.invalidateActionAnchor('layout'); @@ -5845,7 +5980,7 @@ index 625a913..d742ddd 100644 if (previousHorizontal !== this.layout.horizontal) { this.viewport.scrollLeft = 0; this.viewport.scrollTop = 0; -@@ -1256,7 +1930,44 @@ export class NativeListWebEngine { +@@ -1256,7 +1931,44 @@ export class NativeListWebEngine { this.renderWindow(); this.performPendingScroll(); }; @@ -5890,7 +6025,7 @@ index 625a913..d742ddd 100644 const viewportLength = this.viewportLength(); const visible = webLayoutItemsForMount(this.layout, this.currentOffset(), viewportLength, this.virtualizationEnabled, viewportLength * OVERSCAN_VIEWPORTS); const desired = new Set(visible.map(item => item.index)); -@@ -1264,6 +1975,7 @@ export class NativeListWebEngine { +@@ -1264,6 +1976,7 @@ export class NativeListWebEngine { if (!desired.has(index)) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); @@ -5898,7 +6033,7 @@ index 625a913..d742ddd 100644 element.remove(); this.pool.push(element); } -@@ -1285,6 +1997,20 @@ export class NativeListWebEngine { +@@ -1285,6 +1998,20 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); @@ -5919,7 +6054,7 @@ index 625a913..d742ddd 100644 this.updateVisibleSelection(); this.updateVisibleState(); } -@@ -1295,12 +2021,16 @@ export class NativeListWebEngine { +@@ -1295,12 +2022,16 @@ export class NativeListWebEngine { } renderElement(element, index, row, overlay = false) { this.invalidateActionAnchorForElement(element); @@ -5936,7 +6071,7 @@ index 625a913..d742ddd 100644 setData(element, 'nativeListReorderable', this.isReorderable(row)); setData(element, 'nativeListDragging', this.pointerReorder?.active && this.pointerReorder.sourceKey === row.key); setData(element, 'separator', row.separator); -@@ -1320,11 +2050,67 @@ export class NativeListWebEngine { +@@ -1320,11 +2051,67 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index }; @@ -6005,7 +6140,7 @@ index 625a913..d742ddd 100644 this.footer.replaceChildren(); if (!row) return; const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -1335,20 +2121,52 @@ export class NativeListWebEngine { +@@ -1335,20 +2122,52 @@ export class NativeListWebEngine { element.style.height = String(estimateWebRowHeight(row, this.snapshot, this.viewport.clientWidth || DEFAULT_VIEWPORT_WIDTH)) + 'px'; this.footer.appendChild(element); } @@ -6065,7 +6200,7 @@ index 625a913..d742ddd 100644 fragment.appendChild(button); }); this.indexRail.appendChild(fragment); -@@ -1357,7 +2175,9 @@ export class NativeListWebEngine { +@@ -1357,7 +2176,9 @@ export class NativeListWebEngine { updateVisibleSelection() { const update = (element, row) => { if (!row) return; @@ -6076,7 +6211,7 @@ index 625a913..d742ddd 100644 setData(element, 'nativeListSelected', selected); element.setAttribute('aria-selected', String(selected)); element.querySelectorAll('.ok-native-list-checkbox').forEach(checkbox => { -@@ -1396,7 +2216,9 @@ export class NativeListWebEngine { +@@ -1396,7 +2217,9 @@ export class NativeListWebEngine { } this.checkEndReached(last?.index ?? -1); this.updateStickyHeader(first?.index ?? -1); @@ -6087,7 +6222,7 @@ index 625a913..d742ddd 100644 } updateStickyHeader(firstVisibleIndex) { if (!this.snapshot.layout.stickyHeaders || this.layout.horizontal || firstVisibleIndex < 0) { -@@ -1407,7 +2229,7 @@ export class NativeListWebEngine { +@@ -1407,7 +2230,7 @@ export class NativeListWebEngine { let index = -1; for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { const row = this.rows[cursor]; @@ -6096,7 +6231,7 @@ index 625a913..d742ddd 100644 index = cursor; break; } -@@ -1433,7 +2255,7 @@ export class NativeListWebEngine { +@@ -1433,7 +2256,7 @@ export class NativeListWebEngine { let nextIndex = -1; for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { const candidate = this.rows[cursor]; @@ -6105,7 +6240,7 @@ index 625a913..d742ddd 100644 nextIndex = cursor; break; } -@@ -1443,10 +2265,15 @@ export class NativeListWebEngine { +@@ -1443,10 +2266,15 @@ export class NativeListWebEngine { this.sticky.style.transform = 'translate3d(0,' + String(translate) + 'px,0)'; this.updateVisibleSelection(); } @@ -6123,7 +6258,7 @@ index 625a913..d742ddd 100644 }); this.indexRail.querySelectorAll('[data-section-key]').forEach(button => setData(button, 'active', button.dataset.sectionKey === activeKey)); } -@@ -1539,7 +2366,14 @@ export class NativeListWebEngine { +@@ -1539,7 +2367,14 @@ export class NativeListWebEngine { const bindingEpoch = rowElement.dataset.nativeListBindingEpoch; if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; this.invalidateActionAnchor('rebind'); @@ -6139,7 +6274,7 @@ index 625a913..d742ddd 100644 const token = [this.actionAnchorInstanceId, this.snapshot.generation, ++this.actionAnchorCounter, bindingEpoch].join(':'); const slotValue = actionElement.dataset.nativeListAnchorSlot; const direction = this.document.defaultView?.getComputedStyle(actionElement).direction === 'rtl' || actionElement.closest('[dir="rtl"]') ? 'rtl' : 'ltr'; -@@ -1577,7 +2411,9 @@ export class NativeListWebEngine { +@@ -1577,7 +2412,9 @@ export class NativeListWebEngine { }); } handleRowPress(row, rowElement, sourceElement = rowElement) { @@ -6150,7 +6285,7 @@ index 625a913..d742ddd 100644 if (this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && isSelectableRow(row)) { this.activateSelection({ scope: 'row' -@@ -1597,6 +2433,27 @@ export class NativeListWebEngine { +@@ -1597,6 +2434,27 @@ export class NativeListWebEngine { this.emitRowAction(row, actionKey); } } @@ -6178,7 +6313,7 @@ index 625a913..d742ddd 100644 handleClick = event => { if (Date.now() < this.suppressClickUntil) { event.preventDefault(); -@@ -1627,6 +2484,12 @@ export class NativeListWebEngine { +@@ -1627,6 +2485,12 @@ export class NativeListWebEngine { this.handleRowPress(sourceRow, rowElement ?? undefined, memberElement ?? rowElement ?? undefined); }; handleKeyDown = event => { @@ -6191,7 +6326,7 @@ index 625a913..d742ddd 100644 if (event.key === 'Escape') { if (this.pointerReorder?.active) { event.preventDefault(); -@@ -1729,7 +2592,10 @@ export class NativeListWebEngine { +@@ -1729,7 +2593,10 @@ export class NativeListWebEngine { if (this.frameHandle !== undefined || this.destroyed) return; this.frameHandle = this.requestFrame(() => { this.frameHandle = undefined; @@ -6203,7 +6338,7 @@ index 625a913..d742ddd 100644 }); } requestFrame(callback) { -@@ -1740,13 +2606,20 @@ export class NativeListWebEngine { +@@ -1740,13 +2607,20 @@ export class NativeListWebEngine { const view = this.document.defaultView; if (view?.cancelAnimationFrame) view.cancelAnimationFrame(handle);else view?.clearTimeout(handle); } @@ -6225,7 +6360,7 @@ index 625a913..d742ddd 100644 this.indexPreview.textContent = title; setData(this.indexPreview, 'visible', true); if (this.previewTimer !== undefined) this.document.defaultView?.clearTimeout(this.previewTimer); -@@ -1754,24 +2627,40 @@ export class NativeListWebEngine { +@@ -1754,24 +2628,40 @@ export class NativeListWebEngine { setData(this.indexPreview, 'visible', false); }, 180); } @@ -6275,7 +6410,7 @@ index 625a913..d742ddd 100644 }; handlePullStart = event => { if (this.pointerReorder?.pointerId === event.pointerId || !this.snapshot.capabilities?.pullToRefresh || this.viewport.scrollTop > 0 || event.pointerType !== 'touch' && event.pointerType !== 'pen') return; -@@ -1816,7 +2705,14 @@ export class NativeListWebEngine { +@@ -1816,7 +2706,14 @@ export class NativeListWebEngine { const index = Number(rowElement?.dataset.nativeListRowIndex); const row = this.rows[index]; if (!row || !this.isReorderable(row)) return; @@ -6291,7 +6426,7 @@ index 625a913..d742ddd 100644 const view = this.document.defaultView; const state = { pointerId: event.pointerId, -@@ -1832,9 +2728,8 @@ export class NativeListWebEngine { +@@ -1832,9 +2729,8 @@ export class NativeListWebEngine { active: false }; this.pointerReorder = state; @@ -6303,7 +6438,7 @@ index 625a913..d742ddd 100644 state.longPressTimer = view?.setTimeout(() => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS); } }; -@@ -1935,6 +2830,20 @@ export class NativeListWebEngine { +@@ -1935,6 +2831,20 @@ export class NativeListWebEngine { state.previewOffsetX = state.startX - rect.left; state.previewOffsetY = Math.min(previewHeight, Math.max(0, state.startY - rect.top)); this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); @@ -6324,7 +6459,7 @@ index 625a913..d742ddd 100644 const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; if (badgeText) { -@@ -1970,6 +2879,7 @@ export class NativeListWebEngine { +@@ -1970,6 +2880,7 @@ export class NativeListWebEngine { } clearReorderPreviewVisual() { this.reorderPreview.hidden = true; @@ -6332,7 +6467,7 @@ index 625a913..d742ddd 100644 this.reorderPreview.replaceChildren(); this.reorderPreview.style.removeProperty('transform'); this.reorderPreview.style.removeProperty('transition'); -@@ -2235,4 +3145,3 @@ export class NativeListWebEngine { +@@ -2235,4 +3146,3 @@ export class NativeListWebEngine { }); } } @@ -7004,10 +7139,10 @@ index 0000000..3510bc4 +}); diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs new file mode 100644 -index 0000000..c6c3400 +index 0000000..820bffd --- /dev/null +++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs -@@ -0,0 +1,554 @@ +@@ -0,0 +1,560 @@ +// OneKey patch: focused regression checks for the serialized selector adapter contract. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); @@ -7405,6 +7540,12 @@ index 0000000..c6c3400 + } + page.close(); +}); ++test('touch-active rows keep the pressed background rule', () => { ++ const page = mount([identity('network', { presentation: 'networkSelector' })]); ++ const css = page.document.querySelector('style').textContent; ++ assert.match(css, /native-list-disabled="true"\]\):active>\.ok-native-list-row\{background:var\(--nl-pressed\)\}/); ++ page.close(); ++}); +test('wallet hover anchors the complete child row without consuming normal selection taps', () => { + const child = identity('child', { presentation: 'walletSidebar', height: 68, titleActionKey: 'wallet.help', titleActionOnHover: true }); + const group = { type: 'walletGroup', key: 'group', parent: identity('group', { presentation: 'walletSidebar', height: 68 }), children: [child] }; @@ -8734,7 +8875,7 @@ index 0000000..d921a4c + return cache.acquire(uri, resolve, reject, priority); +} diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -index 11d57d1..3a4e04b 100644 +index 11d57d1..75364d7 100644 --- a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts +++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts @@ -37,8 +37,14 @@ import { @@ -8806,7 +8947,15 @@ index 11d57d1..3a4e04b 100644 .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -752,9 +776,29 @@ export const WEB_LIST_CSS = ` +@@ -710,6 +734,7 @@ export const WEB_LIST_CSS = ` + .ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} + .ok-native-list-item[data-native-list-disabled="true"]>.ok-native-list-row{opacity:.5;cursor:default} + .ok-native-list-item:not([data-native-list-disabled="true"]):hover>.ok-native-list-row{background:var(--nl-pressed)} ++.ok-native-list-item:not([data-native-list-disabled="true"]):active>.ok-native-list-row{background:var(--nl-pressed)} + .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):hover>.ok-native-list-wallet-row{background:var(--nl-strong)} + .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):active>.ok-native-list-wallet-row{background:var(--nl-pressed)} + .ok-native-list-item[data-native-list-selected="true"]:hover>.ok-native-list-wallet-row{background:var(--nl-selected)} +@@ -752,9 +777,29 @@ export const WEB_LIST_CSS = ` .ok-native-list-media{display:block;padding:0 5px;background:transparent;border-radius:16px}.ok-native-list-media-image{display:block;width:100%;aspect-ratio:1;border-radius:10px;background:var(--nl-strong);object-fit:cover}.ok-native-list-media-image[data-state="empty"]{background:transparent}.ok-native-list-media-image[data-state="error"]{display:flex;align-items:center;justify-content:center;color:var(--nl-icon-subdued);font-size:24px}.ok-native-list-media-meta{padding-top:7px}.ok-native-list-media-subtitle-row{display:flex;align-items:center;gap:6px}.ok-native-list-media-subtitle{flex:1;min-width:0;font-size:12px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-network{width:14px;height:14px;border-radius:50%}.ok-native-list-media-title{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-close{position:absolute;right:9px;top:4px;border:0;background:color-mix(in srgb,var(--nl-inverse) 72%,transparent);color:var(--nl-inverse-text);width:24px;height:24px;border-radius:50%;font:18px/20px inherit;cursor:pointer} .ok-native-list-metric{display:flex;flex-direction:column;align-items:flex-start;padding:12px;border-radius:12px;gap:5px;background:var(--nl-row)}.ok-native-list-metric-value{font-size:22px;line-height:28px;font-weight:700}.ok-native-list-composite{display:flex;flex-direction:column;align-items:stretch;padding:14px;border-radius:12px;gap:12px;background:var(--nl-subdued)}.ok-native-list-composite-heading{font-size:14px;letter-spacing:1px;color:var(--nl-secondary)}.ok-native-list-composite-row{display:flex;gap:12px}.ok-native-list-composite-cell{flex:1;min-width:0}.ok-native-list-composite-cell[data-shaded="true"]{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--nl-primary) 5%,transparent)}.ok-native-list-composite-value{font-size:18px;font-weight:600}.ok-native-list-divider{height:1px;background:var(--nl-separator)}.ok-native-list-progress{height:4px;border-radius:2px;overflow:hidden;background:var(--nl-negative)}.ok-native-list-progress>span{display:block;height:100%;border-radius:2px;background:var(--nl-positive)} .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} @@ -8837,7 +8986,7 @@ index 11d57d1..3a4e04b 100644 `; function createElement( -@@ -788,16 +832,114 @@ function safeImageUri(uri: string): string | undefined { +@@ -788,16 +833,114 @@ function safeImageUri(uri: string): string | undefined { return undefined; } @@ -8954,7 +9103,7 @@ index 11d57d1..3a4e04b 100644 image.alt = ''; image.draggable = false; image.loading = 'lazy'; -@@ -807,6 +949,43 @@ function createImage( +@@ -807,6 +950,43 @@ function createImage( return image; } @@ -8998,7 +9147,7 @@ index 11d57d1..3a4e04b 100644 function iconGlyph(name: string): string { const normalized = name.toLocaleLowerCase(); if (normalized.includes('chevron')) { -@@ -841,7 +1020,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { +@@ -841,7 +1021,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { function createVisual( context: RenderContext, @@ -9008,7 +9157,7 @@ index 11d57d1..3a4e04b 100644 ): HTMLElement | undefined { if (!visual) return undefined; if (visual.kind === 'stackedImages') { -@@ -873,6 +1053,7 @@ function createVisual( +@@ -873,6 +1054,7 @@ function createVisual( 'ok-native-list-visual-fallback', iconGlyph(visual.name) ); @@ -9016,7 +9165,7 @@ index 11d57d1..3a4e04b 100644 if (visual.tintColor) fallback.style.color = visual.tintColor; frame.appendChild(fallback); return frame; -@@ -885,6 +1066,7 @@ function createVisual( +@@ -885,6 +1067,7 @@ function createVisual( if (image) { image.className = 'ok-native-list-visual-main'; frame.appendChild(image); @@ -9024,7 +9173,7 @@ index 11d57d1..3a4e04b 100644 } else { frame.appendChild( createElement( -@@ -913,8 +1095,51 @@ function createVisual( +@@ -913,8 +1096,51 @@ function createVisual( corner.style.color = visual.cornerIcon.tintColor; if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; @@ -9076,7 +9225,7 @@ index 11d57d1..3a4e04b 100644 return frame; } -@@ -1022,6 +1247,16 @@ function createCheckbox( +@@ -1022,6 +1248,16 @@ function createCheckbox( setData(element, 'checkboxFallback', accessory.state); setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); @@ -9093,7 +9242,7 @@ index 11d57d1..3a4e04b 100644 if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey); else if (accessory.target?.scope === 'row') -@@ -1046,6 +1281,7 @@ function createIconAction( +@@ -1046,6 +1282,7 @@ function createIconAction( element.setAttribute('type', 'button'); element.toggleAttribute('disabled', Boolean(disabled)); } @@ -9101,7 +9250,7 @@ index 11d57d1..3a4e04b 100644 if (actionKey) setData(element, 'nativeListAction', actionKey); if (tintColor) element.style.color = tintColor; return element; -@@ -1060,6 +1296,20 @@ function markActionAnchorSource( +@@ -1060,6 +1297,20 @@ function markActionAnchorSource( if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); } @@ -9122,7 +9271,7 @@ index 11d57d1..3a4e04b 100644 function createAccessory( context: RenderContext, rowKey: string, -@@ -1080,6 +1330,18 @@ function createAccessory( +@@ -1080,6 +1331,18 @@ function createAccessory( accessory.tintColor ); markActionAnchorSource(element, 'trailingAccessory', slot); @@ -9141,7 +9290,7 @@ index 11d57d1..3a4e04b 100644 return element; } if (accessory.kind === 'spinner') { -@@ -1099,6 +1361,7 @@ function createAccessory( +@@ -1099,6 +1362,7 @@ function createAccessory( switch (accessory.kind) { case 'value': element.textContent = accessory.text; @@ -9149,7 +9298,7 @@ index 11d57d1..3a4e04b 100644 if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); break; -@@ -1157,6 +1420,13 @@ function appendAccessories( +@@ -1157,6 +1421,13 @@ function appendAccessories( 'span', 'ok-native-list-accessories' ); @@ -9163,7 +9312,7 @@ index 11d57d1..3a4e04b 100644 accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot)) ); -@@ -1234,7 +1504,59 @@ function createSectionHeader( +@@ -1234,7 +1505,59 @@ function createSectionHeader( markActionAnchorSource(titleIcon, 'leadingAction'); body.appendChild(titleIcon); } @@ -9224,7 +9373,7 @@ index 11d57d1..3a4e04b 100644 if (row.value) { const value = createElement( context.document, -@@ -1244,6 +1566,15 @@ function createSectionHeader( +@@ -1244,6 +1567,15 @@ function createSectionHeader( : 'ok-native-list-value ok-native-list-section-value', row.value ); @@ -9240,7 +9389,7 @@ index 11d57d1..3a4e04b 100644 if (row.valueActionKey) { value.setAttribute('type', 'button'); setData(value, 'nativeListAction', row.valueActionKey); -@@ -1292,6 +1623,7 @@ function createActionRow( +@@ -1292,6 +1624,7 @@ function createActionRow( row.title ); setData(title, 'tone', row.tone); @@ -9248,7 +9397,7 @@ index 11d57d1..3a4e04b 100644 body.appendChild(title); if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); -@@ -1309,6 +1641,13 @@ function createSystemRow( +@@ -1309,6 +1642,13 @@ function createSystemRow( 'ok-native-list-row ok-native-list-system' ); setData(body, 'variant', row.variant); @@ -9262,7 +9411,7 @@ index 11d57d1..3a4e04b 100644 if (row.variant === 'loading') body.appendChild( createElement(context.document, 'span', 'ok-native-list-spinner') -@@ -1703,6 +2042,11 @@ function createIdentityActivityOrMessageRow( +@@ -1703,6 +2043,11 @@ function createIdentityActivityOrMessageRow( .filter(Boolean) .join(' ') ); @@ -9274,7 +9423,7 @@ index 11d57d1..3a4e04b 100644 if (row.type === 'identity' && row.leadingAction) { const action = createIconAction( context, -@@ -1714,7 +2058,15 @@ function createIdentityActivityOrMessageRow( +@@ -1714,7 +2059,15 @@ function createIdentityActivityOrMessageRow( markActionAnchorSource(action, 'leadingAction'); body.appendChild(action); } @@ -9291,7 +9440,7 @@ index 11d57d1..3a4e04b 100644 if (visual) body.appendChild(visual); if (row.type === 'activity' && row.secondaryLeading) { const secondVisual = createVisual(context, row.secondaryLeading); -@@ -1737,8 +2089,44 @@ function createIdentityActivityOrMessageRow( +@@ -1737,8 +2090,44 @@ function createIdentityActivityOrMessageRow( subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, @@ -9337,7 +9486,7 @@ index 11d57d1..3a4e04b 100644 if (row.type === 'activity' && row.status) column.appendChild( createElement( -@@ -1814,6 +2202,13 @@ function createIdentityActivityOrMessageRow( +@@ -1814,6 +2203,13 @@ function createIdentityActivityOrMessageRow( return body; } @@ -9351,7 +9500,7 @@ index 11d57d1..3a4e04b 100644 function createWalletGroupRow( context: RenderContext, row: Extract -@@ -1830,11 +2225,18 @@ function createWalletGroupRow( +@@ -1830,11 +2226,18 @@ function createWalletGroupRow( 'ok-native-list-wallet-member' ); setData(memberElement, 'nativeListGroupMemberKey', member.key); @@ -9373,7 +9522,7 @@ index 11d57d1..3a4e04b 100644 body.appendChild(memberElement); }); return body; -@@ -1894,9 +2296,18 @@ export class NativeListWebEngine { +@@ -1894,9 +2297,18 @@ export class NativeListWebEngine { private resizeObserver: ResizeObserver | undefined; private pendingScroll: PendingScroll | undefined; private lastVisibleSignature: string | undefined; @@ -9392,7 +9541,7 @@ index 11d57d1..3a4e04b 100644 private pointerReorder: PointerReorderState | undefined; private keyboardReorder: KeyboardReorderState | undefined; private reorderMoveFrame: number | undefined; -@@ -1919,6 +2330,8 @@ export class NativeListWebEngine { +@@ -1919,6 +2331,8 @@ export class NativeListWebEngine { private lastViewportWidth = -1; private lastViewportHeight = -1; private destroyed = false; @@ -9401,7 +9550,7 @@ index 11d57d1..3a4e04b 100644 constructor( host: HTMLElement, -@@ -2004,6 +2417,9 @@ export class NativeListWebEngine { +@@ -2004,6 +2418,9 @@ export class NativeListWebEngine { passive: true, }); this.root.addEventListener('click', this.handleClick); @@ -9411,7 +9560,7 @@ index 11d57d1..3a4e04b 100644 this.root.addEventListener('keydown', this.handleKeyDown); this.viewport.addEventListener( 'pointerdown', -@@ -2160,7 +2576,7 @@ export class NativeListWebEngine { +@@ -2160,7 +2577,7 @@ export class NativeListWebEngine { const index = resolveLocationIndex(this.snapshot.rows, params); if (index === undefined) { const sectionCount = this.snapshot.rows.filter( @@ -9420,7 +9569,7 @@ index 11d57d1..3a4e04b 100644 ).length; this.emitScrollFailure( params.itemIndex, -@@ -2219,6 +2635,8 @@ export class NativeListWebEngine { +@@ -2219,6 +2636,8 @@ export class NativeListWebEngine { ); this.viewport.removeEventListener('scroll', this.handleScroll); this.root.removeEventListener('click', this.handleClick); @@ -9429,7 +9578,7 @@ index 11d57d1..3a4e04b 100644 this.root.removeEventListener('keydown', this.handleKeyDown); this.cancelPointerReorder(true); this.viewport.removeEventListener( -@@ -2251,25 +2669,29 @@ export class NativeListWebEngine { +@@ -2251,25 +2670,29 @@ export class NativeListWebEngine { this.viewport.removeEventListener('pointerup', this.handlePullEnd); this.viewport.removeEventListener('pointercancel', this.handlePullEnd); const host = this.root.parentElement; @@ -9460,7 +9609,7 @@ index 11d57d1..3a4e04b 100644 this.applyTheme(); this.recomputeLayout(); this.renderFooter(); -@@ -2288,6 +2710,11 @@ export class NativeListWebEngine { +@@ -2288,6 +2711,11 @@ export class NativeListWebEngine { '--nl-primary': theme.primaryText, '--nl-secondary': theme.secondaryText, '--nl-disabled': theme.disabledText, @@ -9472,7 +9621,7 @@ index 11d57d1..3a4e04b 100644 '--nl-icon': theme.icon, '--nl-icon-subdued': theme.iconSubdued, '--nl-separator': theme.separator, -@@ -2316,18 +2743,24 @@ export class NativeListWebEngine { +@@ -2316,18 +2744,24 @@ export class NativeListWebEngine { viewportHeight !== this.lastViewportHeight) ) { this.invalidateActionAnchor('layout'); @@ -9498,7 +9647,7 @@ index 11d57d1..3a4e04b 100644 if (previousHorizontal !== this.layout.horizontal) { this.viewport.scrollLeft = 0; this.viewport.scrollTop = 0; -@@ -2336,7 +2769,38 @@ export class NativeListWebEngine { +@@ -2336,7 +2770,38 @@ export class NativeListWebEngine { this.performPendingScroll(); }; @@ -9537,7 +9686,7 @@ index 11d57d1..3a4e04b 100644 const viewportLength = this.viewportLength(); const visible = webLayoutItemsForMount( this.layout, -@@ -2350,6 +2814,7 @@ export class NativeListWebEngine { +@@ -2350,6 +2815,7 @@ export class NativeListWebEngine { if (!desired.has(index)) { this.invalidateActionAnchorForElement(element); this.mounted.delete(index); @@ -9545,7 +9694,7 @@ index 11d57d1..3a4e04b 100644 element.remove(); this.pool.push(element); } -@@ -2377,6 +2842,17 @@ export class NativeListWebEngine { +@@ -2377,6 +2843,17 @@ export class NativeListWebEngine { element.dataset.renderSignature = signature; } }); @@ -9563,7 +9712,7 @@ index 11d57d1..3a4e04b 100644 this.updateVisibleSelection(); this.updateVisibleState(); } -@@ -2395,6 +2871,7 @@ export class NativeListWebEngine { +@@ -2395,6 +2872,7 @@ export class NativeListWebEngine { overlay = false ) { this.invalidateActionAnchorForElement(element); @@ -9571,7 +9720,7 @@ index 11d57d1..3a4e04b 100644 const bindingEpoch = String(++this.bindingEpochCounter); element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' -@@ -2403,6 +2880,9 @@ export class NativeListWebEngine { +@@ -2403,6 +2881,9 @@ export class NativeListWebEngine { setData(element, 'nativeListBindingEpoch', bindingEpoch); setData(element, 'nativeListRowIndex', index); setData(element, 'nativeListDisabled', Boolean(row.disabled)); @@ -9581,7 +9730,7 @@ index 11d57d1..3a4e04b 100644 setData(element, 'nativeListReorderable', this.isReorderable(row)); setData( element, -@@ -2435,12 +2915,48 @@ export class NativeListWebEngine { +@@ -2435,12 +2916,48 @@ export class NativeListWebEngine { selectedKeys: this.selectedKeys, itemIndex: index, }; @@ -9631,7 +9780,7 @@ index 11d57d1..3a4e04b 100644 this.footer.replaceChildren(); if (!row) return; const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -2459,25 +2975,78 @@ export class NativeListWebEngine { +@@ -2459,25 +2976,78 @@ export class NativeListWebEngine { this.footer.appendChild(element); } @@ -9717,7 +9866,7 @@ index 11d57d1..3a4e04b 100644 fragment.appendChild(button); }); this.indexRail.appendChild(fragment); -@@ -2487,7 +3056,9 @@ export class NativeListWebEngine { +@@ -2487,7 +3057,9 @@ export class NativeListWebEngine { private updateVisibleSelection() { const update = (element: HTMLElement, row: RowModel | undefined) => { if (!row) return; @@ -9728,7 +9877,7 @@ index 11d57d1..3a4e04b 100644 setData(element, 'nativeListSelected', selected); element.setAttribute('aria-selected', String(selected)); element -@@ -2548,7 +3119,9 @@ export class NativeListWebEngine { +@@ -2548,7 +3120,9 @@ export class NativeListWebEngine { } this.checkEndReached(last?.index ?? -1); this.updateStickyHeader(first?.index ?? -1); @@ -9739,7 +9888,7 @@ index 11d57d1..3a4e04b 100644 } private updateStickyHeader(firstVisibleIndex: number) { -@@ -2564,7 +3137,7 @@ export class NativeListWebEngine { +@@ -2564,7 +3138,7 @@ export class NativeListWebEngine { let index = -1; for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { const row = this.rows[cursor]; @@ -9748,7 +9897,7 @@ index 11d57d1..3a4e04b 100644 index = cursor; break; } -@@ -2594,7 +3167,7 @@ export class NativeListWebEngine { +@@ -2594,7 +3168,7 @@ export class NativeListWebEngine { for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { const candidate = this.rows[cursor]; if ( @@ -9757,7 +9906,7 @@ index 11d57d1..3a4e04b 100644 candidate.variant !== 'summary' ) { nextIndex = cursor; -@@ -2610,12 +3183,15 @@ export class NativeListWebEngine { +@@ -2610,12 +3184,15 @@ export class NativeListWebEngine { this.updateVisibleSelection(); } @@ -9776,7 +9925,7 @@ index 11d57d1..3a4e04b 100644 row.indexTitle ) activeKey = row.key; -@@ -2793,7 +3369,9 @@ export class NativeListWebEngine { +@@ -2793,7 +3370,9 @@ export class NativeListWebEngine { if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; this.invalidateActionAnchor('rebind'); @@ -9787,7 +9936,7 @@ index 11d57d1..3a4e04b 100644 const token = [ this.actionAnchorInstanceId, this.snapshot.generation, -@@ -2866,7 +3444,9 @@ export class NativeListWebEngine { +@@ -2866,7 +3445,9 @@ export class NativeListWebEngine { rowElement?: HTMLElement, sourceElement = rowElement ) { @@ -9798,7 +9947,7 @@ index 11d57d1..3a4e04b 100644 if ( this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && -@@ -2894,6 +3474,28 @@ export class NativeListWebEngine { +@@ -2894,6 +3475,28 @@ export class NativeListWebEngine { } } @@ -9827,7 +9976,7 @@ index 11d57d1..3a4e04b 100644 private handleClick = (event: Event) => { if (Date.now() < this.suppressClickUntil) { event.preventDefault(); -@@ -2944,6 +3546,12 @@ export class NativeListWebEngine { +@@ -2944,6 +3547,12 @@ export class NativeListWebEngine { }; private handleKeyDown = (event: KeyboardEvent) => { @@ -9840,7 +9989,7 @@ index 11d57d1..3a4e04b 100644 if (event.key === 'Escape') { if (this.pointerReorder?.active) { event.preventDefault(); -@@ -3073,7 +3681,7 @@ export class NativeListWebEngine { +@@ -3073,7 +3682,7 @@ export class NativeListWebEngine { this.frameHandle = this.requestFrame(() => { this.frameHandle = undefined; if (this.virtualizationEnabled) this.renderWindow(); @@ -9849,7 +9998,7 @@ index 11d57d1..3a4e04b 100644 }); } -@@ -3090,13 +3698,27 @@ export class NativeListWebEngine { +@@ -3090,13 +3699,27 @@ export class NativeListWebEngine { else view?.clearTimeout(handle); } @@ -9878,7 +10027,7 @@ index 11d57d1..3a4e04b 100644 this.indexPreview.textContent = title; setData(this.indexPreview, 'visible', true); if (this.previewTimer !== undefined) -@@ -3106,37 +3728,69 @@ export class NativeListWebEngine { +@@ -3106,37 +3729,69 @@ export class NativeListWebEngine { }, 180); } @@ -9963,7 +10112,7 @@ index 11d57d1..3a4e04b 100644 ); }; -@@ -3203,12 +3857,13 @@ export class NativeListWebEngine { +@@ -3203,12 +3858,13 @@ export class NativeListWebEngine { const index = Number(rowElement?.dataset.nativeListRowIndex); const row = this.rows[index]; if (!row || !this.isReorderable(row)) return; @@ -9983,7 +10132,7 @@ index 11d57d1..3a4e04b 100644 const view = this.document.defaultView; const state: PointerReorderState = { -@@ -3225,9 +3880,8 @@ export class NativeListWebEngine { +@@ -3225,9 +3881,8 @@ export class NativeListWebEngine { active: false, }; this.pointerReorder = state; @@ -9995,7 +10144,7 @@ index 11d57d1..3a4e04b 100644 state.longPressTimer = view?.setTimeout( () => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS -@@ -3363,6 +4017,18 @@ export class NativeListWebEngine { +@@ -3363,6 +4018,18 @@ export class NativeListWebEngine { Math.max(0, state.startY - rect.top) ); this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); @@ -10014,7 +10163,7 @@ index 11d57d1..3a4e04b 100644 const sourceRow = state.workingRows[state.currentIndex]; const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) -@@ -3430,6 +4096,7 @@ export class NativeListWebEngine { +@@ -3430,6 +4097,7 @@ export class NativeListWebEngine { private clearReorderPreviewVisual() { this.reorderPreview.hidden = true; From 551eb38fc1ece9a3d8f736c59fbe132881d2d60d Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 08:11:28 +0800 Subject: [PATCH 13/18] chore: upgrade native modules to 3.0.106 and remove patches --- apps/mobile/ios/Podfile.lock | 160 +- apps/mobile/package.json | 76 +- package.json | 4 +- packages/components/package.json | 6 +- packages/kit/package.json | 2 +- ...@onekeyfe+react-native-image+3.0.105.patch | 1731 --- ...yfe+react-native-native-list+3.0.105.patch | 10173 ---------------- yarn.lock | 394 +- 8 files changed, 321 insertions(+), 12225 deletions(-) delete mode 100644 patches/@onekeyfe+react-native-image+3.0.105.patch delete mode 100644 patches/@onekeyfe+react-native-native-list+3.0.105.patch diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index f49ebe49e4bf..a35285821ca7 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.105): + - AesCrypto (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -30,7 +30,7 @@ PODS: - GoogleUtilities/Environment (~> 8.0) - GoogleUtilities/UserDefaults (~> 8.0) - PromisesObjC (~> 2.4) - - AsyncStorage (3.0.105): + - AsyncStorage (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -51,7 +51,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.105): + - AutoSizeInput (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -74,7 +74,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.105): + - BackgroundThread (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -121,7 +121,7 @@ PODS: - ExpoModulesCore - SPAlert (~> 4.2) - SPIndicator (~> 1.6) - - ChartWebview (3.0.105): + - ChartWebview (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -145,7 +145,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.105): + - CloudFs (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -166,7 +166,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.105): + - CloudKitModule (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -193,7 +193,7 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core - - DnsLookup (3.0.105): + - DnsLookup (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -395,7 +395,7 @@ PODS: - JPushRN (3.2.1): - React - JuiceboxSdk (0.3.2) - - KeychainModule (3.0.105): + - KeychainModule (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -458,7 +458,7 @@ PODS: - MMKVCore (~> 2.4.0) - MMKVCore (2.4.0) - MultiplatformBleAdapter (0.2.0) - - NetworkInfo (3.0.105): + - NetworkInfo (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -525,7 +525,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.105): + - OneKeyImage (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -550,12 +550,12 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.105) + - Skeleton (= 3.0.106) - Yoga - - OneKeyTextInput (3.0.105): + - OneKeyTextInput (3.0.106): - React-Core - OpenSSL-Universal (3.6.2000) - - Pbkdf2 (3.0.105): + - Pbkdf2 (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -576,7 +576,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.105): + - PerpDepthBar (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -599,7 +599,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.105): + - Ping (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2116,10 +2116,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list (3.0.105): + - react-native-native-list (3.0.106): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.105) + - OneKeyImage (= 3.0.106) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -2161,7 +2161,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (3.0.105): + - react-native-pager-view (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2335,7 +2335,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view (3.0.105): + - react-native-tab-view (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2346,7 +2346,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.105) + - react-native-tab-view/common (= 3.0.106) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2357,7 +2357,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view/common (3.0.105): + - react-native-tab-view/common (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2788,7 +2788,7 @@ PODS: - React-perflogger (= 0.86.2) - React-utils (= 0.86.2) - ReactNativeDependencies - - ReactNativeAppUpdate (3.0.105): + - ReactNativeAppUpdate (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -2812,7 +2812,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.105): + - ReactNativeBundleCrypto (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -2836,7 +2836,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.105): + - ReactNativeBundleUpdate (3.0.106): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2885,7 +2885,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.105): + - ReactNativeCheckBiometricAuthChanged (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -2910,7 +2910,7 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.105): + - ReactNativeDeviceUtils (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -2955,7 +2955,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeGetRandomValues (3.0.105): + - ReactNativeGetRandomValues (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -2979,7 +2979,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.105): + - ReactNativeLiteCard (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3001,7 +3001,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.105): + - ReactNativeNativeLogger (3.0.106): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -3025,7 +3025,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeNetworkThrottle (3.0.105): + - ReactNativeNetworkThrottle (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3048,7 +3048,7 @@ PODS: - Yoga - ReactNativePasskeys (0.3.3): - ExpoModulesCore - - ReactNativePerfMemory (3.0.105): + - ReactNativePerfMemory (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3072,7 +3072,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.105): + - ReactNativePerfStats (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3096,7 +3096,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.105): + - ReactNativeRangeDownloader (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3121,7 +3121,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.105): + - ReactNativeSplashScreen (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3167,7 +3167,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeZipArchive (3.0.105): + - ReactNativeZipArchive (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3650,7 +3650,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.105): + - ScrollGuard (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3681,7 +3681,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - SegmentSlider (3.0.105): + - SegmentSlider (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3707,7 +3707,7 @@ PODS: - Sentry (9.5.1): - Sentry/Core (= 9.5.1) - Sentry/Core (9.5.1) - - Skeleton (3.0.105): + - Skeleton (3.0.106): - hermes-engine - NitroModules - RCTRequired @@ -3730,7 +3730,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.105): + - SniConnect (3.0.106): - EMASCurl (= 1.5.5) - hermes-engine - RCTRequired @@ -3755,7 +3755,7 @@ PODS: - Yoga - SPAlert (4.2.0) - SPIndicator (1.6.4) - - SplitBundleLoader (3.0.105): + - SplitBundleLoader (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3778,7 +3778,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.105): + - TcpSocket (3.0.106): - hermes-engine - RCTRequired - RCTTypeSafety @@ -4427,19 +4427,19 @@ CHECKOUT OPTIONS: :git: https://github.com/OneKeyHQ/app-modules.git SPEC CHECKSUMS: - AesCrypto: 96f93cf21b9936c545a596390ea62c14260cbe86 + AesCrypto: 7909e4a082134b01fb2d64e3249f78e4911f2f1e AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - AsyncStorage: bfcce781d84cd2ab7759a86c9ef43fa5a62e479b - AutoSizeInput: ea141bb75f04cca90b633d728fddbe08498d21ca - BackgroundThread: fd3db8963f4f5b28b44b88c33f3c116d0b0270c0 + AsyncStorage: 852df8e7bad2af341ced8208a0b0484e49fc6105 + AutoSizeInput: c8ab18e22a148c270b59a9342b32b11e3fd2d15b + BackgroundThread: c0a4f0ea1613ad742f23a90d57b5a44130289544 BleUtils: 00f63c6bf8115301f8f47774b6e978f7174b4bc1 Burnt: e3a3397e26172fca31a59bb27421475a58068836 - ChartWebview: c2bfef7a1f4ba735fe2ffb7069767e3046c4f5bc - CloudFs: ec232bbfb3c3ac3cb73a6fb6ab5a94a56661c4c0 - CloudKitModule: 3599939a447731242a6e48e4cdd010b4afef52b0 + ChartWebview: 4826820cef1a13c401e3571a18f2c5e81a21af67 + CloudFs: 7d02ff7cc5690c4bec2e411675971d1eee7ff91c + CloudKitModule: 9f62ca7c1682ae1ee7d83d5b2d7bd1798b581e32 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 2babc8764c4b35dc02a83d4538eeb0c6bb9d6ab3 + DnsLookup: 04af040f1ac40d201f4bef8fbb6266843f638879 EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f EXApplication: bbd517d50878ca1d121fb3843392beb210181e28 EXConstants: e283cc77f61bddf6537e8ec1d351985e2394168a @@ -4488,22 +4488,22 @@ SPEC CHECKSUMS: JCoreRN: d985de509185b381c177fde4bb6bfc686089fdbd JPushRN: 807bc962b2f25860e5c0caa5fcb7b4910572b890 JuiceboxSdk: b2222491ce92b263694c217ea202a54b66c5ec5f - KeychainModule: 9aab964af35ed134ed1d0270413420ed37d1ca08 + KeychainModule: 40c8cbb7bf307f0dab48dabfee0a87a299ca6fd7 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3 lottie-react-native: 3234694e4dbd43853060e5eba15a35c323ed29ee MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa MultiplatformBleAdapter: b1fddd0d499b96b607e00f0faa8e60648343dc1d - NetworkInfo: 3a615c690b29bdb27f149d1d4219a260dc517c35 + NetworkInfo: 7524267e5076b1972ae36d5f39c3d7517e863d37 NitroMmkv: 437a1303946283cfefe556d74e989228874aa8c0 NitroModules: d5be9f4559fc5178388ccf3ba2e152230b766d67 - OneKeyImage: 4f8b337bd1ec92df01eaa9af755016d9a90c9e8f - OneKeyTextInput: 2a260c9713ff205d80a3f644f917e81da479f258 + OneKeyImage: f2c8ba7e13442cdd583360dadae3890e41390d42 + OneKeyTextInput: 679bd3e51ff58a227e1043e7e6899eda50cd7b58 OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e - Pbkdf2: f733bdc7b1ea667d48ddb816e5793884d2fdeb5a - PerpDepthBar: cba3101c3424abfdc7ce423d29751212d51fb1a3 - Ping: 6b78c45c9af12ed40dfc7512b3758c0b7bce9c47 + Pbkdf2: 5f11d2a96171c0233f2ebcc24a6329a9bcdabc96 + PerpDepthBar: 620f028a5794f6021344f833ef24284c4fe72d6b + Ping: 60410bd86683b4381bd6607ef8fd270d2a4dba05 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PurchasesHybridCommon: 097af88db3cb39b415bef2f3140e6b3324899756 QuickCrypto: 459dd2f5b5c33f115238c08a9d75ace23bdf5601 @@ -4548,14 +4548,14 @@ SPEC CHECKSUMS: react-native-compat: 27466917b93b2da7c1eed1022227dba8c69d4ef5 react-native-document-picker: 598c549d1cbcc8c1f73abfbb5369d2faa10da242 react-native-keyboard-controller: 2ef1abca0f1b5ffc41c282c9e5f9c5576f83a53d - react-native-native-list: 8122bf3096a81da3ab32ce910e43b1f082d95090 + react-native-native-list: faa479ef001f5644c1b2a0a49176d4d5e96a668b react-native-netinfo: ac0848a4773ef5bd015399444409b5ea9ed75fa3 - react-native-pager-view: 24a99af5bf689345b48ab96321140cc530dd1518 + react-native-pager-view: 1868a50140dd388bf99383d9d54193e414b9be9f react-native-quick-base64: 3bc58c20e3621427e066fdcfe38b7ed452702bdb react-native-safe-area-context: 866dbfc3292621c18abe9857bda29485bf20607a react-native-skia: 65a19b18cfddadd1ba5f267216763a0b8467f2ec react-native-slider: 8a70a9fbb0253489730cc274171dc78b44600b46 - react-native-tab-view: 0b585b213696b54da697b23b9fcfa828065115dd + react-native-tab-view: 52652a951cdea8ca2fedcc8822924e4ec68ff95b react-native-view-shot: e31564b1d0c57add676123f74ed1b6e7c7e22851 react-native-webview: d2b92fc3878f206cc5fecd618f4fb5b8d1652bd6 react-native-webview-cleaner: c2d3bbb850105553c845303044234ca82062d592 @@ -4593,25 +4593,25 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 ReactCodegen: 5f06f18a986ad124808f4bdae7d4051fe157a5b1 ReactCommon: df0928b8d2064de53c42645dcf684c6e7bdea48c - ReactNativeAppUpdate: 08c35b67c23d95f163ac40bdfd3016f99f1c439f - ReactNativeBundleCrypto: 1dacbe21497cf04eb03770874421878aba5254cf - ReactNativeBundleUpdate: 05daf070c6b1463c7147c79aa5b522d4b12b3eaf + ReactNativeAppUpdate: a6fadb2428d6b2b0fa6af5f1a749578a185877a7 + ReactNativeBundleCrypto: b58243c919f2afeab869538e79b20d81a0d532f0 + ReactNativeBundleUpdate: 2b1680db9671f69d489e3feaba99d5e379dc989a ReactNativeCameraKit: 21f6c85397cfcb494dfa453a6fb4c9933a7c1bef - ReactNativeCheckBiometricAuthChanged: cc4a8d33c31ad17dc6e9634f8bbe94e6e131a3e8 + ReactNativeCheckBiometricAuthChanged: a8c1bf582bc3bfde5efb154db20d632168b18a77 ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: 3d0f37636218246dbcf89f77964736bbff9837e7 + ReactNativeDeviceUtils: 30ee562d8198bd7586b445364b4640f878200acb ReactNativeFs: 4704ddcd290a4f26195f7236c7237611360df74b - ReactNativeGetRandomValues: 4c152576be63fa984767282431534253272c0f05 - ReactNativeLiteCard: 47a21163d8b38a09e6dacc7c32949439872ac58f - ReactNativeNativeLogger: c4f763f64985fec01cdbdbc30533964a1044e538 - ReactNativeNetworkThrottle: 9f4166dd273eee7ea5dbbd28d4bc07bdfc98e58e + ReactNativeGetRandomValues: 864eae0a5da79341367b5f401b7694a4f95e2101 + ReactNativeLiteCard: 7a1bca1bad58cc431f26ce9c1cd2eab245cdb374 + ReactNativeNativeLogger: 493986816ca6a5ad1832c6bc7936403b9bc79340 + ReactNativeNetworkThrottle: dbebabf25d19eaee78db3d3925f53621c0ea2621 ReactNativePasskeys: 9e950e8cbf0e7d6aad9df4dcd21cee0efeb4e5cd - ReactNativePerfMemory: 5589376aad5526ca4d333b958b4788d45154cf78 - ReactNativePerfStats: d2ff1603689916404510523dacc8d8976719e806 - ReactNativeRangeDownloader: 871ee72e73c87f2234d467877ad16e93b91acfa3 - ReactNativeSplashScreen: 6ef71692a8821299654d59efc23c8c7a6ad642a1 + ReactNativePerfMemory: 53ae7e4ed47e291bc02e865ee14b5c7c5bdf7bb1 + ReactNativePerfStats: 432b69f23e6dc0bf6e299724292937b3c23b0829 + ReactNativeRangeDownloader: c813e2a6ee477bc1e916f2024051e53fc524f45d + ReactNativeSplashScreen: 4d73adce994f5c27d178ca0cd0169b5f7178cfbb ReactNativeVideo: 36938964abd84cb1355c5844d89feb501f3fbf93 - ReactNativeZipArchive: 329dc5d5a9a9d79faef5e716f873b7d220ca7f7a + ReactNativeZipArchive: 464042ea7d079449571716072bd055b9a3b6e672 RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1 RevenueCat: 72d1e14966339bf38c41d9b2841ef64c8fd79646 RNCMaskedView: a543b7a36c195a519d1bd7ef0291f1cbc6a84a0d @@ -4628,19 +4628,19 @@ SPEC CHECKSUMS: RNSentry: f9219292e372d83cccb99b7f5fa7a9d06c204adf RNSVG: 26c9fd280121dbb1639fae26d476a54c8016ed81 RNWorklets: 4405c9ce44ccd5af4e389229bbdc5e0314f2eee1 - ScrollGuard: ad990e799199769320bef2a388702c091e0fd509 + ScrollGuard: 971d2a7589962614a210ca815e580a0b2338bb84 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 12eadfe0873789868c3616af532d0ca0a2e942ad + SegmentSlider: 6f501d7103882d1ebd9465ce412c39a286aeb60d Sentry: 7475eb7bf6a41d7505f46341706015ad2d1766b9 - Skeleton: fcaa7565b56eb8448f2f5f9d21ff4714219421d2 - SniConnect: 54c48aa876fc810899704f0b5a0fc9fc0bcf3e45 + Skeleton: a66ac67dc8e30d32a5b82906601f6947f45b628f + SniConnect: 89b01ad65feab2929488c1100e1cf2998f669929 SPAlert: 735da1f16a887e294719217572ce1f936d8c8782 SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f - SplitBundleLoader: dd00e84d9ba93268331bbad26472920b99163ae2 + SplitBundleLoader: eaacec9de73fc174ff3a5b244349261e46a0ee54 SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: 324f84fd12979f3e6bb77a4c09c8b9d3edcc19e4 + TcpSocket: d38cd72416df7b199d746e6851d643659aeecb86 TOCropViewController: 5fa42dd0ac8c32790c06fc6057831d17b3b16857 Yoga: 0b38f02674a32b9a15de1f41f8680ffa06eaf8ef ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7e1700168e83..9735fdaf90a7 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -77,39 +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.105", - "@onekeyfe/react-native-auto-size-input": "3.0.105", - "@onekeyfe/react-native-background-thread": "3.0.105", + "@onekeyfe/react-native-app-update": "3.0.106", + "@onekeyfe/react-native-auto-size-input": "3.0.106", + "@onekeyfe/react-native-background-thread": "3.0.106", "@onekeyfe/react-native-ble-utils": "0.1.6", - "@onekeyfe/react-native-bundle-crypto": "3.0.105", - "@onekeyfe/react-native-bundle-update": "3.0.105", - "@onekeyfe/react-native-chart-webview": "3.0.105", - "@onekeyfe/react-native-check-biometric-auth-changed": "3.0.105", - "@onekeyfe/react-native-cloud-kit-module": "3.0.105", - "@onekeyfe/react-native-device-utils": "3.0.105", - "@onekeyfe/react-native-image": "3.0.105", - "@onekeyfe/react-native-keychain-module": "3.0.105", - "@onekeyfe/react-native-lite-card": "3.0.105", - "@onekeyfe/react-native-native-list": "3.0.105", - "@onekeyfe/react-native-native-logger": "3.0.105", - "@onekeyfe/react-native-network-throttle": "3.0.105", - "@onekeyfe/react-native-perf-memory": "3.0.105", - "@onekeyfe/react-native-perf-stats": "3.0.105", - "@onekeyfe/react-native-perp-depth-bar": "3.0.105", - "@onekeyfe/react-native-range-downloader": "3.0.105", - "@onekeyfe/react-native-scroll-guard": "3.0.105", - "@onekeyfe/react-native-segment-slider": "3.0.105", - "@onekeyfe/react-native-skeleton": "3.0.105", - "@onekeyfe/react-native-sni-connect": "3.0.105", - "@onekeyfe/react-native-splash-screen": "3.0.105", - "@onekeyfe/react-native-split-bundle-loader": "3.0.105", - "@onekeyfe/react-native-tab-view": "3.0.105", - "@onekeyfe/react-native-text-input": "3.0.105", + "@onekeyfe/react-native-bundle-crypto": "3.0.106", + "@onekeyfe/react-native-bundle-update": "3.0.106", + "@onekeyfe/react-native-chart-webview": "3.0.106", + "@onekeyfe/react-native-check-biometric-auth-changed": "3.0.106", + "@onekeyfe/react-native-cloud-kit-module": "3.0.106", + "@onekeyfe/react-native-device-utils": "3.0.106", + "@onekeyfe/react-native-image": "3.0.106", + "@onekeyfe/react-native-keychain-module": "3.0.106", + "@onekeyfe/react-native-lite-card": "3.0.106", + "@onekeyfe/react-native-native-list": "3.0.106", + "@onekeyfe/react-native-native-logger": "3.0.106", + "@onekeyfe/react-native-network-throttle": "3.0.106", + "@onekeyfe/react-native-perf-memory": "3.0.106", + "@onekeyfe/react-native-perf-stats": "3.0.106", + "@onekeyfe/react-native-perp-depth-bar": "3.0.106", + "@onekeyfe/react-native-range-downloader": "3.0.106", + "@onekeyfe/react-native-scroll-guard": "3.0.106", + "@onekeyfe/react-native-segment-slider": "3.0.106", + "@onekeyfe/react-native-skeleton": "3.0.106", + "@onekeyfe/react-native-sni-connect": "3.0.106", + "@onekeyfe/react-native-splash-screen": "3.0.106", + "@onekeyfe/react-native-split-bundle-loader": "3.0.106", + "@onekeyfe/react-native-tab-view": "3.0.106", + "@onekeyfe/react-native-text-input": "3.0.106", "@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.105", + "@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.106", "@react-native-community/netinfo": "12.0.1", "@react-native-community/slider": "5.2.0", "@react-native-documents/picker": "^12.0.1", @@ -158,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.105", + "react-native-aes-crypto": "npm:@onekeyfe/react-native-aes-crypto@3.0.106", "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.105", + "react-native-cloud-fs": "npm:@onekeyfe/react-native-cloud-fs@3.0.106", "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.105", - "react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.105", + "react-native-dns-lookup": "npm:@onekeyfe/react-native-dns-lookup@3.0.106", + "react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.106", "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.105", + "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.106", "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.105", + "react-native-network-info": "npm:@onekeyfe/react-native-network-info@3.0.106", "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.105", + "react-native-pager-view": "npm:@onekeyfe/react-native-pager-view@3.0.106", "react-native-passkeys": "0.3.3", "react-native-permissions": "5.4.4", - "react-native-ping": "npm:@onekeyfe/react-native-ping@3.0.105", + "react-native-ping": "npm:@onekeyfe/react-native-ping@3.0.106", "react-native-purchases": "10.4.3", "react-native-qrcode-styled": "0.4.0", "react-native-quick-base64": "^3.0.0", @@ -194,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.105", + "react-native-tcp-socket": "npm:@onekeyfe/react-native-tcp-socket@3.0.106", "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.105", + "react-native-zip-archive": "npm:@onekeyfe/react-native-zip-archive@3.0.106", "readable-stream": "^3.6.0", "realm": "20.2.0", "realm-flipper-plugin-device": "^1.1.0", diff --git a/package.json b/package.json index 87f8ece076ce..d82318e8c8bf 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.105", + "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.106", "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.105", + "react-native-get-random-values": "npm:@onekeyfe/react-native-get-random-values@3.0.106", "@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 7f621e5f4417..2edeed9c00e2 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -7,9 +7,9 @@ "**/*.css" ], "dependencies": { - "@onekeyfe/react-native-scroll-guard": "3.0.105", - "@onekeyfe/react-native-segment-slider": "3.0.105", - "@onekeyfe/react-native-tab-view": "3.0.105", + "@onekeyfe/react-native-scroll-guard": "3.0.106", + "@onekeyfe/react-native-segment-slider": "3.0.106", + "@onekeyfe/react-native-tab-view": "3.0.106", "@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/kit/package.json b/packages/kit/package.json index 5e085ef71284..c952e88a8b4e 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -4,7 +4,7 @@ "private": true, "main": "src/index.tsx", "dependencies": { - "@onekeyfe/react-native-native-list": "3.0.105", + "@onekeyfe/react-native-native-list": "3.0.106", "@onekeyhq/components": "*", "@types/url-parse": "^1.4.8", "date-fns": "2.30.0", diff --git a/patches/@onekeyfe+react-native-image+3.0.105.patch b/patches/@onekeyfe+react-native-image+3.0.105.patch deleted file mode 100644 index d455c7aaf6db..000000000000 --- a/patches/@onekeyfe+react-native-image+3.0.105.patch +++ /dev/null @@ -1,1731 +0,0 @@ -diff --git a/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec b/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec -index a8befa8..50b91c3 100644 ---- a/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec -+++ b/node_modules/@onekeyfe/react-native-image/OneKeyImage.podspec -@@ -12,8 +12,9 @@ Pod::Spec.new do |s| - s.platforms = { :ios => min_ios_version_supported } - s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" } - -- s.source_files = ["ios/**/*.{swift,m,mm}", "cpp/**/*.{hpp,cpp}"] -+ s.source_files = ["ios/**/*.{h,swift,m,mm}", "cpp/**/*.{hpp,cpp}"] - s.exclude_files = "ios/tests/**/*" -+ s.public_header_files = ["ios/OneKeyImageCoderBridge.h"] - - s.dependency "React-jsi" - s.dependency "React-callinvoker" -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt -new file mode 100644 -index 0000000..88f0152 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatar.kt -@@ -0,0 +1,264 @@ -+// OneKey patch: Render the versioned local avatar URI without a JS PNG payload. -+// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64 -+// Permission is hereby granted, free of charge, to any person obtaining a copy -+// of this software and associated documentation files (the "Software"), to deal -+// in the Software without restriction, including without limitation the rights -+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -+// copies of the Software, and to permit persons to whom the Software is -+// furnished to do so, subject to the following conditions: -+// The above copyright notice and this permission notice shall be included in -+// all copies or substantial portions of the Software. -+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -+// THE SOFTWARE. -+ -+package com.margelo.nitro.onekeyimage -+ -+import java.io.ByteArrayOutputStream -+import java.io.DataOutputStream -+import java.nio.ByteBuffer -+import java.nio.charset.CodingErrorAction -+import java.util.concurrent.CancellationException -+import java.util.concurrent.FutureTask -+import java.util.concurrent.atomic.AtomicBoolean -+import java.util.zip.CRC32 -+import java.util.zip.Deflater -+import java.util.zip.DeflaterOutputStream -+import java.util.zip.DataFormatException -+import java.util.zip.Inflater -+import kotlin.math.floor -+ -+internal data class OneKeyBlockieAvatarModel(val uri: String) -+ -+internal object OneKeyBlockieAvatar { -+ const val URI_PREFIX = "onekey-avatar://blockie/v1/" -+ const val SIZE = 128 -+ const val MAX_PNG_BYTES = SIZE * (SIZE / 4 + 1) + 128 -+ -+ fun isAvatarUri(uri: String): Boolean = uri.startsWith("onekey-avatar:") -+ -+ fun decodeSeed(uri: String, isCancelled: () -> Boolean = { false }): String { -+ require(uri.startsWith(URI_PREFIX)) { "Unsupported avatar URI version" } -+ val encoded = uri.substring(URI_PREFIX.length) -+ require(encoded.isNotEmpty()) { "Avatar seed is empty" } -+ val bytes = ByteArrayOutputStream(encoded.length) -+ var index = 0 -+ while (index < encoded.length) { -+ if (index % 256 == 0 && isCancelled()) throw CancellationException("Avatar generation cancelled") -+ val char = encoded[index] -+ if (char == '%') { -+ require(index + 2 < encoded.length) { "Invalid avatar percent encoding" } -+ val high = encoded[index + 1].digitToIntOrNull(16) -+ val low = encoded[index + 2].digitToIntOrNull(16) -+ require(high != null && low != null) { "Invalid avatar percent encoding" } -+ bytes.write((high shl 4) or low) -+ index += 3 -+ } else { -+ require(char in 'a'..'z' || char in 'A'..'Z' || char in '0'..'9' || char in "-_.!~*'()") { -+ "Invalid avatar URI component" -+ } -+ bytes.write(char.code) -+ index += 1 -+ } -+ } -+ // The app already applies JavaScript lowercase; JVM Unicode casing differs. -+ return Charsets.UTF_8.newDecoder() -+ .onMalformedInput(CodingErrorAction.REPORT) -+ .onUnmappableCharacter(CodingErrorAction.REPORT) -+ .decode(ByteBuffer.wrap(bytes.toByteArray())) -+ .toString() -+ } -+ -+ // Only our fixed v1 encoder format is accepted, never arbitrary user PNG data. -+ fun isValidPng(bytes: ByteArray, isCancelled: () -> Boolean = { false }): Boolean { -+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled") -+ if (bytes.size !in 93..MAX_PNG_BYTES) return false -+ val input = ByteBuffer.wrap(bytes) -+ if (input.long != 0x89504e470d0a1a0aUL.toLong()) return false -+ fun chunk(expectedType: String, expectedLength: Int? = null): ByteArray? { -+ if (input.remaining() < 12) return null -+ val length = input.int -+ if (length < 0 || length > input.remaining() - 8) return null -+ if (expectedLength != null && length != expectedLength) return null -+ val type = ByteArray(4).also(input::get) -+ if (!type.contentEquals(expectedType.toByteArray(Charsets.US_ASCII))) return null -+ val data = ByteArray(length).also(input::get) -+ val crc = CRC32().apply { update(type); update(data) } -+ if (input.int != crc.value.toInt()) return null -+ return data -+ } -+ val header = chunk("IHDR", 13) ?: return false -+ if (!header.contentEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128, 2, 3, 0, 0, 0))) return false -+ chunk("PLTE", 9) ?: return false -+ if (chunk("tRNS", 3)?.contentEquals(byteArrayOf(-1, -1, -1)) != true) return false -+ val compressed = chunk("IDAT") ?: return false -+ chunk("IEND", 0) ?: return false -+ if (input.hasRemaining()) return false -+ -+ val expectedBytes = SIZE * (SIZE / 4 + 1) -+ val pixels = ByteArray(expectedBytes + 1) -+ val inflater = Inflater() -+ var count = 0 -+ try { -+ inflater.setInput(compressed) -+ while (!inflater.finished() && count <= expectedBytes) { -+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled") -+ val decoded = inflater.inflate(pixels, count, pixels.size - count) -+ if (decoded == 0) return false -+ count += decoded -+ } -+ if (!inflater.finished() || inflater.remaining != 0 || count != expectedBytes) return false -+ } catch (_: DataFormatException) { -+ return false -+ } finally { -+ inflater.end() -+ } -+ repeat(SIZE) { row -> -+ if (isCancelled()) throw CancellationException("Avatar cache read cancelled") -+ val offset = row * 33 -+ if (pixels[offset] != 0.toByte()) return false -+ for (index in 1..32) { -+ val value = pixels[offset + index].toInt() and 0xff -+ if ( -+ (value and 3) == 3 || ((value shr 2) and 3) == 3 || -+ ((value shr 4) and 3) == 3 || ((value shr 6) and 3) == 3 -+ ) return false -+ } -+ } -+ return true -+ } -+ -+ fun png(uri: String, isCancelled: () -> Boolean = { false }): ByteArray { -+ fun checkCancelled() { -+ if (isCancelled()) throw CancellationException("Avatar generation cancelled") -+ } -+ checkCancelled() -+ val seed = decodeSeed(uri, isCancelled) -+ val state = IntArray(4) -+ seed.forEachIndexed { index, char -> -+ if (index % 256 == 0) checkCancelled() -+ val slot = index % 4 -+ // Kotlin Int overflow and signed shr preserve JavaScript's bitwise PRNG. -+ state[slot] = (state[slot] shl 5) - state[slot] + char.code -+ } -+ fun random(): Double { -+ val t = state[0] xor (state[0] shl 11) -+ state[0] = state[1] -+ state[1] = state[2] -+ state[2] = state[3] -+ state[3] = state[3] xor (state[3] shr 19) xor t xor (t shr 8) -+ return (state[3].toLong() and 0xffffffffL).toDouble() / 2147483648.0 -+ } -+ fun hue(p: Double, q: Double, value: Double): Double { -+ var t = value -+ if (t < 0) t += 1 -+ if (t > 1) t -= 1 -+ return when { -+ t < 1.0 / 6.0 -> p + (q - p) * 6 * t -+ t < 1.0 / 2.0 -> q -+ t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6 -+ else -> p -+ } -+ } -+ fun color(): ByteArray { -+ val h = floor(random() * 360) / 360 -+ val saturation = (random() * 60 + 40) / 100 -+ val lightness = ((random() + random() + random() + random()) * 25) / 100 -+ val q = if (lightness < 0.5) lightness * (1 + saturation) -+ else lightness + saturation - lightness * saturation -+ val p = 2 * lightness - q -+ return doubleArrayOf( -+ hue(p, q, h + 1.0 / 3.0), hue(p, q, h), hue(p, q, h - 1.0 / 3.0), -+ ).map { floor(it * 255 + 0.5).toInt().toByte() }.toByteArray() -+ } -+ val foreground = color() -+ val background = color() -+ val spot = color() -+ val pixels = ByteArray(SIZE * 33) -+ repeat(8) { row -> -+ checkCancelled() -+ val line = ByteArray(33) -+ repeat(4) { column -> -+ val value = floor(random() * 2.3).toInt() -+ val paletteIndex = if (value == 0) 0 else if (value == 1) 1 else 2 -+ val packed = (paletteIndex * 0x55).toByte() -+ line.fill(packed, 1 + column * 4, 1 + (column + 1) * 4) -+ line.fill(packed, 1 + (7 - column) * 4, 1 + (8 - column) * 4) -+ } -+ repeat(16) { line.copyInto(pixels, (row * 16 + it) * 33) } -+ } -+ val compressed = ByteArrayOutputStream() -+ val deflater = Deflater(Deflater.BEST_SPEED) -+ try { -+ DeflaterOutputStream(compressed, deflater).use { it.write(pixels) } -+ } finally { -+ deflater.end() -+ } -+ checkCancelled() -+ val result = ByteArrayOutputStream() -+ DataOutputStream(result).use { output -> -+ output.write(byteArrayOf(137.toByte(), 80, 78, 71, 13, 10, 26, 10)) -+ fun chunk(type: String, data: ByteArray) { -+ val typeBytes = type.toByteArray(Charsets.US_ASCII) -+ output.writeInt(data.size) -+ output.write(typeBytes) -+ output.write(data) -+ val crc = CRC32().apply { update(typeBytes); update(data) } -+ output.writeInt(crc.value.toInt()) -+ } -+ chunk("IHDR", byteArrayOf(0, 0, 0, 128.toByte(), 0, 0, 0, 128.toByte(), 2, 3, 0, 0, 0)) -+ chunk("PLTE", background + foreground + spot) -+ chunk("tRNS", byteArrayOf(-1, -1, -1)) -+ chunk("IDAT", compressed.toByteArray()) -+ chunk("IEND", byteArrayOf()) -+ } -+ return result.toByteArray() -+ } -+} -+ -+/** Only overlapping source fetches are retained; Glide owns all lasting caches. */ -+internal class OneKeyAvatarInFlight( -+ private val generate: (String, () -> Boolean) -> ByteArray, -+) { -+ internal class Work(uri: String, generate: (String, () -> Boolean) -> ByteArray) { -+ val cancelled = AtomicBoolean(false) -+ val task = FutureTask { generate(uri, cancelled::get) } -+ var references = 0 -+ } -+ -+ private val pending = mutableMapOf() -+ -+ internal inner class Lease(private val uri: String, internal val work: Work) { -+ private val released = AtomicBoolean(false) -+ -+ fun bytes(): ByteArray { -+ work.task.run() -+ return work.task.get() -+ } -+ -+ fun release() { -+ if (!released.compareAndSet(false, true)) return -+ synchronized(pending) { -+ work.references -= 1 -+ if (work.references == 0) { -+ if (pending[uri] === work) pending.remove(uri) -+ if (!work.task.isDone) { -+ work.cancelled.set(true) -+ work.task.cancel(false) -+ } -+ } -+ } -+ } -+ } -+ -+ fun acquire(uri: String): Lease = synchronized(pending) { -+ val work = pending.getOrPut(uri) { Work(uri, generate) } -+ work.references += 1 -+ Lease(uri, work) -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt -new file mode 100644 -index 0000000..9c8e11f ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoader.kt -@@ -0,0 +1,173 @@ -+package com.margelo.nitro.onekeyimage -+ -+import com.bumptech.glide.Priority -+import com.bumptech.glide.load.DataSource -+import com.bumptech.glide.load.Option -+import com.bumptech.glide.load.Options -+import com.bumptech.glide.load.data.DataFetcher -+import com.bumptech.glide.load.engine.DiskCacheStrategy -+import com.bumptech.glide.load.model.ModelLoader -+import com.bumptech.glide.load.model.ModelLoaderFactory -+import com.bumptech.glide.load.model.MultiModelLoaderFactory -+import com.bumptech.glide.signature.ObjectKey -+import com.bumptech.glide.request.RequestOptions -+import java.io.ByteArrayInputStream -+import java.io.ByteArrayOutputStream -+import java.io.File -+import java.io.IOException -+import java.nio.ByteBuffer -+import java.util.concurrent.ExecutionException -+import java.util.concurrent.CancellationException -+ -+// Only source PNGs are persisted; transformed resources can have other dimensions. -+internal fun oneKeyImageMemoryDiskStrategy(uri: String): DiskCacheStrategy = -+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) DiskCacheStrategy.DATA else DiskCacheStrategy.AUTOMATIC -+ -+internal val oneKeyAvatarCacheFileOption: Option = -+ Option.memory("onekey-image.blockie-source-cache-v1", false) -+ -+internal fun RequestOptions.withOneKeyAvatarCache(uri: String): RequestOptions = -+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) set(oneKeyAvatarCacheFileOption, true) else this -+ -+internal class OneKeyAvatarCacheFileLoaderFactory : ModelLoaderFactory { -+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = -+ OneKeyAvatarCacheFileLoader() -+ -+ override fun teardown() = Unit -+} -+ -+private class OneKeyAvatarCacheFileLoader : ModelLoader { -+ override fun handles(model: File): Boolean = true -+ -+ override fun buildLoadData( -+ model: File, -+ width: Int, -+ height: Int, -+ options: Options, -+ ): ModelLoader.LoadData? = -+ if (options.get(oneKeyAvatarCacheFileOption) == true) { -+ ModelLoader.LoadData(ObjectKey(model), OneKeyAvatarCacheFileFetcher(model)) -+ } else null -+} -+ -+// Serialize validation/removal so two failed reads cannot delete a newly repaired file. -+private val avatarCacheFileLock = Any() -+ -+private class OneKeyAvatarCacheFileFetcher(private val file: File) : DataFetcher { -+ @Volatile -+ private var cancelled = false -+ -+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { -+ val bytes = try { -+ synchronized(avatarCacheFileLock) { -+ checkCancelled() -+ val bytes = readBounded() -+ if (bytes == null || !OneKeyBlockieAvatar.isValidPng(bytes) { cancelled }) { -+ checkCancelled() -+ if (file.exists() && !file.delete()) throw IOException("Cannot remove invalid avatar cache entry") -+ // Glide's journal sees the missing clean file; SOURCE can write it again. -+ throw IOException("Invalid avatar cache entry removed") -+ } -+ bytes -+ } -+ } catch (error: Exception) { -+ if (!cancelled) callback.onLoadFailed(error) -+ return -+ } -+ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(bytes)) -+ } -+ -+ private fun readBounded(): ByteArray? { -+ if (file.length() > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null -+ return file.inputStream().use { input -> -+ val output = ByteArrayOutputStream() -+ val buffer = ByteArray(1024) -+ while (true) { -+ checkCancelled() -+ val count = input.read(buffer) -+ if (count < 0) break -+ if (output.size() + count > OneKeyBlockieAvatar.MAX_PNG_BYTES) return null -+ output.write(buffer, 0, count) -+ } -+ output.toByteArray() -+ } -+ } -+ -+ private fun checkCancelled() { -+ if (cancelled) throw CancellationException("Avatar cache read cancelled") -+ } -+ -+ override fun cancel() { cancelled = true } -+ override fun cleanup() = cancel() -+ override fun getDataClass(): Class = ByteBuffer::class.java -+ override fun getDataSource(): DataSource = DataSource.LOCAL -+} -+ -+internal class OneKeyBlockieAvatarLoaderFactory : ModelLoaderFactory { -+ override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader = -+ OneKeyBlockieAvatarLoader() -+ -+ override fun teardown() = Unit -+} -+ -+private val avatarRequests = OneKeyAvatarInFlight { uri, isCancelled -> -+ OneKeyImageSafety.requireEncodedLength(uri.length.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES) -+ val png = OneKeyBlockieAvatar.png(uri, isCancelled) -+ OneKeyImageSafety.requireEncodedLength(png.size.toLong(), OneKeyImageSafety.MAX_DATA_URI_DECODED_BYTES) -+ OneKeyEncodedImageInspector.inspect(ByteArrayInputStream(png)) -+ png -+} -+ -+private class OneKeyBlockieAvatarLoader : ModelLoader { -+ override fun handles(model: OneKeyBlockieAvatarModel): Boolean = true -+ -+ override fun buildLoadData( -+ model: OneKeyBlockieAvatarModel, -+ width: Int, -+ height: Int, -+ options: Options, -+ ): ModelLoader.LoadData = ModelLoader.LoadData( -+ OneKeyImageSafetyVersionedKey(ObjectKey(model)), -+ OneKeyBlockieAvatarFetcher(model.uri), -+ ) -+} -+ -+internal class OneKeyBlockieAvatarFetcher( -+ private val uri: String, -+ private val requests: OneKeyAvatarInFlight = avatarRequests, -+) : DataFetcher { -+ @Volatile -+ private var cancelled = false -+ private var lease: OneKeyAvatarInFlight.Lease? = null -+ -+ override fun loadData(priority: Priority, callback: DataFetcher.DataCallback) { -+ val request = synchronized(this) { -+ if (cancelled) return -+ requests.acquire(uri).also { lease = it } -+ } -+ // Glide invokes loadData on its source executor, never on the UI thread. -+ val png = try { -+ request.bytes() -+ } catch (error: Exception) { -+ val cause = if (error is ExecutionException) error.cause else error -+ if (!cancelled) callback.onLoadFailed(cause as? Exception ?: IOException("Avatar generation failed")) -+ return -+ } -+ // Glide may cancel while holding its EngineJob lock; do not call back under ours. -+ if (!cancelled) callback.onDataReady(ByteBuffer.wrap(png)) -+ } -+ -+ override fun cancel() = release() -+ override fun cleanup() = release() -+ -+ private fun release() { -+ val request = synchronized(this) { -+ cancelled = true -+ lease.also { lease = null } -+ } -+ request?.release() -+ } -+ -+ override fun getDataClass(): Class = ByteBuffer::class.java -+ override fun getDataSource(): DataSource = DataSource.LOCAL -+} -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt -index 5b9aacc..887ea50 100644 ---- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImage.kt -@@ -485,7 +485,7 @@ class HybridOneKeyImage(private val context: ThemedReactContext) : - requestManager() - .asDrawable() - .load(OneKeyImageModel.build(requestUrl, headersJson)) -- .apply(requestOptions(policy)) -+ .apply(requestOptions(policy, requestUrl)) - .override(decodeDimensions.width, decodeDimensions.height) - .listener(object : RequestListener { - override fun onLoadFailed( -@@ -513,12 +513,13 @@ class HybridOneKeyImage(private val context: ThemedReactContext) : - .into(target) - } - -- private fun requestOptions(policy: OneKeyImageCachePolicy): RequestOptions { -+ private fun requestOptions(policy: OneKeyImageCachePolicy, uri: String): RequestOptions { - val options = RequestOptions() -+ .withOneKeyAvatarCache(uri) - .dontTransform() - .downsample(OneKeyImageSafeDownsampleStrategy) - return when (policy) { -- OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) -+ OneKeyImageCachePolicy.MEMORY_DISK -> options.diskCacheStrategy(oneKeyImageMemoryDiskStrategy(uri)) - OneKeyImageCachePolicy.MEMORY -> options.diskCacheStrategy(DiskCacheStrategy.NONE) - OneKeyImageCachePolicy.DISK -> options - .diskCacheStrategy(DiskCacheStrategy.DATA) -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt -index 7b30df1..0b97164 100644 ---- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageCache.kt -@@ -22,11 +22,12 @@ class HybridOneKeyImageCache : HybridOneKeyImageCacheSpec() { - return@forEach - } - val baseOptions = RequestOptions() -+ .withOneKeyAvatarCache(source.uri) - .dontTransform() - .downsample(OneKeyImageSafeDownsampleStrategy) - val options = when (source.cachePolicy ?: OneKeyImageCachePolicy.MEMORY_DISK) { - OneKeyImageCachePolicy.MEMORY_DISK -> baseOptions -- .diskCacheStrategy(DiskCacheStrategy.AUTOMATIC) -+ .diskCacheStrategy(oneKeyImageMemoryDiskStrategy(source.uri)) - OneKeyImageCachePolicy.MEMORY -> baseOptions - .diskCacheStrategy(DiskCacheStrategy.NONE) - OneKeyImageCachePolicy.DISK -> baseOptions -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt -index eb76d33..f35264c 100644 ---- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageGlideRegistry.kt -@@ -28,6 +28,7 @@ import com.github.penfeizhou.animation.glide.StreamAnimationDecoder - import java.io.ByteArrayOutputStream - import java.io.ByteArrayInputStream - import java.io.IOException -+import java.io.File - import java.io.InputStream - import java.nio.ByteBuffer - -@@ -50,6 +51,16 @@ internal object OneKeyImageGlideRegistry { - val glide = Glide.get(appContext) - val registry = glide.registry - -+ registry.prepend( -+ File::class.java, -+ ByteBuffer::class.java, -+ OneKeyAvatarCacheFileLoaderFactory(), -+ ) -+ registry.prepend( -+ OneKeyBlockieAvatarModel::class.java, -+ ByteBuffer::class.java, -+ OneKeyBlockieAvatarLoaderFactory(), -+ ) - registry.prepend( - OneKeyImageDataUriModel::class.java, - ByteBuffer::class.java, -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt -index 026985d..3969761 100644 ---- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageModel.kt -@@ -25,6 +25,7 @@ internal object OneKeyImageModel { - } - - fun build(uri: String, headersJson: String?): Any { -+ if (OneKeyBlockieAvatar.isAvatarUri(uri)) return OneKeyBlockieAvatarModel(uri) - if (uri.startsWith("data:")) return OneKeyImageDataUriModel(uri) - if (!uri.startsWith("http://") && !uri.startsWith("https://")) { - return OneKeyImageLocalModel(Uri.parse(uri)) -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt -index cbad4c8..3120623 100644 ---- a/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt -+++ b/node_modules/@onekeyfe/react-native-image/android/src/main/java/com/margelo/nitro/onekeyimage/OneKeyImageReusableView.kt -@@ -28,7 +28,12 @@ class OneKeyImageReusableView(context: ThemedReactContext) : FrameLayout(context - optimizeTos: Boolean, - overscan: Double, - loadingStrategy: String, -+ onLoad: (() -> Unit)? = null, -+ onError: (() -> Unit)? = null, - ) { -+ // OneKey patch: Let reusable cells display their own success and fallback visuals. -+ image.onLoad = { _, _, _ -> onLoad?.invoke() } -+ image.onError = { _ -> onError?.invoke() } - image.sourceHeadersJson = sourceHeadersJson - image.variant = when (variant) { - "token" -> OneKeyImageVariant.TOKEN -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt -new file mode 100644 -index 0000000..64d5c04 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarLoaderTest.kt -@@ -0,0 +1,327 @@ -+package com.margelo.nitro.onekeyimage -+ -+import androidx.core.util.Pools -+import com.bumptech.glide.Priority -+import com.bumptech.glide.disklrucache.DiskLruCache -+import com.bumptech.glide.load.DataSource -+import com.bumptech.glide.load.EncodeStrategy -+import com.bumptech.glide.load.Options -+import com.bumptech.glide.load.data.DataFetcher -+import com.bumptech.glide.load.engine.DiskCacheStrategy -+import com.bumptech.glide.load.model.MultiModelLoaderFactory -+import org.junit.Assert.assertArrayEquals -+import org.junit.Assert.assertEquals -+import org.junit.Assert.assertFalse -+import org.junit.Assert.assertNotEquals -+import org.junit.Assert.assertNull -+import org.junit.Assert.assertTrue -+import org.junit.Test -+import java.io.IOException -+import java.io.File -+import java.nio.file.Files -+import java.nio.ByteBuffer -+import java.security.MessageDigest -+import java.util.concurrent.CountDownLatch -+import java.util.concurrent.Executors -+import java.util.concurrent.TimeUnit -+import java.util.concurrent.atomic.AtomicInteger -+ -+class OneKeyBlockieAvatarLoaderTest { -+ private val uri = "onekey-avatar://blockie/v1/0x1234" -+ -+ private class Callback : DataFetcher.DataCallback { -+ var data: ByteBuffer? = null -+ var error: Exception? = null -+ override fun onDataReady(data: ByteBuffer?) { this.data = data } -+ override fun onLoadFailed(error: Exception) { this.error = error } -+ } -+ -+ @Test -+ fun memoryDiskAvatarRequestsCacheTheOriginalLocalPngAcrossSizes() { -+ val strategy = oneKeyImageMemoryDiskStrategy(uri) -+ assertTrue(strategy.isDataCacheable(DataSource.LOCAL)) -+ assertTrue(strategy.decodeCachedData()) -+ assertFalse(strategy.decodeCachedResource()) -+ assertFalse(strategy.isResourceCacheable(false, DataSource.LOCAL, EncodeStrategy.TRANSFORMED)) -+ assertFalse(strategy.isDataCacheable(DataSource.DATA_DISK_CACHE)) -+ assertFalse(DiskCacheStrategy.ALL.isDataCacheable(DataSource.LOCAL)) -+ assertEquals(DiskCacheStrategy.AUTOMATIC, oneKeyImageMemoryDiskStrategy("https://example.com/image.png")) -+ assertEquals(DiskCacheStrategy.AUTOMATIC, oneKeyImageMemoryDiskStrategy("data:image/png;base64,AA==")) -+ assertFalse(DiskCacheStrategy.AUTOMATIC.isDataCacheable(DataSource.LOCAL)) -+ } -+ -+ @Test -+ fun originalPngCacheKeyIsStableAcrossRequestedDecodeDimensions() { -+ val factory = MultiModelLoaderFactory(Pools.SynchronizedPool>(1)) -+ val loader = OneKeyBlockieAvatarLoaderFactory().build(factory) -+ val first = loader.buildLoadData(OneKeyBlockieAvatarModel(uri), 96, 96, Options())!! -+ val second = loader.buildLoadData(OneKeyBlockieAvatarModel(uri), 128, 128, Options())!! -+ val other = loader.buildLoadData(OneKeyBlockieAvatarModel(uri + "0"), 96, 96, Options())!! -+ assertEquals(first.sourceKey, second.sourceKey) -+ assertNotEquals(first.sourceKey, other.sourceKey) -+ val firstDigest = MessageDigest.getInstance("SHA-256").also(first.sourceKey::updateDiskCacheKey).digest() -+ val secondDigest = MessageDigest.getInstance("SHA-256").also(second.sourceKey::updateDiskCacheKey).digest() -+ assertArrayEquals(firstDigest, secondDigest) -+ } -+ -+ private fun cacheFetcher(file: File): DataFetcher { -+ val factory = MultiModelLoaderFactory(Pools.SynchronizedPool>(1)) -+ val loader = OneKeyAvatarCacheFileLoaderFactory().build(factory) -+ return loader.buildLoadData(file, 96, 96, Options().set(oneKeyAvatarCacheFileOption, true))!!.fetcher -+ } -+ -+ @Test -+ fun cacheValidationIsNotRegisteredForOrdinaryImageRequests() { -+ val factory = MultiModelLoaderFactory(Pools.SynchronizedPool>(1)) -+ val loader = OneKeyAvatarCacheFileLoaderFactory().build(factory) -+ assertNull(loader.buildLoadData(File("ordinary-network-image"), 96, 96, Options())) -+ } -+ -+ @Test -+ fun validCachedSourceIsReturnedWithoutRemovingOrRewritingIt() { -+ val directory = Files.createTempDirectory("avatar-cache-valid").toFile() -+ val file = File(directory, "avatar.0") -+ val bytes = OneKeyBlockieAvatar.png(uri) -+ try { -+ file.writeBytes(bytes) -+ val modified = file.lastModified() -+ val fetcher = cacheFetcher(file) -+ val callback = Callback() -+ fetcher.loadData(Priority.NORMAL, callback) -+ fetcher.cleanup() -+ assertNull(callback.error) -+ assertArrayEquals(bytes, callback.data!!.array()) -+ assertEquals(modified, file.lastModified()) -+ assertArrayEquals(bytes, file.readBytes()) -+ } finally { -+ directory.deleteRecursively() -+ } -+ } -+ -+ @Test -+ fun corruptEntryRemovalAllowsRealGlideJournalRewriteAndRestartReuse() { -+ val directory = Files.createTempDirectory("avatar-cache-journal").toFile() -+ val bytes = OneKeyBlockieAvatar.png(uri) -+ try { -+ DiskLruCache.open(directory, 1, 1, 1024L * 1024).use { disk -> -+ disk.edit("avatar").also { editor -> -+ editor.getFile(0).writeBytes(bytes.copyOf(bytes.size - 1)) -+ editor.commit() -+ } -+ val file = disk.get("avatar")!!.getFile(0) -+ val fetcher = cacheFetcher(file) -+ val callback = Callback() -+ fetcher.loadData(Priority.NORMAL, callback) -+ fetcher.cleanup() -+ assertTrue(callback.error is IOException) -+ assertNull(callback.data) -+ assertFalse(file.exists()) -+ // DiskLruCacheWrapper.put uses this same journal lookup before deciding to skip a write. -+ assertNull(disk.get("avatar")) -+ disk.edit("avatar").also { editor -> -+ editor.getFile(0).writeBytes(bytes) -+ editor.commit() -+ } -+ assertArrayEquals(bytes, disk.get("avatar")!!.getFile(0).readBytes()) -+ } -+ DiskLruCache.open(directory, 1, 1, 1024L * 1024).use { reopened -> -+ val fetcher = cacheFetcher(reopened.get("avatar")!!.getFile(0)) -+ val callback = Callback() -+ fetcher.loadData(Priority.NORMAL, callback) -+ fetcher.cleanup() -+ assertNull(callback.error) -+ assertArrayEquals(bytes, callback.data!!.array()) -+ } -+ } finally { -+ directory.deleteRecursively() -+ } -+ } -+ -+ @Test -+ fun oversizedCacheIsRejectedAndRemovedBeforeUnboundedRead() { -+ val directory = Files.createTempDirectory("avatar-cache-large").toFile() -+ val file = File(directory, "avatar.0") -+ try { -+ file.writeBytes(ByteArray(OneKeyBlockieAvatar.MAX_PNG_BYTES + 1)) -+ val fetcher = cacheFetcher(file) -+ val callback = Callback() -+ fetcher.loadData(Priority.NORMAL, callback) -+ fetcher.cleanup() -+ assertTrue(callback.error is IOException) -+ assertFalse(file.exists()) -+ } finally { -+ directory.deleteRecursively() -+ } -+ } -+ -+ @Test -+ fun cancelledCacheReadDoesNotDeleteTheFileOrCallBack() { -+ val directory = Files.createTempDirectory("avatar-cache-cancel").toFile() -+ val file = File(directory, "avatar.0") -+ try { -+ file.writeBytes(byteArrayOf(1, 2, 3)) -+ val fetcher = cacheFetcher(file) -+ val callback = Callback() -+ fetcher.cancel() -+ fetcher.loadData(Priority.NORMAL, callback) -+ fetcher.cleanup() -+ assertNull(callback.data) -+ assertNull(callback.error) -+ assertTrue(file.exists()) -+ } finally { -+ directory.deleteRecursively() -+ } -+ } -+ -+ @Test -+ fun modelDispatchDoesNotDecodeTheUriOrParseHeadersOnMain() { -+ val model = OneKeyImageModel.build("onekey-avatar://blockie/v1/%INVALID", "invalid JSON") -+ assertEquals(OneKeyBlockieAvatarModel("onekey-avatar://blockie/v1/%INVALID"), model) -+ } -+ -+ @Test -+ fun defaultFetcherGeneratesAValidatedPngAndReportsLocalData() { -+ val fetcher = OneKeyBlockieAvatarFetcher(uri) -+ val callback = Callback() -+ try { -+ fetcher.loadData(Priority.NORMAL, callback) -+ assertNull(callback.error) -+ assertEquals(DataSource.LOCAL, fetcher.dataSource) -+ assertArrayEquals(OneKeyBlockieAvatar.png(uri), callback.data!!.array()) -+ } finally { -+ fetcher.cleanup() -+ } -+ } -+ -+ @Test -+ fun malformedUriReportsFailureWithoutReturningImageData() { -+ val fetcher = OneKeyBlockieAvatarFetcher("onekey-avatar://blockie/v2/seed") -+ val callback = Callback() -+ try { -+ fetcher.loadData(Priority.NORMAL, callback) -+ assertNull(callback.data) -+ assertTrue(callback.error is IllegalArgumentException) -+ } finally { -+ fetcher.cleanup() -+ } -+ } -+ -+ @Test -+ fun cancelledBeforeLoadingDoesNotGenerateOrCallBack() { -+ val generated = AtomicInteger() -+ val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> -+ generated.incrementAndGet() -+ byteArrayOf(1) -+ }) -+ val callback = Callback() -+ fetcher.cancel() -+ fetcher.loadData(Priority.NORMAL, callback) -+ fetcher.cleanup() -+ assertEquals(0, generated.get()) -+ assertNull(callback.data) -+ assertNull(callback.error) -+ } -+ -+ @Test -+ fun cancellationSuppressesLateCallbacksAndReleasesTheWorkForAFreshRequest() { -+ val started = CountDownLatch(1) -+ val complete = CountDownLatch(1) -+ val generated = AtomicInteger() -+ val requests = OneKeyAvatarInFlight { _, cancelled -> -+ if (generated.incrementAndGet() == 1) { -+ started.countDown() -+ check(complete.await(3, TimeUnit.SECONDS)) -+ assertTrue(cancelled()) -+ } -+ byteArrayOf(1) -+ } -+ val fetcher = OneKeyBlockieAvatarFetcher(uri, requests) -+ val callback = Callback() -+ val executor = Executors.newSingleThreadExecutor() -+ try { -+ val loaded = executor.submit { fetcher.loadData(Priority.NORMAL, callback) } -+ assertTrue(started.await(3, TimeUnit.SECONDS)) -+ fetcher.cancel() -+ fetcher.cleanup() -+ complete.countDown() -+ loaded.get(3, TimeUnit.SECONDS) -+ assertNull(callback.data) -+ assertNull(callback.error) -+ val fresh = OneKeyBlockieAvatarFetcher(uri, requests) -+ val next = Callback() -+ fresh.loadData(Priority.NORMAL, next) -+ fresh.cleanup() -+ assertArrayEquals(byteArrayOf(1), next.data!!.array()) -+ assertEquals(2, generated.get()) -+ } finally { -+ fetcher.cleanup() -+ complete.countDown() -+ executor.shutdownNow() -+ } -+ } -+ -+ @Test -+ fun cancellationNeverWaitsForAGlideCallbackHoldingItsOwnLock() { -+ val callbackStarted = CountDownLatch(1) -+ val callbackComplete = CountDownLatch(1) -+ val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> byteArrayOf(1) }) -+ val executor = Executors.newFixedThreadPool(2) -+ val callback = object : DataFetcher.DataCallback { -+ override fun onDataReady(data: ByteBuffer?) { -+ callbackStarted.countDown() -+ check(callbackComplete.await(3, TimeUnit.SECONDS)) -+ } -+ override fun onLoadFailed(error: Exception) { throw error } -+ } -+ try { -+ val loaded = executor.submit { fetcher.loadData(Priority.NORMAL, callback) } -+ assertTrue(callbackStarted.await(3, TimeUnit.SECONDS)) -+ executor.submit { fetcher.cancel() }.get(1, TimeUnit.SECONDS) -+ callbackComplete.countDown() -+ loaded.get(3, TimeUnit.SECONDS) -+ } finally { -+ callbackComplete.countDown() -+ fetcher.cleanup() -+ executor.shutdownNow() -+ } -+ } -+ -+ @Test -+ fun glideCallbackFailureIsNotReportedAsASecondGenerationFailure() { -+ val failures = AtomicInteger() -+ val expected = IllegalStateException("Synthetic callback failure") -+ val fetcher = OneKeyBlockieAvatarFetcher(uri, OneKeyAvatarInFlight { _, _ -> byteArrayOf(1) }) -+ val callback = object : DataFetcher.DataCallback { -+ override fun onDataReady(data: ByteBuffer?) { throw expected } -+ override fun onLoadFailed(error: Exception) { failures.incrementAndGet() } -+ } -+ try { -+ fetcher.loadData(Priority.NORMAL, callback) -+ org.junit.Assert.fail("Callback exception must propagate") -+ } catch (error: IllegalStateException) { -+ assertEquals(expected, error) -+ assertEquals(0, failures.get()) -+ } finally { -+ fetcher.cleanup() -+ } -+ } -+ -+ @Test -+ fun failedGenerationIsReleasedSoRetryCanSucceed() { -+ val generated = AtomicInteger() -+ val requests = OneKeyAvatarInFlight { _, _ -> -+ if (generated.incrementAndGet() == 1) throw IOException("Synthetic generation failure") -+ byteArrayOf(1) -+ } -+ val failed = OneKeyBlockieAvatarFetcher(uri, requests) -+ val first = Callback() -+ failed.loadData(Priority.NORMAL, first) -+ failed.cleanup() -+ assertTrue(first.error is IOException) -+ val retried = OneKeyBlockieAvatarFetcher(uri, requests) -+ val second = Callback() -+ retried.loadData(Priority.NORMAL, second) -+ retried.cleanup() -+ assertArrayEquals(byteArrayOf(1), second.data!!.array()) -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt -new file mode 100644 -index 0000000..7b81e86 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/android/src/test/java/com/margelo/nitro/onekeyimage/OneKeyBlockieAvatarTest.kt -@@ -0,0 +1,214 @@ -+package com.margelo.nitro.onekeyimage -+ -+import org.junit.Assert.assertArrayEquals -+import org.junit.Assert.assertEquals -+import org.junit.Assert.assertFalse -+import org.junit.Assert.assertTrue -+import org.junit.Assert.fail -+import org.junit.Test -+import java.io.ByteArrayOutputStream -+import java.io.DataOutputStream -+import java.nio.ByteBuffer -+import java.util.zip.CRC32 -+import java.util.zip.DeflaterOutputStream -+import java.util.concurrent.CancellationException -+import java.util.concurrent.CountDownLatch -+import java.util.concurrent.Executors -+import java.util.concurrent.TimeUnit -+import java.util.concurrent.atomic.AtomicInteger -+ -+class OneKeyBlockieAvatarTest { -+ @Test -+ fun percentDecodePreservesJavaScriptNormalizedUtf16WithoutRecasing() { -+ assertEquals("i\u0307中🙂/#+", OneKeyBlockieAvatar.decodeSeed( -+ "onekey-avatar://blockie/v1/i%CC%87%E4%B8%AD%F0%9F%99%82%2F%23%2B", -+ )) -+ assertEquals("İ", OneKeyBlockieAvatar.decodeSeed("onekey-avatar://blockie/v1/%C4%B0")) -+ assertEquals("\u0000", OneKeyBlockieAvatar.decodeSeed("onekey-avatar://blockie/v1/%00")) -+ } -+ -+ @Test -+ fun invalidProtocolPercentEncodingAndUtf8AreRejected() { -+ listOf( -+ "onekey-avatar://blockie/v2/seed", "onekey-avatar://blockie/v1/", -+ "onekey-avatar://blockie/v1/%", "onekey-avatar://blockie/v1/%GG", -+ "onekey-avatar://blockie/v1/%C0%AF", "onekey-avatar://blockie/v1/%ED%A0%80", -+ "onekey-avatar://blockie/v1/seed?query", "onekey-avatar://blockie/v1/a/b", -+ "onekey-avatar://blockie/v1/a+b", "onekey-avatar://blockie/v1/中", -+ ).forEach { uri -> -+ try { -+ OneKeyBlockieAvatar.decodeSeed(uri) -+ fail("Invalid avatar URI was accepted") -+ } catch (_: Exception) { } -+ } -+ } -+ -+ @Test -+ fun generationIsDeterministicAndEmits128pxIndexedPng() { -+ val uri = "onekey-avatar://blockie/v1/0x1234" -+ val first = OneKeyBlockieAvatar.png(uri) -+ assertArrayEquals(first, OneKeyBlockieAvatar.png(uri)) -+ assertArrayEquals(byteArrayOf(-119, 80, 78, 71, 13, 10, 26, 10), first.copyOfRange(0, 8)) -+ assertArrayEquals(byteArrayOf(0, 0, 0, -128, 0, 0, 0, -128), first.copyOfRange(16, 24)) -+ assertTrue(first.size < 1024) -+ } -+ -+ @Test(expected = CancellationException::class) -+ fun cancellationStopsBeforeGeneration() { -+ OneKeyBlockieAvatar.png("onekey-avatar://blockie/v1/seed") { true } -+ } -+ -+ @Test(expected = CancellationException::class) -+ fun cancellationAlsoStopsLargeSeedDecoding() { -+ val checks = AtomicInteger() -+ OneKeyBlockieAvatar.png("onekey-avatar://blockie/v1/" + "a".repeat(8192)) { -+ checks.incrementAndGet() > 3 -+ } -+ } -+ -+ private fun replaceChunk(png: ByteArray, type: String, data: ByteArray): ByteArray { -+ val input = ByteBuffer.wrap(png) -+ input.position(8) -+ while (input.hasRemaining()) { -+ val start = input.position() -+ val length = input.int -+ val chunkType = ByteArray(4).also(input::get).toString(Charsets.US_ASCII) -+ val end = input.position() + length + 4 -+ if (chunkType == type) { -+ val chunk = ByteArrayOutputStream() -+ DataOutputStream(chunk).use { -+ val name = type.toByteArray(Charsets.US_ASCII) -+ it.writeInt(data.size) -+ it.write(name) -+ it.write(data) -+ it.writeInt(CRC32().apply { update(name); update(data) }.value.toInt()) -+ } -+ return png.copyOfRange(0, start) + chunk.toByteArray() + png.copyOfRange(end, png.size) -+ } -+ input.position(end) -+ } -+ throw IllegalArgumentException("Test PNG chunk missing") -+ } -+ -+ private fun compressedPixels(size: Int, invalidFilter: Boolean = false): ByteArray { -+ val result = ByteArrayOutputStream() -+ DeflaterOutputStream(result).use { output -> -+ repeat(size) { index -> output.write(if (invalidFilter && index == 0) 1 else 0) } -+ } -+ return result.toByteArray() -+ } -+ -+ @Test -+ fun completeFixedFormatPngPassesIntegrityValidation() { -+ listOf("seed", "0x1234", "%E4%B8%AD%F0%9F%99%82").forEach { -+ assertTrue(OneKeyBlockieAvatar.isValidPng(OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + it))) -+ } -+ } -+ -+ @Test -+ fun corruptedOrTruncatedChunksAndTrailingBytesAreRejected() { -+ val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed") -+ assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf(png.size - 1))) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(png + byteArrayOf(0))) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf().also { it[45] = (it[45].toInt() xor 1).toByte() })) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(png.copyOf().also { ByteBuffer.wrap(it).putInt(8, Int.MAX_VALUE) })) -+ } -+ -+ @Test -+ fun validCrcCannotHideWrongDimensionsOrMalformedCompressedData() { -+ val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed") -+ val header = png.copyOfRange(16, 29).also { it[3] = 96 } -+ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IHDR", header))) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", byteArrayOf(1, 2, 3)))) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", compressedPixels(128 * 33 - 1)))) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(replaceChunk(png, "IDAT", compressedPixels(128 * 33, true)))) -+ } -+ -+ @Test -+ fun decompressionIsBoundedEvenWhenEveryChunkCrcIsValid() { -+ val png = OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed") -+ val bomb = replaceChunk(png, "IDAT", compressedPixels(1024 * 1024)) -+ assertTrue(bomb.size < OneKeyBlockieAvatar.MAX_PNG_BYTES) -+ assertFalse(OneKeyBlockieAvatar.isValidPng(bomb)) -+ } -+ -+ @Test(expected = CancellationException::class) -+ fun cancellationStopsCachedPngValidation() { -+ OneKeyBlockieAvatar.isValidPng(OneKeyBlockieAvatar.png(OneKeyBlockieAvatar.URI_PREFIX + "seed")) { true } -+ } -+ -+ @Test -+ fun overlappingSizeRequestsShareOneGenerationAndDoNotRetainCompletedImages() { -+ val generated = AtomicInteger() -+ val started = CountDownLatch(1) -+ val complete = CountDownLatch(1) -+ val requests = OneKeyAvatarInFlight { _, _ -> -+ generated.incrementAndGet() -+ started.countDown() -+ check(complete.await(3, TimeUnit.SECONDS)) -+ byteArrayOf(1, 2, 3) -+ } -+ val leases = List(8) { requests.acquire("uri") } -+ val executor = Executors.newFixedThreadPool(8) -+ try { -+ val values = leases.map { lease -> executor.submit { lease.bytes() } } -+ assertTrue(started.await(3, TimeUnit.SECONDS)) -+ complete.countDown() -+ values.forEach { assertArrayEquals(byteArrayOf(1, 2, 3), it.get(3, TimeUnit.SECONDS)) } -+ assertEquals(1, generated.get()) -+ leases.forEach { it.release() } -+ val fresh = requests.acquire("uri") -+ assertArrayEquals(byteArrayOf(1, 2, 3), fresh.bytes()) -+ fresh.release() -+ assertEquals(2, generated.get()) -+ } finally { -+ complete.countDown() -+ leases.forEach { it.release() } -+ executor.shutdownNow() -+ } -+ } -+ -+ @Test -+ fun cancellingOneConsumerKeepsTheOtherConsumerAlive() { -+ val requests = OneKeyAvatarInFlight { _, cancelled -> -+ assertFalse(cancelled()) -+ byteArrayOf(7) -+ } -+ val cancelled = requests.acquire("uri") -+ val active = requests.acquire("uri") -+ cancelled.release() -+ cancelled.release() -+ assertArrayEquals(byteArrayOf(7), active.bytes()) -+ active.release() -+ } -+ -+ @Test -+ fun cancellingEveryConsumerStopsWorkAndAllowsAFreshRequest() { -+ val started = CountDownLatch(1) -+ val stopped = CountDownLatch(1) -+ val generated = AtomicInteger() -+ val requests = OneKeyAvatarInFlight { _, cancelled -> -+ if (generated.incrementAndGet() == 1) { -+ started.countDown() -+ while (!cancelled()) Thread.yield() -+ stopped.countDown() -+ throw CancellationException("Cancelled") -+ } -+ byteArrayOf(9) -+ } -+ val lease = requests.acquire("uri") -+ val executor = Executors.newSingleThreadExecutor() -+ try { -+ executor.submit { try { lease.bytes() } catch (_: CancellationException) { } } -+ assertTrue(started.await(3, TimeUnit.SECONDS)) -+ lease.release() -+ assertTrue(stopped.await(3, TimeUnit.SECONDS)) -+ val fresh = requests.acquire("uri") -+ assertArrayEquals(byteArrayOf(9), fresh.bytes()) -+ fresh.release() -+ } finally { -+ lease.release() -+ executor.shutdownNow() -+ } -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift -new file mode 100644 -index 0000000..f4588c6 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyAvatarImageLoader.swift -@@ -0,0 +1,217 @@ -+// OneKey patch: Share local avatar work and persistent SDWebImage caching across -+// render/preload requests, including managers isolated for HTTP headers. -+import Foundation -+import SDWebImage -+import UIKit -+ -+final class OneKeyAvatarImageLoader: NSObject, SDImageLoader { -+ static let shared = OneKeyAvatarImageLoader() -+ static let cache: SDImageCache = { -+ let config = SDImageCacheConfig() -+ config.maxDiskAge = 30 * 24 * 60 * 60 -+ config.maxDiskSize = 32 * 1024 * 1024 -+ config.maxMemoryCost = 8 * 1024 * 1024 -+ config.maxMemoryCount = 128 -+ return SDImageCache(namespace: "onekey-avatar-blockie-v1", diskCacheDirectory: nil, config: config) -+ }() -+ -+ private final class Subscription: NSObject, SDWebImageOperation { -+ let id = UUID() -+ let options: SDWebImage.SDWebImageOptions -+ let context: [SDWebImageContextOption: Any]? -+ private let lock = NSLock() -+ private var completion: SDImageLoaderCompletedBlock? -+ private var cancellation: (() -> Void)? -+ private var terminal = false -+ -+ init(options: SDWebImage.SDWebImageOptions, context: [SDWebImageContextOption: Any]?, -+ completion: SDImageLoaderCompletedBlock?) { -+ self.options = options; self.context = context; self.completion = completion -+ } -+ -+ var isTerminal: Bool { -+ lock.lock(); defer { lock.unlock() }; return terminal -+ } -+ -+ func onCancel(_ block: @escaping () -> Void) { -+ lock.lock() -+ let alreadyTerminal = terminal -+ if !alreadyTerminal { cancellation = block } -+ lock.unlock() -+ if alreadyTerminal { block() } -+ } -+ -+ func finish(image: UIImage?, data: Data?, error: Error?) { -+ lock.lock() -+ guard !terminal else { lock.unlock(); return } -+ terminal = true -+ let callback = completion -+ completion = nil; cancellation = nil -+ lock.unlock() -+ callback?(image, data, error, true) -+ } -+ -+ func cancel() { -+ lock.lock() -+ guard !terminal else { lock.unlock(); return } -+ terminal = true -+ let callback = completion, cancel = cancellation -+ completion = nil; cancellation = nil -+ lock.unlock() -+ cancel?() -+ // Preload's checked continuation must also terminate on cancellation. -+ DispatchQueue.global(qos: .userInitiated).async { -+ callback?(nil, nil, URLError(.cancelled), true) -+ } -+ } -+ } -+ -+ private final class Flight { -+ let id = UUID() -+ let descriptor: OneKeyBlockieDescriptor -+ let url: URL -+ var subscriptions: [UUID: Subscription] = [:] -+ var operation: BlockOperation? -+ init(descriptor: OneKeyBlockieDescriptor, url: URL) { -+ self.descriptor = descriptor; self.url = url -+ } -+ } -+ -+ private let state = DispatchQueue(label: "onekey.avatar.state") -+ private let workers: OperationQueue = { -+ let queue = OperationQueue() -+ queue.name = "onekey.avatar.generate" -+ queue.qualityOfService = .userInitiated -+ queue.maxConcurrentOperationCount = 2 -+ return queue -+ }() -+ private var flights: [String: Flight] = [:] -+ private var diskWrites = 0 -+ -+ func canRequestImage(for url: URL?) -> Bool { -+ // Own invalid local-avatar URLs too: fail locally instead of using HTTP. -+ url?.scheme == "onekey-avatar" -+ } -+ -+ func shouldBlockFailedURL(with url: URL, error: Error) -> Bool { false } -+ -+ func requestImage(with url: URL?, options: SDWebImage.SDWebImageOptions, -+ context: [SDWebImageContextOption: Any]?, progress: SDImageLoaderProgressBlock?, -+ completed: SDImageLoaderCompletedBlock?) -> SDWebImageOperation? { -+ let subscriber = Subscription(options: options, context: context, completion: completed) -+ state.async { -+ guard !subscriber.isTerminal else { return } -+ guard let url, let descriptor = OneKeyBlockieDescriptor(url: url) else { -+ subscriber.finish(image: nil, data: nil, error: URLError(.badURL)) -+ return -+ } -+ let key = descriptor.cacheKey -+ let flight = self.flights[key] ?? Flight(descriptor: descriptor, url: url) -+ self.flights[key] = flight -+ flight.subscriptions[subscriber.id] = subscriber -+ subscriber.onCancel { [weak self, weak flight] in -+ guard let self, let flight else { return } -+ self.state.async { -+ guard self.flights[key] === flight else { return } -+ flight.subscriptions.removeValue(forKey: subscriber.id) -+ if flight.subscriptions.isEmpty { -+ self.flights.removeValue(forKey: key) -+ flight.operation?.cancel() -+ } -+ } -+ } -+ guard flight.operation == nil else { return } -+ let operation = BlockOperation { [weak self, weak flight] in -+ guard let self, let flight else { return } -+ self.load(flight) -+ } -+ flight.operation = operation -+ self.workers.addOperation(operation) -+ } -+ return subscriber -+ } -+ -+ private func load(_ flight: Flight) { -+ let key = flight.descriptor.cacheKey -+ let cancelled = { flight.operation?.isCancelled != false } -+ guard !cancelled() else { return } -+ let queryTypes = state.sync { cacheTypes(flight, field: .originalQueryCacheType) } -+ // Query again inside the merged worker to close the manager-cache-query vs -+ // completed previous flight race. These disk operations never run on UI. -+ var data: Data? -+ if queryTypes.memory, let image = Self.cache.imageFromMemoryCache(forKey: key) { -+ data = image.pngData() -+ } -+ if data == nil, queryTypes.disk { data = Self.cache.diskImageData(forKey: key) } -+ var original = data.flatMap { UIImage(data: $0) } -+ // A damaged persistent entry is a local cache miss, not a permanent avatar failure. -+ if original == nil { -+ data = OneKeyBlockie.png(seed: flight.descriptor.seed, isCancelled: cancelled) -+ original = data.flatMap { UIImage(data: $0) } -+ } -+ guard !cancelled() else { return } -+ guard let data, let original else { -+ finish(flight, data: nil, error: URLError(.cannotDecodeContentData)); return -+ } -+ // Synchronous store on this worker seals the cache-fill window before any -+ // subscriber (or a new isolated manager) can observe a completed flight. -+ var storedMemory = false, storedDisk = false -+ while !cancelled() { -+ var subscribers: [Subscription]? -+ let storeTypes = state.sync { () -> (memory: Bool, disk: Bool) in -+ guard flights[key] === flight else { subscribers = []; return (false, false) } -+ let required = cacheTypes(flight, field: .originalStoreCacheType) -+ if (!required.memory || storedMemory) && (!required.disk || storedDisk) { -+ flights.removeValue(forKey: key) -+ subscribers = Array(flight.subscriptions.values) -+ } -+ return required -+ } -+ if let subscribers { deliver(subscribers, flight: flight, data: data, error: nil); return } -+ if storeTypes.memory && !storedMemory { -+ Self.cache.storeImage(toMemory: original, forKey: key) -+ storedMemory = true -+ } -+ if storeTypes.disk && !storedDisk { -+ Self.cache.storeImageData(toDisk: data, forKey: key) -+ storedDisk = true -+ state.async { -+ self.diskWrites += 1 -+ // SDWebImage also cleans on background/termination; bound active-session -+ // growth without scanning the directory after every small PNG write. -+ if self.diskWrites % 128 == 0 { Self.cache.deleteOldFiles(completionBlock: nil) } -+ } -+ } -+ } -+ } -+ -+ private func cacheTypes(_ flight: Flight, field: SDWebImageContextOption) -> (memory: Bool, disk: Bool) { -+ var memory = false, disk = false -+ for subscriber in flight.subscriptions.values where !subscriber.isTerminal { -+ let raw = (subscriber.context?[field] as? NSNumber)?.intValue ?? SDImageCacheType.all.rawValue -+ memory = memory || raw == SDImageCacheType.memory.rawValue || raw == SDImageCacheType.all.rawValue -+ disk = disk || raw == SDImageCacheType.disk.rawValue || raw == SDImageCacheType.all.rawValue -+ } -+ return (memory, disk) -+ } -+ -+ private func finish(_ flight: Flight, data: Data?, error: Error?) { -+ let subscribers: [Subscription] = state.sync { -+ guard flights[flight.descriptor.cacheKey] === flight else { return [] } -+ flights.removeValue(forKey: flight.descriptor.cacheKey) -+ return Array(flight.subscriptions.values) -+ } -+ deliver(subscribers, flight: flight, data: data, error: error) -+ } -+ -+ private func deliver(_ subscribers: [Subscription], flight: Flight, data: Data?, error: Error?) { -+ for subscriber in subscribers where !subscriber.isTerminal { -+ let image: UIImage? = data.flatMap { -+ SDWebImage.SDImageLoaderDecodeImageData($0, flight.url, -+ .init(rawValue: subscriber.options.rawValue), subscriber.context) -+ } -+ subscriber.finish(image: image, data: data, -+ error: error ?? (image == nil ? URLError(.cannotDecodeContentData) : nil)) -+ } -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyBlockie.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyBlockie.swift -new file mode 100644 -index 0000000..3171368 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyBlockie.swift -@@ -0,0 +1,126 @@ -+// OneKey patch: Render the versioned local avatar URI without a JS PNG payload. -+// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64 -+// Permission is hereby granted, free of charge, to any person obtaining a copy -+// of this software and associated documentation files (the "Software"), to deal -+// in the Software without restriction, including without limitation the rights -+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -+// copies of the Software, and to permit persons to whom the Software is -+// furnished to do so, subject to the following conditions: -+// The above copyright notice and this permission notice shall be included in -+// all copies or substantial portions of the Software. -+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -+// THE SOFTWARE. -+ -+import CoreGraphics -+import Foundation -+import ImageIO -+ -+struct OneKeyBlockieDescriptor { -+ let seed: String -+ let cacheKey: String -+ -+ init?(url: URL) { -+ guard let parts = URLComponents(url: url, resolvingAgainstBaseURL: false), -+ parts.scheme == "onekey-avatar", parts.host == "blockie", -+ parts.user == nil, parts.password == nil, parts.port == nil, -+ parts.query == nil, parts.fragment == nil, -+ parts.percentEncodedPath.hasPrefix("/v1/") -+ else { return nil } -+ let encoded = String(parts.percentEncodedPath.dropFirst(4)) -+ guard !encoded.contains("/"), let seed = encoded.removingPercentEncoding, -+ !seed.isEmpty -+ else { return nil } -+ // The caller already applied JS lowercase. ASCII keys preserve distinct -+ // UTF16 seeds that Swift String otherwise compares as canonically equal. -+ let allowed = CharacterSet(charactersIn: -+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()") -+ guard let canonical = seed.addingPercentEncoding(withAllowedCharacters: allowed) else { return nil } -+ self.seed = seed -+ cacheKey = "onekey-avatar://blockie/v1/\(canonical)" -+ } -+} -+ -+enum OneKeyBlockie { -+ static let pixelSize = 128 -+ -+ private struct Random { -+ var state = [Int32](repeating: 0, count: 4) -+ -+ init(seed: String) { -+ for (index, unit) in seed.utf16.enumerated() { -+ let slot = index % 4 -+ state[slot] = (state[slot] &<< 5) &- state[slot] &+ Int32(unit) -+ } -+ } -+ -+ mutating func next() -> Double { -+ let t = state[0] ^ (state[0] &<< 11) -+ state[0] = state[1]; state[1] = state[2]; state[2] = state[3] -+ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8) -+ return Double(UInt32(bitPattern: state[3])) / 2_147_483_648 -+ } -+ -+ mutating func color() -> [UInt8] { -+ let h = floor(next() * 360) / 360 -+ let s = (next() * 60 + 40) / 100 -+ let l = ((next() + next() + next() + next()) * 25) / 100 -+ let q = l < 0.5 ? l * (1 + s) : l + s - l * s -+ let p = 2 * l - q -+ func channel(_ value: Double) -> UInt8 { -+ var t = value -+ if t < 0 { t += 1 } -+ if t > 1 { t -= 1 } -+ let value: Double -+ if t < 1.0 / 6 { value = p + (q - p) * 6 * t } -+ else if t < 1.0 / 2 { value = q } -+ else if t < 2.0 / 3 { value = p + (q - p) * (2.0 / 3 - t) * 6 } -+ else { value = p } -+ return UInt8(truncatingIfNeeded: Int(floor(value * 255 + 0.5))) -+ } -+ return [channel(h + 1.0 / 3), channel(h), channel(h - 1.0 / 3), 255] -+ } -+ } -+ -+ static func rgba(seed: String, isCancelled: () -> Bool = { false }) -> Data? { -+ var random = Random(seed: seed) -+ let foreground = random.color(), background = random.color(), spot = random.color() -+ var pixels = [UInt8](repeating: 0, count: pixelSize * pixelSize * 4) -+ for row in 0..<8 { -+ guard !isCancelled() else { return nil } -+ let half = (0..<4).map { _ in Int(floor(random.next() * 2.3)) } -+ let cells = half + half.reversed() -+ for column in 0..<8 { -+ let color = cells[column] == 0 ? background : cells[column] == 1 ? foreground : spot -+ for y in (row * 16)..<((row + 1) * 16) { -+ for x in (column * 16)..<((column + 1) * 16) { -+ let offset = (y * pixelSize + x) * 4 -+ for channel in 0..<4 { pixels[offset + channel] = color[channel] } -+ } -+ } -+ } -+ } -+ return Data(pixels) -+ } -+ -+ static func png(seed: String, isCancelled: () -> Bool = { false }) -> Data? { -+ guard let data = rgba(seed: seed, isCancelled: isCancelled), !isCancelled(), -+ let provider = CGDataProvider(data: data as CFData), -+ let image = CGImage(width: pixelSize, height: pixelSize, bitsPerComponent: 8, -+ bitsPerPixel: 32, bytesPerRow: pixelSize * 4, space: CGColorSpaceCreateDeviceRGB(), -+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), -+ provider: provider, decode: nil, shouldInterpolate: false, intent: .defaultIntent) -+ else { return nil } -+ let output = NSMutableData() -+ guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) -+ else { return nil } -+ CGImageDestinationAddImage(destination, image, nil) -+ guard CGImageDestinationFinalize(destination), !isCancelled() else { return nil } -+ return output as Data -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift -index dbbe6cc..6162f20 100644 ---- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImage.swift -@@ -313,7 +313,10 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView { - cachePolicy: cachePolicy ?? .memoryDisk, - thumbnailPixelSize: thumbnailPixelSize, - safetyTracker: safetyHandle.tracker, -- manager: safetyHandle.manager -+ // OneKey patch: Rendering and preload use the same local-avatar loader. -+ // manager: safetyHandle.manager -+ manager: safetyHandle.manager, -+ url: url - ) - hostView.sd_setImage( - with: url, -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift -index 8517d8a..00f0f20 100644 ---- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCache.swift -@@ -74,6 +74,8 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec { - func clearMemory() throws -> Promise { - Promise.async { - SDImageCache.shared.clearMemory() -+ // OneKey patch: Clear the dedicated local-avatar cache with public cache operations. -+ OneKeyAvatarImageLoader.cache.clearMemory() - } - } - -@@ -82,11 +84,16 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec { - await withCheckedContinuation { continuation in - SDImageCache.shared.clearDisk { continuation.resume() } - } -+ await withCheckedContinuation { continuation in -+ OneKeyAvatarImageLoader.cache.clearDisk { continuation.resume() } -+ } - } - } - - func clearAll() throws -> Promise { - SDImageCache.shared.clearMemory() -+ // OneKey patch: The avatar cache is shared by render and preload requests. -+ OneKeyAvatarImageLoader.cache.clearMemory() - return try clearDisk() - } - -@@ -154,7 +161,10 @@ final class HybridOneKeyImageCache: HybridOneKeyImageCacheSpec { - cachePolicy: source.cachePolicy ?? .memoryDisk, - thumbnailPixelSize: thumbnailPixelSize, - safetyTracker: safetyHandle.tracker, -- manager: safetyHandle.manager -+ // OneKey patch: Rendering and preload use the same local-avatar loader. -+ // manager: safetyHandle.manager -+ manager: safetyHandle.manager, -+ url: url - ) - return await load(url: url, context: context, safetyHandle: safetyHandle) - } -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.h b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.h -new file mode 100644 -index 0000000..e28d6cf ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.h -@@ -0,0 +1,14 @@ -+#import -+ -+NS_ASSUME_NONNULL_BEGIN -+ -+// Keep optional coder modules out of the Swift compilation unit so SDWebImage -+// Objective-C types are imported under a single Swift module identity. -+@interface OneKeyImageCoderBridge : NSObject -+ -++ (void)addCodersToManager:(id)manager NS_SWIFT_NAME(addCoders(to:)); -++ (void)ensureWebPCoderRegistered; -+ -+@end -+ -+NS_ASSUME_NONNULL_END -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.m b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.m -new file mode 100644 -index 0000000..beec851 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageCoderBridge.m -@@ -0,0 +1,25 @@ -+#import "OneKeyImageCoderBridge.h" -+ -+#import -+#import -+#import -+ -+@implementation OneKeyImageCoderBridge -+ -++ (void)addCodersToManager:(id)manager { -+ SDImageCodersManager *coderManager = (SDImageCodersManager *)manager; -+ [coderManager addCoder:SDImageSVGCoder.sharedCoder]; -+ [coderManager addCoder:SDImageWebPCoder.sharedCoder]; -+} -+ -++ (void)ensureWebPCoderRegistered { -+ SDImageCodersManager *manager = SDImageCodersManager.sharedManager; -+ for (id coder in manager.coders) { -+ if (coder == SDImageWebPCoder.sharedCoder) { -+ return; -+ } -+ } -+ [manager addCoder:SDImageWebPCoder.sharedCoder]; -+} -+ -+@end -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift -index 393660a..5439929 100644 ---- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageRequestContext.swift -@@ -2,8 +2,6 @@ import CryptoKit - import Foundation - import ImageIO - import SDWebImage --import SDWebImageSVGCoder --import SDWebImageWebPCoder - import UIKit - - enum OneKeyImageSafetyViolation: LocalizedError, Equatable, Sendable { -@@ -467,28 +465,18 @@ enum OneKeyImageSafetyPolicy { - } - - enum OneKeyImageCoderRegistry { -- private static let svgCoder = SDImageSVGCoder.shared -- private static let webPCoder = SDImageWebPCoder.shared -- - static let coder: SDImageCodersManager = { - // Keep OneKey's decoder set independent from Expo's global registrations. - // SDImageCodersManager starts with ImageIO, GIF and APNG coders. - let manager = SDImageCodersManager() -- manager.addCoder(svgCoder) -- manager.addCoder(webPCoder) -+ OneKeyImageCoderBridge.addCoders(to: manager) - return manager - }() - - private static let globalRegistration: Void = { - // SDAnimatedImage resolves its animated coder through the global manager, - // even when a request-local coder is provided in the SDWebImage context. -- let global = SDImageCodersManager.shared -- let isAlreadyRegistered = (global.coders ?? []).contains { -- ($0 as AnyObject) === webPCoder -- } -- if !isAlreadyRegistered { -- global.addCoder(webPCoder) -- } -+ OneKeyImageCoderBridge.ensureWebPCoderRegistered() - }() - - static func ensureWebPRegistered() { -@@ -697,7 +685,10 @@ enum OneKeyImageRequestContext { - cachePolicy: OneKeyImageCachePolicy, - thumbnailPixelSize: CGSize?, - safetyTracker: OneKeyImageSafetyTracker?, -- manager: SDWebImageManager -+ // OneKey patch: Scope local avatar routing to this request, preserving HTTP managers. -+ // manager: SDWebImageManager -+ manager: SDWebImageManager, -+ url: URL? = nil - ) -> [SDWebImageContextOption: Any] { - var context = baseContext - context[.customManager] = manager -@@ -728,6 +719,14 @@ enum OneKeyImageRequestContext { - context[.storeCacheType] = cacheType.rawValue - context[.originalQueryCacheType] = cacheType.rawValue - context[.originalStoreCacheType] = cacheType.rawValue -+ if url?.scheme == "onekey-avatar" { -+ context[.imageLoader] = OneKeyAvatarImageLoader.shared -+ context[.imageCache] = OneKeyAvatarImageLoader.cache -+ context[.originalImageCache] = OneKeyAvatarImageLoader.cache -+ context[.cacheKeyFilter] = SDWebImageCacheKeyFilter { url in -+ OneKeyBlockieDescriptor(url: url)?.cacheKey ?? url.absoluteString -+ } -+ } - return context - } - -diff --git a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift -index a55ad89..f15571d 100644 ---- a/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift -+++ b/node_modules/@onekeyfe/react-native-image/ios/OneKeyImageReusableView.swift -@@ -27,8 +27,13 @@ public final class OneKeyImageReusableView: UIView { - recyclingKey: String, - optimizeTos: Bool, - overscan: Double, -- loadingStrategy: String -+ loadingStrategy: String, -+ onLoad: (() -> Void)? = nil, -+ onError: (() -> Void)? = nil - ) { -+ // OneKey patch: Let reusable cells display their own success and fallback visuals. -+ image.onLoad = { _, _, _ in onLoad?() } -+ image.onError = { _ in onError?() } - image.sourceHeadersJson = sourceHeadersJson - image.variant = OneKeyImageVariant(fromString: variant) ?? .generic - image.contentFit = OneKeyImageContentFit(fromString: contentFit) ?? .cover -diff --git a/node_modules/@onekeyfe/react-native-image/ios/tests/OneKeyAvatarImageTests.swift b/node_modules/@onekeyfe/react-native-image/ios/tests/OneKeyAvatarImageTests.swift -new file mode 100644 -index 0000000..9c93474 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-image/ios/tests/OneKeyAvatarImageTests.swift -@@ -0,0 +1,49 @@ -+// OneKey patch: Protect request-local avatar routing and exact UTF16 identities. -+import Foundation -+import SDWebImage -+import XCTest -+ -+@testable import OneKeyImage -+ -+final class OneKeyAvatarImageTests: XCTestCase { -+ func testAvatarCacheIsSharedWithoutReplacingIsolatedHTTPManagers() throws { -+ let url = try XCTUnwrap(URL(string: "onekey-avatar://blockie/v1/synthetic-avatar")) -+ let first = OneKeyImagePipeline.makeIsolatedManager() -+ let second = OneKeyImagePipeline.makeIsolatedManager() -+ func context(_ manager: SDWebImageManager, headers: String) -> [SDWebImageContextOption: Any] { -+ OneKeyImageRequestContext.make(headersJson: headers, cachePolicy: .memoryDisk, -+ thumbnailPixelSize: CGSize(width: 96, height: 96), safetyTracker: nil, -+ manager: manager, url: url) -+ } -+ let a = context(first, headers: "{\"X-Test\":\"a\"}") -+ let b = context(second, headers: "{\"X-Test\":\"b\"}") -+ XCTAssertTrue(a[.customManager] as? SDWebImageManager === first) -+ XCTAssertTrue(b[.customManager] as? SDWebImageManager === second) -+ XCTAssertTrue(a[.imageLoader] as? OneKeyAvatarImageLoader === OneKeyAvatarImageLoader.shared) -+ XCTAssertTrue(b[.imageLoader] as? OneKeyAvatarImageLoader === OneKeyAvatarImageLoader.shared) -+ XCTAssertTrue(a[.imageCache] as? SDImageCache === b[.imageCache] as? SDImageCache) -+ XCTAssertTrue(a[.originalImageCache] as? SDImageCache === OneKeyAvatarImageLoader.cache) -+ let firstFilter = try XCTUnwrap(a[.cacheKeyFilter] as? SDWebImageCacheKeyFilter) -+ let secondFilter = try XCTUnwrap(b[.cacheKeyFilter] as? SDWebImageCacheKeyFilter) -+ XCTAssertEqual(firstFilter.cacheKey(for: url), secondFilter.cacheKey(for: url)) -+ let remote = OneKeyImageRequestContext.make(headersJson: nil, cachePolicy: .memoryDisk, -+ thumbnailPixelSize: nil, safetyTracker: nil, manager: first, -+ url: URL(string: "https://example.com/image.png")) -+ XCTAssertNil(remote[.imageLoader]) -+ XCTAssertNil(remote[.imageCache]) -+ XCTAssertNil(remote[.originalImageCache]) -+ } -+ -+ func testURIDecodesExactlyOnceAndPreservesUTF16SeedIdentity() throws { -+ func descriptor(_ suffix: String) throws -> OneKeyBlockieDescriptor { -+ try XCTUnwrap(OneKeyBlockieDescriptor(url: -+ XCTUnwrap(URL(string: "onekey-avatar://blockie/v1/" + suffix)))) -+ } -+ XCTAssertEqual(try descriptor("%2561").seed, "%61") -+ XCTAssertEqual(try descriptor("%61").cacheKey, try descriptor("a").cacheKey) -+ XCTAssertNotEqual(try descriptor("%C3%A9").cacheKey, try descriptor("e%CC%81").cacheKey) -+ XCTAssertEqual(try descriptor("%C4%B0").seed.utf16.count, 1) -+ XCTAssertNotEqual(OneKeyBlockie.rgba(seed: "é"), OneKeyBlockie.rgba(seed: "e\u{301}")) -+ XCTAssertNil(OneKeyBlockie.png(seed: "synthetic", isCancelled: { true })) -+ } -+} diff --git a/patches/@onekeyfe+react-native-native-list+3.0.105.patch b/patches/@onekeyfe+react-native-native-list+3.0.105.patch deleted file mode 100644 index 49587d1ec333..000000000000 --- a/patches/@onekeyfe+react-native-native-list+3.0.105.patch +++ /dev/null @@ -1,10173 +0,0 @@ -diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt -index c7d7905..e338756 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt -+++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListAdapter.kt -@@ -41,6 +41,7 @@ internal class NativeListAdapter( - private val createdRows = Collections.newSetFromMap( - WeakHashMap(), - ) -+ var usesSelectorSourceScale = false - var theme: JSONObject? = null - var layout: String = "linear" - var orientation: String = "vertical" -@@ -75,8 +76,9 @@ internal class NativeListAdapter( - layout, - orientation, - position, -- selectedKeys.contains(item.key), -+ item.json.optBoolean("selected", false) || selectedKeys.contains(item.key), - checkboxState, -+ useSourceScale = usesSelectorSourceScale, - ) - } - -@@ -85,14 +87,17 @@ internal class NativeListAdapter( - position: Int, - payloads: MutableList, - ) { -- if (payloads.contains(SELECTION_PAYLOAD)) { -+ // OneKey patch: an unknown payload must retain full binding; validated echoes keep images alive. -+ // if (payloads.contains(SELECTION_PAYLOAD)) { -+ if (payloads.isNotEmpty() && payloads.all { it == SELECTION_PAYLOAD || it == SELECTION_ECHO_PAYLOAD }) { - val item = itemAt(position) ?: return -+ if (payloads.contains(SELECTION_ECHO_PAYLOAD)) holder.rowView.bindStableSummary(item) - holder.rowView.bindSelection( - item, - theme, - layout, - position, -- selectedKeys.contains(item.key), -+ item.json.optBoolean("selected", false) || selectedKeys.contains(item.key), - checkboxState, - ) - return -@@ -158,8 +163,16 @@ internal class NativeListAdapter( - - override fun areContentsTheSame(oldItem: NativeListItem, newItem: NativeListItem): Boolean = - oldItem.revision == newItem.revision && oldItem.content == newItem.content -+ -+ // OneKey patch: a theme/layout update or a diff spanning another baseline stays a full bind. -+ override fun getChangePayload(oldItem: NativeListItem, newItem: NativeListItem): Any? = -+ if (oldItem.key == newItem.key && oldItem.type == newItem.type && -+ newItem.selectionUpdateFromContent == oldItem.content -+ ) SELECTION_ECHO_PAYLOAD else null - } - } - } - - internal const val SELECTION_PAYLOAD = "selection" -+// OneKey patch: internal payload, never exposed through the serialized row contract. -+internal const val SELECTION_ECHO_PAYLOAD = "selectionEcho" -diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt -index d8300e2..a129837 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt -+++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListModels.kt -@@ -11,6 +11,12 @@ internal data class NativeListItem( - val json: JSONObject, - ) { - val content: String = json.toString() -+ // OneKey patch: only a host-validated stable snapshot can request a lightweight diff payload. -+ var selectionUpdateFromContent: String? = null -+ -+ // OneKey patch: migrated selectors keep source dimensions on narrow Android screens. -+ val usesSelectorSourceScale: Boolean -+ get() = if (type == "walletGroup") json.optJSONObject("parent")?.has("height") == true else json.has("height") && json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") - - val isSelectable: Boolean - get() = !json.optBoolean("disabled", false) && type in SELECTABLE_TYPES -diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt -index db47e5e..94a87ef 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt -+++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListRowView.kt -@@ -29,6 +29,12 @@ import android.widget.LinearLayout - import android.widget.ProgressBar - import android.widget.TextView - import com.facebook.react.uimanager.ThemedReactContext -+// OneKey patch: selector checkboxes share the source React Native border/background renderer. -+import com.facebook.react.uimanager.BackgroundStyleApplicator -+import com.facebook.react.uimanager.LengthPercentage -+import com.facebook.react.uimanager.LengthPercentageType -+import com.facebook.react.uimanager.style.BorderRadiusProp -+import com.facebook.react.uimanager.style.LogicalEdge - import com.margelo.nitro.onekeyimage.OneKeyImageReusableView - import androidx.core.graphics.PathParser - import androidx.core.widget.TextViewCompat -@@ -42,6 +48,7 @@ internal data class NativeListActionOrigin( - val bindingEpoch: Long, - val source: String, - val slot: Int? = null, -+ val anchorInsetPixels: Int = 0, - ) - - /** React Native color strings use CSS #RRGGBBAA ordering; Android expects #AARRGGBB. */ -@@ -96,7 +103,20 @@ internal object NativeListFonts { - - internal data class NativeSelectionTarget(val scope: String, val key: String?) - -+// OneKey patch: match React Native's CustomLineHeightSpan for first/last line bounds. -+private class SelectorLineHeightSpan(private val lineHeight: Int) : android.text.style.LineHeightSpan { -+ override fun chooseHeight(text: CharSequence, start: Int, end: Int, spanstartv: Int, v: Int, fm: Paint.FontMetricsInt) { -+ val leading = lineHeight - (fm.descent - fm.ascent) -+ fm.ascent -= kotlin.math.ceil(leading / 2.0).toInt() -+ fm.descent += kotlin.math.floor(leading / 2.0).toInt() -+ if (start == 0) fm.top = fm.ascent -+ if (end == text.length) fm.bottom = fm.descent -+ } -+} -+ - private class DottedUnderlineTextView(context: android.content.Context) : TextView(context) { -+ var useSourceScale = false -+ private fun scaledDp(value: Float) = if (useSourceScale) value * resources.displayMetrics.density else NativeListScale.dp(resources, value) - var showsDottedUnderline = false - var dottedUnderlineColor = Color.TRANSPARENT - private val dottedPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } -@@ -105,11 +125,11 @@ private class DottedUnderlineTextView(context: android.content.Context) : TextVi - super.onDraw(canvas) - if (!showsDottedUnderline || text.isEmpty()) return - dottedPaint.color = dottedUnderlineColor -- val radius = NativeListScale.dp(resources, 0.75f) -- val spacing = NativeListScale.dp(resources, 4f) -+ val radius = scaledDp(0.75f) -+ val spacing = scaledDp(4f) - val lineWidth = paint.measureText(text.toString()).coerceAtMost(width.toFloat()) - val y = height - radius -- var x = NativeListScale.dp(resources, 1f) -+ var x = scaledDp(1f) - while (x <= lineWidth - radius) { - canvas.drawCircle(x, y, radius, dottedPaint) - x += spacing -@@ -160,6 +180,29 @@ private class PackedTitleLineLayout(context: android.content.Context) : LinearLa - } - } - -+// OneKey patch: fit subtitle segments at intrinsic width, shrinking text only when necessary. -+private class SelectorSubtitleLayout(context: android.content.Context) : LinearLayout(context) { -+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { -+ val available = MeasureSpec.getSize(widthMeasureSpec) -+ val labels = mutableListOf>() -+ var fixedWidth = 0 -+ for (index in 0 until childCount) { -+ val child = getChildAt(index) -+ val params = child.layoutParams as LayoutParams -+ if (child is TextView) { -+ child.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), heightMeasureSpec) -+ labels.add(child to child.measuredWidth) -+ } else fixedWidth += params.width + params.leftMargin + params.rightMargin -+ } -+ val desired = labels.sumOf { it.second } -+ val textWidth = (available - fixedWidth).coerceAtLeast(0) -+ labels.forEach { (label, width) -> -+ (label.layoutParams as LayoutParams).width = if (desired <= textWidth) width else (width.toLong() * textWidth / desired.coerceAtLeast(1)).toInt() -+ } -+ super.onMeasure(widthMeasureSpec, heightMeasureSpec) -+ } -+} -+ - private class NativeListTableColumnView(context: android.content.Context) : LinearLayout(context) { - private val primaryLine = LinearLayout(context) - private val primary = TextView(context) -@@ -282,6 +325,21 @@ internal class NativeListRowView( - private val reactContext: ThemedReactContext, - ) : LinearLayout(reactContext) { - private val leadingFrame = FrameLayout(context) -+ // OneKey patch: reset selector fragments and corner decorations on every bind. -+ private val selectorViews = mutableListOf() -+ private var selectorUsesSourceScale = false -+ private val selectorAccessibilityDelegate = object : View.AccessibilityDelegate() { -+ override fun onInitializeAccessibilityNodeInfo(host: View, info: android.view.accessibility.AccessibilityNodeInfo) { -+ super.onInitializeAccessibilityNodeInfo(host, info) -+ info.viewIdResourceName = host.getTag(com.facebook.react.R.id.react_test_id) as? String -+ } -+ } -+ private val selectorOriginalFontFeatures = mutableMapOf() -+ private val selectorOriginalPaintFlags = mutableMapOf() -+ private val selectorLineHeights = mutableMapOf() -+ private val selectorFontSizes = mutableMapOf() -+ private val selectorImages = mutableListOf() -+ private var selectorHeight: Int? = null - private val leadingImages = List(3) { OneKeyImageReusableView(reactContext) } - private val leadingOverlayBackground = View(context) - private val leadingCornerIconFrame = FrameLayout(context) -@@ -334,16 +392,22 @@ internal class NativeListRowView( - private var walletGroupExpandAnimator: ValueAnimator? = null - private var isMediaTile = false - private var boundKey: String? = null -+ // OneKey patch: delayed retries cannot survive cell rebinding or recycling. -+ private val selectorImageRetries = mutableMapOf() - var bindingEpoch: Long = 0 - private set - private var boundCheckboxData: JSONObject? = null - private var currentLayout = "linear" - private var restingRowBackground: Drawable? = null - private var pressedRowBackground: Drawable? = null -+ // OneKey patch: preserve a held row independently from RecyclerView snapshot rebinding. -+ private var touchPressed = false - private var reorderActive = false - private var checkboxCheckedColor = Color.rgb(32, 32, 32) - private var checkboxUncheckedColor = Color.rgb(252, 252, 252) - private var checkboxBorderColor = Color.rgb(206, 206, 206) -+ private var checkboxIconColor = Color.rgb(252, 252, 252) -+ private var checkboxUsesSelectorStyle = false - private var iconSubduedColor = Color.rgb(141, 141, 141) - private var visualBackdropColor = Color.WHITE - private val circleOutlineProvider = object : ViewOutlineProvider() { -@@ -445,6 +509,7 @@ internal class NativeListRowView( - setOnTouchListener { _, event -> - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> if (isEnabled) { -+ touchPressed = true - if ((tag as? NativeListItem)?.type == "mediaTile") { - leadingFrame.alpha = 0.8f - } else { -@@ -454,15 +519,20 @@ internal class NativeListRowView( - MotionEvent.ACTION_MOVE -> if ( - event.x < 0 || event.y < 0 || event.x >= width || event.y >= height - ) { -+ touchPressed = false -+ restoreRestingBackground() -+ } -+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { -+ touchPressed = false - restoreRestingBackground() - } -- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> restoreRestingBackground() - } - false - } - setOnClickListener { view -> - (view.tag as? NativeListItem)?.let { item -> -- onRowPress?.invoke(item, actionOrigin(view, "row")) -+ // OneKey patch: allow create-address accessories when whole-row press is gated. -+ if (!item.json.optBoolean("pressDisabled", false)) onRowPress?.invoke(item, actionOrigin(view, "row")) - } - } - setWillNotDraw(false) -@@ -509,13 +579,64 @@ internal class NativeListRowView( - } - } - -+ // OneKey patch: RecyclerView may replace item delegates during a selection update. -+ override fun onInitializeAccessibilityNodeInfo(info: android.view.accessibility.AccessibilityNodeInfo) { -+ super.onInitializeAccessibilityNodeInfo(info) -+ info.viewIdResourceName = getTag(com.facebook.react.R.id.react_test_id) as? String -+ } -+ - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - if (isMediaTile) { - val availableWidth = (MeasureSpec.getSize(widthMeasureSpec) - paddingLeft - paddingRight) - .coerceAtLeast(0) - leadingFrame.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, availableWidth) - } -- super.onMeasure(widthMeasureSpec, heightMeasureSpec) -+ // OneKey patch: RecyclerView must use the adapter's measured selector height. -+ super.onMeasure(widthMeasureSpec, selectorHeight?.let { MeasureSpec.makeMeasureSpec(it, MeasureSpec.EXACTLY) } ?: heightMeasureSpec) -+ val item = tag as? NativeListItem ?: return -+ if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && item.type in setOf("identity", "sectionHeader") && checkbox.visibility == VISIBLE && trailingColumn.parent === this) { -+ // OneKey patch: Yoga snaps the compound accessory before its children; their rounded edges can overflow it. -+ val density = resources.displayMetrics.density -+ val visibleValues = trailingViews.filter { it.visibility == VISIBLE } -+ val sourceTrailingWidth = visibleValues.sumOf { it.measuredWidth } + (20 + 12 * visibleValues.size) * density -+ val sourceTrailingLeft = (measuredWidth - 12 * density - sourceTrailingWidth).roundToInt() -+ val measuredTrailingLeft = measuredWidth - paddingRight - trailingColumn.measuredWidth -+ val mainWidth = (mainColumn.measuredWidth + sourceTrailingLeft - measuredTrailingLeft).coerceAtLeast(0) -+ mainColumn.measure(MeasureSpec.makeMeasureSpec(mainWidth, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(mainColumn.measuredHeight, MeasureSpec.EXACTLY)) -+ } -+ } -+ -+ // OneKey patch: Yoga rounds a half-pixel text center upward; LinearLayout truncates it. -+ override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { -+ super.onLayout(changed, left, top, right, bottom) -+ val item = tag as? NativeListItem ?: return -+ val accessory = item.json.optJSONArray("trailing")?.optJSONObject(0) -+ if (item.type == "identity" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && accessory?.optString("kind") == "icon" && accessory.optString("name") == "PlusSmallOutline") { -+ // OneKey patch: the borderless Plus retains the source's fixed top18/negative7 slot. -+ val icon = trailingIcons[0] -+ trailingColumn.offsetTopAndBottom(dp(18) - dp(7) - trailingColumn.top - icon.top) -+ } -+ if (item.type == "action" && item.json.has("height") && item.json.optString("presentation") == "accountSelector" && leadingIcon.visibility == VISIBLE) { -+ // OneKey patch: Yoga rounds the 4dp padding inside the 32dp Add account icon upward. -+ leadingIcon.offsetLeftAndRight((leadingFrame.width - leadingIcon.width + 1) / 2 - leadingIcon.left) -+ leadingIcon.offsetTopAndBottom((leadingFrame.height - leadingIcon.height + 1) / 2 - leadingIcon.top) -+ } -+ if (!item.json.has("height")) return -+ val isNetworkIdentity = item.type == "identity" && item.json.optString("presentation") == "networkSelector" -+ val isAccountAction = item.type == "action" && item.json.optString("presentation") == "accountSelector" -+ val isNetworkSummary = item.type == "sectionHeader" && item.json.optString("presentation") == "networkSelector" && item.json.optString("variant") == "summary" -+ val centeredColumns = when { -+ isNetworkIdentity || isAccountAction -> listOf(mainColumn, trailingColumn) -+ isNetworkSummary -> listOf(trailingColumn) -+ else -> return -+ } -+ for (column in centeredColumns) { -+ if (column.parent !== this || column.visibility == GONE) continue -+ val margins = column.layoutParams as MarginLayoutParams -+ val available = height - paddingTop - paddingBottom - margins.topMargin - margins.bottomMargin -+ val desiredTop = paddingTop + margins.topMargin + (available - column.height + 1) / 2 -+ column.offsetTopAndBottom(desiredTop - column.top) -+ } - } - - fun bind( -@@ -526,12 +647,16 @@ internal class NativeListRowView( - itemIndex: Int?, - selected: Boolean, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, -+ useSourceScale: Boolean = false, - ) { -+ val shouldRestorePressed = touchPressed && boundKey == item.key - invalidateCurrentBinding() - bindingEpoch += 1 - boundKey = item.key -+ touchPressed = shouldRestorePressed - currentLayout = layout - tag = item -+ selectorUsesSourceScale = item.usesSelectorSourceScale || useSourceScale - reorderActive = false - leadingImages.forEach(OneKeyImageReusableView::prepareForReuse) - secondaryImage.prepareForReuse() -@@ -544,12 +669,21 @@ internal class NativeListRowView( - val accent = color(theme, "accent", "#0D8200FC") - checkboxCheckedColor = primary - checkboxUncheckedColor = color(theme, "inverseText", "#FCFCFC") -+ checkboxIconColor = checkboxUncheckedColor -+ checkboxUsesSelectorStyle = item.json.optString("presentation") == "networkSelector" - checkboxBorderColor = Color.argb( - 0x31, - 0, - 0, - 0, - ) -+ if (item.json.optString("presentation") == "networkSelector") { -+ checkboxCheckedColor = color(theme, "checkboxBackground", "#202020") -+ checkboxBorderColor = color(theme, "checkboxBorder", "#00000031") -+ checkboxIconColor = color(theme, "checkboxIcon", "#FFFFFF") -+ // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse. -+ checkboxUncheckedColor = checkboxIconColor -+ } - iconSubduedColor = color(theme, "iconSubdued", "#00000072") - visualBackdropColor = color(theme, "rowBackground", "#FFFFFF") - unreadDot.background = roundedFill( -@@ -569,7 +703,7 @@ internal class NativeListRowView( - if (item.type == "rail") "#0000000F" else "#00000017", - ), - ) -- background = restingRowBackground -+ background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground - if (layout == "table") { - if (item.type == "dataRow") { - setPadding(dp(20), dp(10), dp(20), dp(10)) -@@ -592,8 +726,14 @@ internal class NativeListRowView( - invalidate() - trailingViews.forEach { it.setTextColor(primary) } - isEnabled = !item.json.optBoolean("disabled", false) -- alpha = if (isEnabled) 1f else 0.5f -+ if (!isEnabled) touchPressed = false -+ background = if (touchPressed || reorderActive) pressedRowBackground else restingRowBackground -+ // OneKey patch: deprecation dims the row without disabling menu controls. -+ // alpha = if (isEnabled) 1f else 0.5f -+ alpha = item.json.optDouble("opacity", 1.0).toFloat() * (if (isEnabled) 1f else 0.5f) - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) -+ // OneKey patch: retain stable original selector test identifiers. -+ setTag(com.facebook.react.R.id.react_test_id, item.json.optString("testID").takeIf { it.isNotEmpty() }) - - when (item.type) { - "walletGroup" -> bindWalletGroup(item, theme, layout, listOrientation, checkboxState) -@@ -609,10 +749,34 @@ internal class NativeListRowView( - "system" -> bindSystem(item, theme) - } - applySize(item) -+ if (item.type == "system" && item.json.optString("variant") == "warning") { -+ title.typeface = NativeListFonts.medium(context) -+ title.textSize = sp(14f) -+ subtitle.textSize = sp(14f) -+ TextViewCompat.setLineHeight(title, dp(20)) -+ TextViewCompat.setLineHeight(subtitle, dp(20)) -+ minimumHeight = 0 -+ } - applyListOrientation(item, listOrientation) -+ applySelectorTypography(item) -+ } -+ -+ // OneKey patch: keep a small idle member pool after reuse or a direct rebind. -+ // The compact proxy/expansion keeps every member until it leaves that state. -+ private fun trimWalletGroupRows(required: Int) { -+ val retained = maxOf(8, required) -+ while (walletGroupRows.size > retained) { -+ val row = walletGroupRows.removeAt(walletGroupRows.lastIndex) -+ (row.parent as? ViewGroup)?.removeView(row) -+ row.dispose() -+ row.onRowPress = null -+ row.onAction = null -+ row.onBindingInvalidated = null -+ } - } - - fun recycle() { -+ touchPressed = false - restoreRestingBackground() - invalidateCurrentBinding() - boundKey = null -@@ -625,6 +789,7 @@ internal class NativeListRowView( - row.alpha = 1f - row.recycle() - } -+ if (!reorderActive && walletGroupExpandAnimator?.isRunning != true) trimWalletGroupRows(0) - } - - fun bindSelection( -@@ -636,6 +801,8 @@ internal class NativeListRowView( - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String, - ) { - if (boundKey != item.key) return -+ // OneKey patch: keep row callbacks and checkbox fallback state current without resetting images. -+ tag = item - if (item.type == "walletGroup") { - val members = buildList { - add(item.json.getJSONObject("parent")) -@@ -669,7 +836,18 @@ internal class NativeListRowView( - ), - ) - } -- boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } -+ // OneKey patch: the comparator permits state changes only on these existing checkbox slots. -+ // boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } -+ if (boundCheckboxData != null) { -+ val latestCheckbox = if (item.type == "identity") { -+ item.json.optJSONArray("trailing")?.let { trailing -> -+ (0 until trailing.length()).mapNotNull { trailing.optJSONObject(it) } -+ .lastOrNull { it.optString("kind") == "checkbox" } -+ } -+ } else item.json.optJSONObject("checkbox") -+ boundCheckboxData = latestCheckbox ?: boundCheckboxData -+ boundCheckboxData?.let { bindCheckbox(item, it, checkboxState) } -+ } - } - - fun bindStableSummary(item: NativeListItem) { -@@ -684,10 +862,14 @@ internal class NativeListRowView( - contentDescription = item.json.optString("accessibilityLabel", item.json.optString("title")) - title.text = item.json.optString("title") - trailingViews[0].text = item.json.optString("value") -+ applySelectorTypography(item) - } - - fun dispose() { -+ invalidateCurrentBinding() - restoreRestingBackground() -+ selectorImages.forEach(OneKeyImageReusableView::dispose) -+ selectorImages.clear() - leadingImages.forEach(OneKeyImageReusableView::dispose) - secondaryImage.dispose() - mediaNetworkImage.dispose() -@@ -699,7 +881,8 @@ internal class NativeListRowView( - sourceView: View, - source: String, - slot: Int? = null, -- ) = NativeListActionOrigin(sourceView, this, bindingEpoch, source, slot) -+ ) = NativeListActionOrigin(sourceView, this, bindingEpoch, source, slot, -+ if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && sourceView in trailingIcons && (sourceView.layoutParams as MarginLayoutParams).marginStart < 0) dp(7) else 0) - - private fun emitAction( - item: NativeListItem, -@@ -712,13 +895,65 @@ internal class NativeListRowView( - onAction?.invoke(item, actionKey, target, actionOrigin(sourceView, source, slot)) - } - -+ // OneKey patch: match SizableText TABULAR_NUMS on dynamic labels without replacing their typeface. -+ private fun applySelectorTypography(item: NativeListItem) { -+ val usesSelectorTypography = item.json.optString("presentation") in setOf("accountSelector", "networkSelector", "walletSidebar") || item.type == "system" && item.json.optString("variant") == "warning" -+ fun visit(view: View) { -+ if (view is OneKeyIconView) view.useSourceScale = selectorUsesSourceScale -+ if (view is DottedUnderlineTextView) view.useSourceScale = selectorUsesSourceScale -+ if (view is TextView && usesSelectorTypography) { -+ val selectorLineHeight = selectorLineHeights.getOrPut(view) { view.lineHeight } -+ val original = view.fontFeatureSettings -+ if (!selectorOriginalFontFeatures.containsKey(view)) selectorOriginalFontFeatures[view] = original -+ view.fontFeatureSettings = if (original.isNullOrEmpty()) "tnum" else if (original.contains("tnum")) original else "$original, 'tnum' 1" -+ // OneKey patch: SizableText disables font scaling and rounds font sizes to whole pixels. -+ val sourceTypography = selectorUsesSourceScale || item.type == "system" && item.json.optString("variant") == "warning" -+ if (sourceTypography) { -+ // OneKey patch: React Native CustomStyleSpan disables hinting and preserves fractional advances. -+ selectorOriginalPaintFlags.putIfAbsent(view, view.paintFlags) -+ view.paintFlags = view.paintFlags or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG -+ val originalSize = selectorFontSizes.getOrPut(view) { view.textSize } -+ val sourceSize = originalSize * resources.displayMetrics.density / resources.displayMetrics.scaledDensity -+ view.setTextSize(android.util.TypedValue.COMPLEX_UNIT_PX, kotlin.math.ceil(sourceSize.toDouble()).toFloat()) -+ view.letterSpacing = 0f -+ } -+ if (sourceTypography && view.text.isNotEmpty()) { -+ val text = SpannableStringBuilder(view.text) -+ text.getSpans(0, text.length, SelectorLineHeightSpan::class.java).forEach(text::removeSpan) -+ text.setSpan(SelectorLineHeightSpan(selectorLineHeight), 0, text.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) -+ view.setLineSpacing(0f, 1f) -+ view.text = text -+ } -+ } -+ if (view is ViewGroup) for (index in 0 until view.childCount) visit(view.getChildAt(index)) -+ } -+ visit(this) -+ } -+ - private fun invalidateCurrentBinding() { -+ selectorImageRetries.forEach { (image, retry) -> image.removeCallbacks(retry) } -+ selectorImageRetries.clear() - if (boundKey == null) return - onBindingInvalidated?.invoke(this, bindingEpoch) - bindingEpoch += 1 - } - - private fun resetViews() { -+ clipToPadding = true -+ selectorOriginalFontFeatures.forEach { (view, original) -> view.fontFeatureSettings = original } -+ selectorOriginalFontFeatures.clear() -+ selectorOriginalPaintFlags.forEach { (view, flags) -> view.paintFlags = flags } -+ selectorOriginalPaintFlags.clear() -+ selectorLineHeights.clear() -+ selectorFontSizes.clear() -+ // OneKey patch: selector-only views cannot survive a recycled binding. -+ selectorViews.forEach { (it.parent as? ViewGroup)?.removeView(it) } -+ selectorViews.clear() -+ selectorImages.forEach(OneKeyImageReusableView::dispose) -+ selectorImages.clear() -+ selectorHeight = null -+ title.setOnClickListener(null) -+ title.isClickable = false - walletGroupRows.forEach { it.invalidateCurrentBinding() } - walletGroupExpandAnimator?.removeAllListeners() - walletGroupExpandAnimator?.cancel() -@@ -736,6 +971,11 @@ internal class NativeListRowView( - activityContentRow.removeAllViews() - (actionLine.parent as? ViewGroup)?.removeView(actionLine) - removeAllViews() -+ // OneKey patch: the old animator is cancelled and old children are detached. -+ // Do not trim currently needed members when re-binding an expanded group. -+ val nextItem = tag as? NativeListItem -+ val required = if (nextItem?.type == "walletGroup") (nextItem.json.optJSONArray("children")?.length() ?: 0) + 1 else 0 -+ trimWalletGroupRows(required) - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - minimumHeight = 0 -@@ -801,6 +1041,7 @@ internal class NativeListRowView( - trailingColumn.layoutParams = wrap() - trailingViews.forEach { - it.visibility = GONE -+ it.setTag(com.facebook.react.R.id.react_test_id, null) - it.gravity = Gravity.END - it.maxLines = 1 - it.layoutParams = wrap() -@@ -879,7 +1120,8 @@ internal class NativeListRowView( - mainColumn.removeView(skeletonSecondary) - setOnClickListener { view -> - (view.tag as? NativeListItem)?.let { item -> -- onRowPress?.invoke(item, actionOrigin(view, "row")) -+ // OneKey patch: allow create-address accessories when whole-row press is gated. -+ if (!item.json.optBoolean("pressDisabled", false)) onRowPress?.invoke(item, actionOrigin(view, "row")) - } - } - } -@@ -1003,9 +1245,12 @@ internal class NativeListRowView( - members.add(children.getJSONObject(index)) - } - } -+ if (members.first().has("height")) setPadding(dp(1), dp(1), dp(1), dp(1)) - walletGroupDragChildCount = members.size - 1 - walletGroupExpandedHeightPx = -- dp(members.size * 68 + walletGroupDragChildCount * 12) -+ // OneKey patch: expanded groups include individual wallet badge heights. -+ // dp(members.size * 68 + walletGroupDragChildCount * 12) -+ dp(members.sumOf { it.optInt("height", if ((it.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) } + walletGroupDragChildCount * 12 + if (members.first().has("height")) 2 else 0) - walletGroupDragBadgeBackgroundPaint.color = color( - theme, - "inverseBackground", -@@ -1048,8 +1293,11 @@ internal class NativeListRowView( - null, - memberJson.optBoolean("selected", false), - checkboxState, -+ useSourceScale = selectorUsesSourceScale, - ) -- memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(68)).apply { -+ // OneKey patch: member geometry matches the outer group height calculation. -+ // memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(68)).apply { -+ memberRow.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(memberJson.optInt("height", if ((memberJson.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68))).apply { - if (index > 0) topMargin = dp(12) - } - addView(memberRow) -@@ -1082,8 +1330,11 @@ internal class NativeListRowView( - titleLine.gravity = Gravity.CENTER - titleLine.packsChildrenAtStart = false - titleLine.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) -- title.layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) -+ // OneKey patch: the original wallet title ellipsizes within the full inner row width. -+ title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - title.gravity = Gravity.CENTER -+ // OneKey patch: this branch returns before the common identity ellipsis setup. -+ if (item.json.has("height")) title.ellipsize = TextUtils.TruncateAt.END - showText(title, item.json.optString("title"), 1) - title.setTextColor( - color( -@@ -1095,13 +1346,41 @@ internal class NativeListRowView( - addView( - mainColumn, - LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { -- topMargin = dp(4) -+ // OneKey patch: snap the source 4 + 40 + 4 sequence once instead of rounding each gap. -+ topMargin = if (item.json.has("height")) dp(48) - dp(44) else dp(4) - }, - ) -+ // OneKey patch: wallet tags are a centered line below the wallet name. -+ item.json.optJSONArray("badges")?.takeIf { it.length() > 0 }?.let { badges -> -+ val isSelector = item.json.has("height") -+ val badgeLineHeight = if (isSelector) 14 else 16 -+ val badgeHeight = badgeLineHeight + 4 -+ val line = LinearLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER } -+ for (index in 0 until badges.length()) { -+ val badge = TextView(context).apply { -+ text = badges.getJSONObject(index).optString("text") -+ textSize = sp(if (isSelector) 11f else 12f) -+ typeface = NativeListFonts.regular(context) -+ includeFontPadding = false -+ maxLines = 1 -+ ellipsize = TextUtils.TruncateAt.END -+ val warning = isSelector && badges.getJSONObject(index).optString("tone") == "warning" -+ setTextColor(color(theme, if (warning) "caution" else "secondaryText", if (warning) "#AB6400" else "#0000009B")) -+ background = roundedFill(color(theme, if (warning) "cautionBackground" else if (isSelector) "subduedBackground" else "strongBackground", if (warning) "#FFF4D5" else "#00000006"), 4f) -+ setPadding(dp(if (isSelector) 6 else 4), dp(2), dp(if (isSelector) 6 else 4), dp(2)) -+ } -+ TextViewCompat.setLineHeight(badge, dp(badgeLineHeight)) -+ line.addView(badge, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { if (index > 0) marginStart = dp(4) }) -+ } -+ mainColumn.addView(line, LayoutParams(LayoutParams.WRAP_CONTENT, dp(badgeHeight)).apply { topMargin = dp(4) }) -+ selectorViews.add(line) -+ } - return - } - if (item.json.optString("presentation") == "networkSelector") { -- setPadding(dp(12), dp(7), dp(12), dp(8)) -+ // OneKey patch: the explicit 48-point row centers its 32-point network icon. -+ // setPadding(dp(12), dp(7), dp(12), dp(8)) -+ setPadding(dp(12), dp(if (item.json.has("height")) 8 else 7), dp(12), dp(8)) - } - val leading = item.json.optJSONObject("leading") - item.json.optJSONObject("leadingAction")?.let { action -> -@@ -1136,6 +1415,13 @@ internal class NativeListRowView( - item.json.optString("presentation") == "accountSelector" - ) 32 else 40, - ) -+ // OneKey patch: custom network initials retain LetterAvatar typography. -+ if (item.json.optString("presentation") == "networkSelector" && leading?.optJSONObject("image") == null && leading?.optJSONObject("fallbackIcon") == null && !leading?.optString("fallbackText").isNullOrEmpty()) { -+ leadingFallback.textSize = sp(19f) -+ leadingFallback.typeface = NativeListFonts.semibold(context) -+ leadingFallback.setTextColor(color(theme, "inverseText", "#FCFCFC")) -+ TextViewCompat.setLineHeight(leadingFallback, dp(27)) -+ } - addView(mainColumn, weighted()) - titleLine.packsChildrenAtStart = true - title.ellipsize = TextUtils.TruncateAt.END -@@ -1144,6 +1430,43 @@ internal class NativeListRowView( - title.typeface = NativeListFonts.regular(context) - } - showText(subtitle, item.json.optString("subtitle"), item.json.optInt("subtitleLines", 2)) -+ // OneKey patch: use separate labels for independently truncated balance/address. -+ item.json.optJSONArray("subtitleSegments")?.takeIf { it.length() > 0 }?.let { segments -> -+ subtitle.visibility = GONE -+ val line = SelectorSubtitleLayout(context).apply { orientation = HORIZONTAL; gravity = Gravity.CENTER_VERTICAL } -+ for (index in 0 until segments.length()) { -+ val segment = segments.getJSONObject(index) -+ if (segment.optBoolean("separatorBefore", false)) { -+ val dot = View(context).apply { background = roundedFill(color(theme, "disabledText", "#00000072"), 2f) } -+ line.addView(dot, LayoutParams(dp(4), dp(4)).apply { marginStart = dp(6); marginEnd = dp(6) }) -+ } -+ val label = TextView(context).apply { -+ text = segment.optString("text") -+ typeface = NativeListFonts.regular(context) -+ textSize = sp(14f) -+ includeFontPadding = false -+ maxLines = 1 -+ ellipsize = TextUtils.TruncateAt.END -+ val toneKey = when (segment.optString("tone")) { "primary" -> "primaryText"; "disabled" -> "disabledText"; "caution" -> "caution"; "positive" -> "positive"; "negative" -> "negative"; else -> "secondaryText" } -+ setTextColor(color(theme, toneKey, if (toneKey == "caution") "#AB6400" else "#0000009B")) -+ } -+ TextViewCompat.setLineHeight(label, dp(20)) -+ applyValueSegments(label, segment.optJSONArray("textSegments"), 14, 20, false) -+ line.addView(label, LayoutParams(LayoutParams.WRAP_CONTENT, dp(20))) -+ } -+ mainColumn.addView(line, 2, LayoutParams(LayoutParams.MATCH_PARENT, dp(20))) -+ selectorViews.add(line) -+ } -+ item.json.optJSONArray("titleMatch")?.takeIf { it.length() > 0 }?.let { matches -> -+ val highlighted = SpannableStringBuilder(title.text) -+ for (index in 0 until matches.length()) { -+ val match = matches.getJSONObject(index) -+ val start = match.optInt("start") -+ val end = match.optInt("end") -+ if (start >= 0 && end > start && end <= highlighted.length) highlighted.setSpan(ForegroundColorSpan(color(theme, "info", "#0D74CE")), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) -+ } -+ title.text = highlighted -+ } - showText(tertiary, item.json.optString("tertiary"), 1) - tertiary.setTextColor( - color( -@@ -1164,6 +1487,10 @@ internal class NativeListRowView( - } - addView(trailingColumn, wrap()) - val accessories = item.json.optJSONArray("trailing") -+ if (item.json.has("height") && item.json.optString("presentation") == "networkSelector" && accessories.hasAccessory("checkbox")) { -+ // OneKey patch: retain ListItem's title-to-accessory gap when measuring truncation. -+ (mainColumn.layoutParams as LayoutParams).marginEnd = dp(12) -+ } - if (accessories.hasAccessory("checkbox") && accessories.hasAccessory("value")) { - trailingColumn.orientation = HORIZONTAL - trailingColumn.gravity = Gravity.END or Gravity.CENTER_VERTICAL -@@ -1847,10 +2174,15 @@ internal class NativeListRowView( - val isSummary = variant == "summary" - val isGallery = variant == "gallery" - val isTable = currentLayout == "table" -+ // OneKey patch: title help emits its own frame instead of toggling the section. -+ item.json.optString("titleActionKey").takeIf { it.isNotEmpty() }?.let { key -> -+ title.setOnClickListener { emitAction(item, key, null, title, "leadingAction") } -+ } - val isNetworkSelector = item.json.optString("presentation") == "networkSelector" - val isHistory = variant == "history" || item.sectionKey?.startsWith("history-") == true - val isTokenManager = item.sectionKey in setOf("linear-tokens", "action-tokens") -- val hasDottedTitle = isSummary || isNetworkSelector || -+ val isExplicitNetworkHeader = isNetworkSelector && item.json.has("height") -+ val hasDottedTitle = isSummary || (isNetworkSelector && (!isExplicitNetworkHeader || item.json.optString("titleActionKey").isNotEmpty())) || - (item.json.optString("value").isNotEmpty() && item.json.optJSONObject("checkbox") != null) - // Linear/sectioned snapshots reserve the ListItem mx=8 at RecyclerView - // level, so header-local insets below are source px minus that outer inset. -@@ -1870,7 +2202,8 @@ internal class NativeListRowView( - ), - ) - if (isNetworkSelector) { -- setPadding(dp(headerHorizontalInset), dp(12), dp(headerHorizontalInset), dp(12)) -+ val verticalInset = if (isExplicitNetworkHeader && item.json.optString("titleActionKey").isEmpty()) 8 else 12 -+ setPadding(dp(headerHorizontalInset), dp(verticalInset), dp(headerHorizontalInset), dp(verticalInset)) - title.textSize = sp(14f) - title.typeface = NativeListFonts.medium(context) - TextViewCompat.setLineHeight(title, dp(20)) -@@ -1924,14 +2257,22 @@ internal class NativeListRowView( - item.json.optString("valueActionKey"), - color(theme, "secondaryText", "#0000009B"), - ) -+ trailingViews[0].setTag(com.facebook.react.R.id.react_test_id, item.json.optString("valueActionTestID").takeIf { it.isNotEmpty() }) -+ trailingViews[0].accessibilityDelegate = selectorAccessibilityDelegate - trailingViews[0].textSize = sp(16f) - trailingViews[0].typeface = NativeListFonts.medium(context) - TextViewCompat.setLineHeight(trailingViews[0], dp(24)) -- trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) -+ // OneKey patch: the migrated media button shares the original 24-point text box. -+ if (isExplicitNetworkHeader) trailingViews[0].setPadding(0, 0, 0, 0) -+ else trailingViews[0].setPadding(dp(14), dp(6), dp(14), dp(6)) - trailingViews[0].layoutParams = wrap() - } else if (!isGallery && !isHistory && !isTokenManager) { - val value = item.json.optString("value") - val checkboxData = item.json.optJSONObject("checkbox") -+ if (isExplicitNetworkHeader && checkboxData != null) { -+ // OneKey patch: the asset section title reserves its original 8dp trailing margin. -+ (mainColumn.layoutParams as LayoutParams).marginEnd = dp(8) -+ } - if (checkboxData != null && value.isNotEmpty()) { - // Value and checkbox share the trailing edge as one compound accessory. - trailingColumn.orientation = HORIZONTAL -@@ -1966,6 +2307,27 @@ internal class NativeListRowView( - } - checkboxData?.let { bindCheckbox(item, it, checkboxState) } - } -+ applyValueSegments(trailingViews[0], item.json.optJSONArray("valueSegments")) -+ } -+ -+ // OneKey patch: preserve compact zero-count digits without changing their baseline. -+ private fun applyValueSegments(view: TextView, segments: JSONArray?, fontSize: Int = 16, lineHeight: Int = 24, medium: Boolean = true) { -+ if (segments == null || segments.length() == 0) return -+ val value = SpannableStringBuilder() -+ for (index in 0 until segments.length()) { -+ val segment = segments.getJSONObject(index) -+ val start = value.length -+ value.append(segment.optString("text")) -+ if (segment.optString("style") == "subscript") { -+ val size = kotlin.math.ceil(fontSize * 0.6).toFloat() -+ val span = if (selectorUsesSourceScale) AbsoluteSizeSpan(kotlin.math.ceil((size * resources.displayMetrics.density).toDouble()).toInt(), false) else AbsoluteSizeSpan(sp(size).roundToInt(), true) -+ value.setSpan(span, start, value.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) -+ } -+ } -+ view.textSize = sp(fontSize.toFloat()) -+ view.typeface = if (medium) NativeListFonts.medium(context) else NativeListFonts.regular(context) -+ TextViewCompat.setLineHeight(view, dp(lineHeight)) -+ view.text = value - } - - private fun bindAction( -@@ -1978,12 +2340,13 @@ internal class NativeListRowView( - addLeading(icon, if (isAccountSelector) 32 else 40) - leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) - if (!icon.has("backgroundColor")) leadingFrame.background = null -+ if (isAccountSelector) leadingFrame.background = roundedFill(safeColor(icon.optString("backgroundColor"), color(theme, "strongBackground", "#0000000F")), 8f) - } - addView(mainColumn, weighted()) - showText(title, item.json.optString("title"), 1) - if (isAccountSelector) { -- title.typeface = NativeListFonts.regular(context) -- title.setTextColor(color(theme, "secondaryText", "#0000009B")) -+ title.typeface = if (item.json.has("icon")) NativeListFonts.medium(context) else NativeListFonts.regular(context) -+ title.setTextColor(color(theme, if (item.json.optString("tone") == "primary") "primaryText" else "secondaryText", "#0000009B")) - } else if (item.json.optString("tone") == "danger") { - title.setTextColor(color(theme, "negative", "#C40006D3")) - } -@@ -2003,6 +2366,17 @@ internal class NativeListRowView( - - private fun bindSystem(item: NativeListItem, theme: JSONObject?) { - val variant = item.json.optString("variant") -+ // OneKey patch: warning title/description wrap inside the actual scroll content. -+ if (variant == "warning") { -+ setPadding(dp(12), dp(14), dp(12), dp(14)) -+ addView(mainColumn, weighted()) -+ showText(title, item.json.optString("title"), Int.MAX_VALUE) -+ showText(subtitle, item.json.optString("message"), Int.MAX_VALUE) -+ title.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) -+ subtitle.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).apply { topMargin = dp(4) } -+ titleLine.packsChildrenAtStart = false -+ return -+ } - if (variant == "spacer") { - minimumHeight = dp(item.json.optInt("height", 0)) - return -@@ -2072,7 +2446,11 @@ internal class NativeListRowView( - spacingDp: Int = 12, - ) { - leadingFrame.visibility = VISIBLE -- leadingFrame.layoutParams = LayoutParams(dp(sizeDp), dp(sizeDp)).apply { marginEnd = dp(spacingDp) } -+ leadingFrame.layoutParams = LayoutParams(dp(sizeDp), dp(sizeDp)).apply { -+ val item = tag as? NativeListItem -+ // OneKey patch: Yoga rounds cumulative selector edges, not each 12dp gap separately. -+ marginEnd = if (item?.json?.has("height") == true && item.json.optString("presentation") in setOf("accountSelector", "networkSelector")) dp(12 + sizeDp + spacingDp) - dp(12) - dp(sizeDp) else dp(spacingDp) -+ } - addView(leadingFrame) - leadingFallback.layoutParams = FrameLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) - if (visual == null) return -@@ -2104,7 +2482,7 @@ internal class NativeListRowView( - leadingFrame.background = GradientDrawable().apply { - setColor(visualBackground) - setStroke(1, parseNativeListColor("#0000001F")) -- cornerRadius = NativeListScale.dp(resources, leadingCornerRadius(shape, sizeDp)) -+ cornerRadius = scaledDp(leadingCornerRadius(shape, sizeDp)) - } - leadingIcon.iconName = visual.optString("name") - leadingIcon.tintColor = safeColor( -@@ -2161,7 +2539,89 @@ internal class NativeListRowView( - else -> leadingOutlineProvider(shape) - } - image.clipToOutline = true -- bindImage(source, image, boundKey ?: "", index, variant) -+ val fallbackIcon = if (index == 0) visual.optJSONObject("fallbackIcon") else null -+ val expectedEpoch = bindingEpoch -+ bindImage(source, image, boundKey ?: "", index, variant, -+ onLoad = if (fallbackIcon == null) null else ({ -+ if (bindingEpoch == expectedEpoch) { image.visibility = VISIBLE; leadingIcon.visibility = GONE } -+ }), -+ onError = if (fallbackIcon == null) null else ({ -+ if (bindingEpoch == expectedEpoch) { -+ image.visibility = GONE -+ leadingFallback.visibility = GONE -+ leadingIcon.iconName = fallbackIcon.optString("name") -+ leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B")) -+ leadingIcon.visibility = VISIBLE -+ } -+ }), -+ ) -+ } -+ // OneKey patch: wallet overlays retain source images, provider colors and QR text. -+ visual.optJSONArray("overlays")?.let { overlays -> -+ for (index in 0 until overlays.length()) { -+ val overlay = overlays.getJSONObject(index) -+ val size = overlay.optInt("size", 20) -+ val inset = dp(overlay.optInt("padding", 0)) -+ val isWalletText = selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && overlay.optString("text").isNotEmpty() && overlay.optJSONObject("image") == null && overlay.optString("name").isEmpty() -+ val width = overlay.optInt("width", size) -+ val height = overlay.optInt("height", if (isWalletText) 16 else size) -+ val offsetX = dp(overlay.optInt("offsetX", overlay.optInt("offset", 2))) -+ val offsetY = dp(overlay.optInt("offsetY", overlay.optInt("offset", 2))) -+ val frame = FrameLayout(context).apply { -+ setPadding(if (isWalletText) dp(2) else inset, if (isWalletText) 0 else inset, if (isWalletText) dp(2) else inset, if (isWalletText) 0 else inset) -+ background = roundedFill(safeColor(overlay.optString("backgroundColor"), Color.TRANSPARENT), minOf(width, height) / 2f) -+ outlineProvider = ViewOutlineProvider.BACKGROUND -+ clipToOutline = true -+ } -+ val image = overlay.optJSONObject("image") -+ val view = when { -+ image != null -> OneKeyImageReusableView(reactContext).also { -+ bindImage(image, it, boundKey ?: "", 10 + index, "generic") -+ selectorImages.add(it) -+ } -+ overlay.optString("text").isNotEmpty() -> TextView(context).apply { -+ text = overlay.optString("text") -+ textSize = sp(if (isWalletText) 12f else 10f) -+ typeface = if (isWalletText) NativeListFonts.regular(context) else NativeListFonts.medium(context) -+ includeFontPadding = false -+ gravity = Gravity.CENTER -+ if (isWalletText) TextViewCompat.setLineHeight(this, dp(16)) -+ setTextColor(safeColor(overlay.optString("tintColor"), color(null, "secondaryText", "#0000009B"))) -+ } -+ else -> OneKeyIconView(context).apply { -+ iconName = overlay.optString("name") -+ tintColor = safeColor(overlay.optString("tintColor"), parseNativeListColor("#0000009B")) -+ } -+ } -+ frame.addView(view, FrameLayout.LayoutParams(if (isWalletText && !overlay.has("width")) FrameLayout.LayoutParams.WRAP_CONTENT else FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) -+ val topLeft = overlay.optString("position") == "topLeft" -+ leadingFrame.addView(frame, FrameLayout.LayoutParams(if (isWalletText && !overlay.has("width")) FrameLayout.LayoutParams.WRAP_CONTENT else dp(width), dp(height), if (topLeft) Gravity.START or Gravity.TOP else Gravity.END or Gravity.BOTTOM).apply { -+ if (topLeft) { marginStart = -offsetX; topMargin = -offsetY } else { marginEnd = -offsetX; bottomMargin = -offsetY } -+ }) -+ selectorViews.add(frame) -+ } -+ } -+ visual.optJSONObject("fallbackIcon")?.takeIf { sources.isEmpty() }?.let { fallbackIcon -> -+ leadingFallback.visibility = GONE -+ leadingIcon.visibility = VISIBLE -+ leadingIcon.iconName = fallbackIcon.optString("name") -+ leadingIcon.tintColor = safeColor(fallbackIcon.optString("tintColor"), parseNativeListColor("#0000009B")) -+ if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && leadingIcon.iconName == "PlusSmallOutline") { -+ leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(24), dp(24), Gravity.CENTER) -+ leadingIcon.glyphSizeDp = 24 -+ } -+ if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar" && leadingIcon.iconName == "LockSolid") { -+ leadingIcon.layoutParams = FrameLayout.LayoutParams(dp(40), dp(40), Gravity.CENTER) -+ leadingIcon.glyphSizeDp = 40 -+ } -+ } -+ if (visual.optString("borderStyle") == "dashed") { -+ leadingFrame.background = GradientDrawable().apply { -+ setColor(visualBackground) -+ cornerRadius = dp(sizeDp / 2).toFloat() -+ setStroke(dp(if (selectorUsesSourceScale && (tag as? NativeListItem)?.json?.optString("presentation") == "walletSidebar") 1 else 2), safeColor(visual.optString("borderColor"), parseNativeListColor("#00000072")), dp(4).toFloat(), dp(4).toFloat()) -+ } -+ leadingFallback.background = null - } - } - -@@ -2223,7 +2683,11 @@ internal class NativeListRowView( - for (index in 0 until minOf(2, accessories.length())) { - val accessory = accessories.getJSONObject(index) - when (accessory.optString("kind")) { -- "value" -> showTrailing(textIndex++, accessory.optString("text"), !accessory.optBoolean("secondary", false)) -+ "value" -> { -+ showTrailing(textIndex, accessory.optString("text"), !accessory.optBoolean("secondary", false)) -+ applyValueSegments(trailingViews[textIndex], accessory.optJSONArray("textSegments")) -+ textIndex++ -+ } - "valuePair" -> showTrailingValuePair(textIndex++, accessory, theme) - "checkbox" -> bindCheckbox(item, accessory, checkboxState) - "radio" -> showTrailing( -@@ -2285,11 +2749,25 @@ internal class NativeListRowView( - return - } - checkbox.visibility = VISIBLE -- checkbox.setState(state, checkboxUncheckedColor) -- checkbox.background = if (state == "unchecked") { -- roundedStroke(checkboxBorderColor, checkboxUncheckedColor, 4f) -+ checkbox.setState(state, checkboxIconColor) -+ val usesSourceCheckboxGeometry = checkboxUsesSelectorStyle && item.json.has("height") -+ checkbox.usesSelectorGeometry = usesSourceCheckboxGeometry -+ if (usesSourceCheckboxGeometry) { -+ // OneKey patch: source Yoga children may round into padding; retain their complete border. -+ clipToPadding = false -+ // OneKey patch: even a transparent source border changes RN's background clipping path. -+ checkbox.background = null -+ BackgroundStyleApplicator.setBackgroundColor(checkbox, if (state == "unchecked") checkboxUncheckedColor else checkboxCheckedColor) -+ BackgroundStyleApplicator.setBorderWidth(checkbox, LogicalEdge.ALL, 2f) -+ BackgroundStyleApplicator.setBorderColor(checkbox, LogicalEdge.ALL, if (state == "unchecked") checkboxBorderColor else Color.TRANSPARENT) -+ BackgroundStyleApplicator.setBorderRadius(checkbox, BorderRadiusProp.BORDER_RADIUS, LengthPercentage(4f, LengthPercentageType.POINT)) - } else { -- roundedFill(checkboxCheckedColor, 4f) -+ checkbox.background = if (state == "unchecked") { -+ if (checkboxUsesSelectorStyle) GradientDrawable().apply { setColor(checkboxUncheckedColor); setStroke(dp(2), checkboxBorderColor); cornerRadius = scaledDp(4f) } -+ else roundedStroke(checkboxBorderColor, checkboxUncheckedColor, 4f) -+ } else { -+ roundedFill(checkboxCheckedColor, 4f) -+ } - } - // Row-level disabled opacity already applies to this child. Only apply a - // local 0.5 when the accessory alone is disabled, never 0.5 * 0.5. -@@ -2330,6 +2808,8 @@ internal class NativeListRowView( - } - val groupPosition = when { - item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> "single" -+ // OneKey patch: explicit selector rows preserve the v1 ListItem corner radius. -+ item.type == "identity" && item.json.optString("presentation") in setOf("accountSelector", "networkSelector") && item.json.has("height") -> "single" - else -> when (item.type) { - "metricCard" -> "single" - "rail" -> "rail" -@@ -2337,7 +2817,7 @@ internal class NativeListRowView( - } - } - var backgroundGroupPosition = if (item.type == "mediaTile") "mediaTile" else groupPosition -- if (layout == "sectioned") { -+ if (layout == "sectioned" && !item.json.optBoolean("selected", false)) { - // Selection in sectioned lists is represented by the OneKey checkbox, - // matching iOS and the app-monorepo network selector. - rowBackground = color(theme, "rowBackground", "#FFFFFF") -@@ -2351,6 +2831,8 @@ internal class NativeListRowView( - rowBackground = color(theme, "subduedBackground", "#F9F9F9") - backgroundGroupPosition = "" - } -+ // OneKey patch: section heading backgrounds are independent of list rows. -+ if (item.json.has("backgroundColor")) rowBackground = safeColor(item.json.optString("backgroundColor"), rowBackground) - restingRowBackground = groupedBackground(backgroundGroupPosition, rowBackground) - background = restingRowBackground - } -@@ -2466,7 +2948,26 @@ internal class NativeListRowView( - val icon = trailingIcons[index] - icon.iconName = data.optString("name") - icon.tintColor = safeColor(data.optString("tintColor"), iconSubduedColor) -- if (icon.iconName == "ChevronRightSmallOutline") { -+ // OneKey patch: preserve the original 38dp press target around its 24dp layout slot. -+ icon.setTag(com.facebook.react.R.id.react_test_id, data.optString("testID").takeIf { it.isNotEmpty() }) -+ icon.accessibilityDelegate = selectorAccessibilityDelegate -+ icon.contentDescription = data.optString("accessibilityLabel").takeIf { it.isNotEmpty() } -+ if (item.json.optString("presentation") == "accountSelector") { -+ icon.glyphSizeDp = 24 -+ val isSourceMenu = item.json.has("height") && icon.iconName == "DotHorOutline" -+ val size = if (isSourceMenu) 24 else if (item.json.has("height") && icon.iconName == "PlusSmallOutline") 36 else 38 -+ icon.layoutParams = LayoutParams(dp(size), dp(size)).apply { -+ gravity = Gravity.CENTER_VERTICAL -+ if (!isSourceMenu) { -+ marginStart = -dp(7) -+ marginEnd = -dp(7) -+ } -+ } -+ if (isSourceMenu) { -+ // OneKey patch: the native ActionList trigger measures 24dp; Yoga rounds its trailing edge cumulatively. -+ setPadding(paddingLeft, paddingTop, (12 * resources.displayMetrics.density).toInt(), paddingBottom) -+ } -+ } else if (icon.iconName == "ChevronRightSmallOutline") { - icon.glyphSizeDp = null - // ListItem.DrillIn is a 24dp icon with mx=-6, for a 12dp layout footprint. - icon.layoutParams = LayoutParams(dp(24), dp(24)).apply { -@@ -2516,7 +3017,7 @@ internal class NativeListRowView( - when (item.type) { - "message" -> 14f - "sectionHeader" -> when { -- isNetworkSelectorSection -> 14f -+ isNetworkSelectorSection && item.json.optString("variant") != "summary" -> 14f - item.json.optString("variant") == "gallery" -> 18f - item.json.optString("variant") == "summary" -> 16f - currentLayout == "table" -> 11f -@@ -2530,11 +3031,14 @@ internal class NativeListRowView( - } - }, - ) -- title.typeface = if (isWalletSidebar || isAccountSelectorIdentity || isAccountSelectorAction) { -+ title.typeface = if (isAccountSelectorAction && item.json.has("icon")) { -+ NativeListFonts.medium(context) -+ } else if (isWalletSidebar || isAccountSelectorIdentity || isAccountSelectorAction) { - NativeListFonts.regular(context) - } else { - when (item.type) { - "sectionHeader" -> when { -+ isNetworkSelectorSection && item.json.has("height") && item.json.optString("variant") != "summary" && (item.json.optJSONObject("checkbox") != null || item.json.optString("titleActionKey").isEmpty()) -> NativeListFonts.semibold(context) - isNetworkSelectorSection -> NativeListFonts.medium(context) - currentLayout == "table" -> NativeListFonts.regular(context) - item.json.optString("variant") == "summary" -> NativeListFonts.medium(context) -@@ -2552,7 +3056,7 @@ internal class NativeListRowView( - else -> 14f - }) - tertiary.textSize = sp(14f) -- if (isNetworkSelectorSection) { -+ if (isNetworkSelectorSection && item.json.optString("variant") != "summary") { - TextViewCompat.setLineHeight(title, dp(20)) - } else if (item.type == "sectionHeader" && item.json.optString("variant") == "gallery") { - TextViewCompat.setLineHeight(title, dp(24)) -@@ -2589,11 +3093,42 @@ internal class NativeListRowView( - column.typeface = NativeListFonts.medium(context) - column.fontFeatureSettings = "tnum" - } -+ // OneKey patch: exact selector row dimensions override template minimums. -+ selectorHeight = if (item.json.has("height")) dp(item.json.optInt("height")) else null -+ // OneKey patch: React Native's section spacers and letter blocks truncate physical heights. -+ val isSelectorLetter = isNetworkSelectorSection && item.json.has("height") && -+ item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isEmpty() && -+ item.json.optJSONObject("checkbox") == null -+ val isSelectorSectionSpacer = selectorUsesSourceScale && currentLayout == "sectioned" && -+ item.type == "system" && item.json.optString("variant") == "spacer" -+ if (isSelectorLetter || isSelectorSectionSpacer) { -+ selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() -+ } -+ if (isSelectorLetter) { -+ val inset = (20 * resources.displayMetrics.density).toInt() - dp(8) -+ // OneKey patch: SectionHeader has a fixed height and centered text, without vertical padding. -+ setPadding(inset, 0, inset, 0) -+ } -+ if (item.json.has("height") && isNetworkSelectorSection && item.json.optString("variant") != "summary" && item.json.optString("titleActionKey").isNotEmpty() && item.json.optJSONObject("checkbox") == null) { -+ // OneKey patch: the text-and-underline header's fractional measured height rounds up in React Native. -+ selectorHeight = kotlin.math.ceil((item.json.optInt("height") * (if (selectorUsesSourceScale) 1f else NativeListScale.factor(resources)) * resources.displayMetrics.density).toDouble()).toInt() -+ } -+ // OneKey patch: an explicit per-row policy preserves each source list's measured heights. -+ if (item.json.has("height")) { -+ when (item.json.optString("heightRounding")) { -+ "floor" -> selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).toInt() -+ "nearest" -> selectorHeight = (item.json.optInt("height") * resources.displayMetrics.density).roundToInt() -+ } -+ } - val baseHeight = when { -+ item.json.has("height") -> item.json.optInt("height") - item.type == "system" && item.json.optString("variant") == "spacer" -> item.json.optInt("height", 0) - item.type == "walletGroup" -> { - val childCount = item.json.optJSONArray("children")?.length() ?: 0 -- (childCount + 1) * 68 + childCount * 12 -+ // OneKey patch: include badge heights in the group layout. -+ // (childCount + 1) * 68 + childCount * 12 -+ val members = listOf(item.json.getJSONObject("parent")) + (0 until childCount).map { item.json.getJSONArray("children").getJSONObject(it) } -+ members.sumOf { it.optInt("height", if ((it.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68) } + childCount * 12 + if (members.first().has("height")) 2 else 0 - } - isNetworkSelectorIdentity -> 47 - else -> when (item.type) { -@@ -2617,6 +3152,7 @@ internal class NativeListRowView( - else -> 36 - } - "system" -> when (item.json.optString("variant")) { -+ "warning" -> 0 - "noMatch", "end" -> 36 - "retry" -> 44 - else -> 56 -@@ -2636,14 +3172,14 @@ internal class NativeListRowView( - 56 - } - else -> when { -- item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> 68 -+ item.type == "identity" && item.json.optString("presentation") == "walletSidebar" -> if ((item.json.optJSONArray("badges")?.length() ?: 0) > 0) 92 else 68 - item.type == "identity" && item.json.optString("tertiary").isNotEmpty() -> 72 - item.type == "identity" && item.json.optString("subtitle").isNotEmpty() -> 60 - else -> 56 - } - } - } -- val modifier = if (isNetworkSelectorIdentity) { -+ val modifier = if (item.json.has("height") || isNetworkSelectorIdentity) { - 0 - } else if ( - item.type == "sectionHeader" && item.json.optString("variant") in listOf("summary", "gallery") -@@ -2713,7 +3249,12 @@ internal class NativeListRowView( - override fun getOutline(view: View, outline: Outline) { - when (shape) { - "square" -> outline.setRect(0, 0, view.width, view.height) -- "rounded" -> outline.setRoundRect(0, 0, view.width, view.height, dp(10).toFloat()) -+ "rounded" -> { -+ // OneKey patch: the account avatar has an 8-point radius at its 32-point size. -+ // outline.setRoundRect(0, 0, view.width, view.height, dp(10).toFloat()) -+ val radius = if ((tag as? NativeListItem)?.json?.optString("presentation") == "accountSelector" && (tag as? NativeListItem)?.json?.has("height") == true) dp(8).toFloat() else dp(10).toFloat() -+ outline.setRoundRect(0, 0, view.width, view.height, radius) -+ } - else -> outline.setOval(0, 0, view.width, view.height) - } - } -@@ -2742,7 +3283,13 @@ internal class NativeListRowView( - token: String, - slot: Int, - variant: String, -+ onLoad: (() -> Unit)? = null, -+ onError: (() -> Unit)? = null, -+ retryAttempt: Int = 0, - ) { -+ selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks) -+ val expectedEpoch = bindingEpoch -+ val retryLimit = source.optInt("retryTimes", 0).coerceAtLeast(0) - val uri = source.optString("uri").trim().takeIf(String::isNotEmpty) - imageView.configure( - sourceUri = uri, -@@ -2751,17 +3298,39 @@ internal class NativeListRowView( - contentFit = source.optString("contentFit", "cover"), - cachePolicy = source.optString("cachePolicy", "memory-disk"), - autoplay = source.optBoolean("autoplay", false), -- recyclingKey = "$token:$slot", -- optimizeTos = source.optBoolean("optimizeTos", true), -+ recyclingKey = if (retryAttempt == 0) "$token:$slot" else "$token:$slot:retry:$retryAttempt", -+ optimizeTos = retryAttempt == 0 && source.optBoolean("optimizeTos", true), - overscan = source.optDouble("overscan", 1.1), - loadingStrategy = source.optString("loadingStrategy", "static"), -+ onLoad = if (retryLimit == 0) onLoad else ({ -+ if (bindingEpoch == expectedEpoch) { -+ selectorImageRetries.remove(imageView)?.let(imageView::removeCallbacks) -+ onLoad?.invoke() -+ } -+ }), -+ onError = if (retryLimit == 0) onError else ({ -+ if (bindingEpoch == expectedEpoch) { -+ if (retryAttempt >= retryLimit) onError?.invoke() -+ else if (!selectorImageRetries.containsKey(imageView)) { -+ val retry = Runnable { -+ if (bindingEpoch == expectedEpoch) { -+ selectorImageRetries.remove(imageView) -+ bindImage(source, imageView, token, slot, variant, onLoad, onError, retryAttempt + 1) -+ } -+ } -+ selectorImageRetries[imageView] = retry -+ imageView.postDelayed(retry, kotlin.random.Random.nextLong(3) * 1000L) -+ } -+ } -+ }), - ) - } - - private fun weighted() = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - private fun wrap() = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT) -- private fun dp(value: Int): Int = NativeListScale.dp(resources, value) -- private fun sp(value: Float): Float = NativeListScale.font(resources, value) -+ private fun dp(value: Int): Int = if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) -+ private fun scaledDp(value: Float): Float = if (selectorUsesSourceScale) (value * resources.displayMetrics.density).roundToInt().toFloat() else NativeListScale.dp(resources, value) -+ private fun sp(value: Float): Float = if (selectorUsesSourceScale) value else NativeListScale.font(resources, value) - - private fun color(theme: JSONObject?, key: String, fallback: String): Int = - safeColor(theme?.optString(key, fallback), parseNativeListColor(fallback)) -@@ -2774,30 +3343,30 @@ internal class NativeListRowView( - - private fun roundedFill(color: Int, radiusDp: Float) = GradientDrawable().apply { - setColor(color) -- cornerRadius = NativeListScale.dp(resources, radiusDp) -+ cornerRadius = scaledDp(radiusDp) - } - - private fun roundedStroke(stroke: Int, fill: Int, radiusDp: Float) = GradientDrawable().apply { - setColor(fill) - setStroke(dp(2), stroke) -- cornerRadius = NativeListScale.dp(resources, radiusDp) -+ cornerRadius = scaledDp(radiusDp) - } - - private fun roundedHairlineStroke(stroke: Int, radiusDp: Float) = GradientDrawable().apply { - setColor(Color.TRANSPARENT) - setStroke(1, stroke) -- cornerRadius = NativeListScale.dp(resources, radiusDp) -+ cornerRadius = scaledDp(radiusDp) - } - - private fun groupedBackground(position: String, color: Int) = GradientDrawable().apply { - setColor(color) -- val radius = NativeListScale.dp(resources, 12f) -+ val radius = scaledDp(12f) - cornerRadii = when (position) { - "first" -> floatArrayOf(radius, radius, radius, radius, 0f, 0f, 0f, 0f) - "last" -> floatArrayOf(0f, 0f, 0f, 0f, radius, radius, radius, radius) - "single" -> FloatArray(8) { radius } -- "rail" -> FloatArray(8) { NativeListScale.dp(resources, 8f) } -- "mediaTile" -> FloatArray(8) { NativeListScale.dp(resources, 16f) } -+ "rail" -> FloatArray(8) { scaledDp(8f) } -+ "mediaTile" -> FloatArray(8) { scaledDp(16f) } - else -> FloatArray(8) - } - } -@@ -2805,6 +3374,7 @@ internal class NativeListRowView( - } - - private class OneKeyIconView(context: android.content.Context) : View(context) { -+ var useSourceScale = false - var iconName: String = "" - set(value) { - field = value -@@ -2828,9 +3398,11 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { - val pathData = iconPaths[iconName] ?: return - val drawSize = minOf( - minOf(width, height).toFloat(), -- glyphSizeDp?.let { NativeListScale.dp(resources, it).toFloat() } ?: Float.MAX_VALUE, -+ glyphSizeDp?.let { if (useSourceScale) (it * resources.displayMetrics.density).roundToInt().toFloat() else NativeListScale.dp(resources, it).toFloat() } ?: Float.MAX_VALUE, - ) -- val scale = drawSize / 24f -+ // OneKey patch: custom account-error artwork uses an 18-point viewBox. -+ // val scale = drawSize / 24f -+ val scale = drawSize / (selectorIconViewBoxes[iconName] ?: 24f) - fill.color = tintColor - canvas.save() - canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) -@@ -2839,6 +3411,8 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { - pathData.forEachIndexed { index, data -> - PathParser.createPathFromPathData(data)?.let { path -> - path.fillType = sourceFillTypes?.getOrNull(index) ?: Path.FillType.EVEN_ODD -+ // OneKey patch: provider illustration colors are part of their source asset. -+ fill.color = selectorIconColors[iconName]?.getOrNull(index) ?: tintColor - canvas.drawPath(path, fill) - } - } -@@ -2846,10 +3420,42 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { - } - - companion object { -+ // OneKey patch: preserve provider colors and non-24 viewBoxes. -+ private val selectorIconColors: Map> = mapOf( -+ "GlobusOutline" to listOf(null), -+ "LockSolid" to listOf(null), -+ "GoogleIllus" to listOf(parseNativeListColor("#4285F4"), parseNativeListColor("#34A853"), parseNativeListColor("#FBBC05"), parseNativeListColor("#EA4335")), -+ "AppleBrand" to listOf(null), -+ "BotIllus" to listOf(parseNativeListColor("#8897A5"), parseNativeListColor("#3FA9F5"), parseNativeListColor("#8897A5"), parseNativeListColor("#8897A5"), parseNativeListColor("#10243E"), parseNativeListColor("#10243E"), parseNativeListColor("#10243E")), -+ "AllNetworksSolid" to listOf(null, null), -+ "CrossedSmallSolid" to listOf(null), -+ "AccountErrorCustom" to listOf(Color.argb(0x72, 0, 0, 0), Color.argb(0x72, 0, 0, 0)), -+ "Circle" to listOf(null), -+ ) -+ private val selectorIconViewBoxes = mapOf( -+ "GlobusOutline" to 24f, -+ "LockSolid" to 24f, -+ "GoogleIllus" to 24f, -+ "AppleBrand" to 16f, -+ "BotIllus" to 24f, -+ "AllNetworksSolid" to 24f, -+ "CrossedSmallSolid" to 24f, -+ "AccountErrorCustom" to 18f, -+ "Circle" to 24f, -+ ) - // Keep the SVG fill-rule used by each Action-row source path. React Native - // SVG defaults to nonzero (WINDING); only paths declaring fillRule="evenodd" - // use EVEN_ODD. Other existing icons retain their prior rendering behavior. - private val actionIconFillTypes = mapOf( -+ "GlobusOutline" to listOf(Path.FillType.EVEN_ODD), -+ "LockSolid" to listOf(Path.FillType.EVEN_ODD), -+ "GoogleIllus" to listOf(Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING), -+ "AppleBrand" to listOf(Path.FillType.WINDING), -+ "BotIllus" to listOf(Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING, Path.FillType.WINDING), -+ "AllNetworksSolid" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), -+ "CrossedSmallSolid" to listOf(Path.FillType.WINDING), -+ "AccountErrorCustom" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), -+ "Circle" to listOf(Path.FillType.WINDING), - "ChevronRightSmallOutline" to listOf(Path.FillType.WINDING), - "MinusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), - "PlusCircleOutline" to listOf(Path.FillType.WINDING, Path.FillType.EVEN_ODD), -@@ -2867,6 +3473,16 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { - - // Exact 24x24 paths from app-monorepo packages/components Icon sources. - private val iconPaths = mapOf( -+ // OneKey patch: official selector SVG path geometry. -+ "GlobusOutline" to listOf("M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361"), -+ "LockSolid" to listOf("M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3"), -+ "GoogleIllus" to listOf("M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09", "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23", "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z", "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53"), -+ "AppleBrand" to listOf("M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z"), -+ "BotIllus" to listOf("M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z", "M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z", "M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", "M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", "M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", "M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", "M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06"), -+ "AllNetworksSolid" to listOf("M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67", "M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z"), -+ "CrossedSmallSolid" to listOf("M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z"), -+ "AccountErrorCustom" to listOf("M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5", "M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5"), -+ "Circle" to listOf("M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0"), - "ArrowBottomOutline" to listOf("m13 17.586 5-5L19.414 14 12 21.414 4.586 14 6 12.586l5 5V3h2z"), - "ArrowTopOutline" to listOf("M19.414 10 18 11.414l-5-5V21h-2V6.414l-5 5L4.586 10 12 2.586z"), - "ChartTrendingUpOutline" to listOf("M22 13h-2V9.414l-7 7-4-4-6 6L1.586 17 9 9.586l4 4L18.586 8H15V6h7z"), -@@ -2910,6 +3526,7 @@ private class OneKeyIconView(context: android.content.Context) : View(context) { - } - - private class OneKeyCheckboxView(context: android.content.Context) : View(context) { -+ var usesSelectorGeometry = false - private val glyphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.FILL } - private var state = "unchecked" - -@@ -2926,9 +3543,11 @@ private class OneKeyCheckboxView(context: android.content.Context) : View(contex - "indeterminate" -> "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1" - else -> return - } -- val drawSize = minOf(width, height) * 0.8f -+ // OneKey patch: the source icon is a 16dp child after the 2dp border, not 80% of a rounded frame. -+ val drawSize = if (usesSelectorGeometry) (16 * resources.displayMetrics.density).roundToInt().toFloat() else minOf(width, height) * 0.8f -+ val borderOffset = (2 * resources.displayMetrics.density).roundToInt().toFloat() - canvas.save() -- canvas.translate((width - drawSize) / 2f, (height - drawSize) / 2f) -+ canvas.translate(if (usesSelectorGeometry) borderOffset else (width - drawSize) / 2f, if (usesSelectorGeometry) borderOffset else (height - drawSize) / 2f) - canvas.scale(drawSize / 16f, drawSize / 16f) - PathParser.createPathFromPathData(pathData)?.let { canvas.drawPath(it, glyphPaint) } - canvas.restore() -diff --git a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt -index f0af895..9629eee 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt -+++ b/node_modules/@onekeyfe/react-native-native-list/android/src/main/java/com/onekey/nativelist/NativeListView.kt -@@ -14,6 +14,7 @@ import android.view.Choreographer - import android.view.Gravity - import android.view.MotionEvent - import android.view.View -+import android.view.ViewConfiguration - import android.view.accessibility.AccessibilityEvent - import android.view.accessibility.AccessibilityNodeInfo - import android.widget.FrameLayout -@@ -31,6 +32,7 @@ import org.json.JSONArray - import org.json.JSONObject - import java.lang.ref.WeakReference - import java.util.UUID -+import kotlin.math.abs - import kotlin.math.ceil - import kotlin.math.cos - import kotlin.math.exp -@@ -87,6 +89,36 @@ class NativeListView( - private val contentContainer = FrameLayout(context) - private val adapter = NativeListAdapter(reactContext) - private val layoutManager = GridLayoutManager(context, 1) -+ // OneKey patch: vertical lists retain vertical drags and leave horizontal drags to a parent pager. -+ private val pagerGestureTouchSlop = ViewConfiguration.get(context).scaledTouchSlop -+ private val pagerGestureTouchListener = object : RecyclerView.SimpleOnItemTouchListener() { -+ private var downX = 0f -+ private var downY = 0f -+ private var directionResolved = false -+ -+ override fun onInterceptTouchEvent(recyclerView: RecyclerView, event: MotionEvent): Boolean { -+ if (layoutManager.orientation != RecyclerView.VERTICAL) return false -+ when (event.actionMasked) { -+ MotionEvent.ACTION_DOWN -> { -+ downX = event.x -+ downY = event.y -+ directionResolved = false -+ } -+ MotionEvent.ACTION_MOVE -> if (!directionResolved) { -+ val deltaX = abs(event.x - downX) -+ val deltaY = abs(event.y - downY) -+ if (max(deltaX, deltaY) > pagerGestureTouchSlop) { -+ directionResolved = true -+ recyclerView.parent?.requestDisallowInterceptTouchEvent(deltaY >= deltaX) -+ } -+ } -+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { -+ recyclerView.parent?.requestDisallowInterceptTouchEvent(false) -+ } -+ } -+ return false -+ } -+ } - private val reorderPlaceholderDecoration = ReorderPlaceholderDecoration( - adapter = adapter, - insetPx = dp(REORDER_PLACEHOLDER_INSET_DP), -@@ -102,6 +134,7 @@ class NativeListView( - private val sectionIndexView = NativeListSectionIndexView(context) - private val sectionIndexPreview = TextView(context) - private var config: NativeListConfig? = null -+ private var usesSelectorSourceScale = false - private var stickyDecoration: StickySectionHeaderDecoration? = null - private var spacingDecoration: ItemSpacingDecoration? = null - private var itemTouchHelper: ItemTouchHelper? = null -@@ -132,7 +165,10 @@ class NativeListView( - recyclerView.adapter = adapter - recyclerView.layoutManager = layoutManager - recyclerView.itemAnimator = null -+ recyclerView.addOnItemTouchListener(pagerGestureTouchListener) - recyclerView.addItemDecoration(reorderPlaceholderDecoration) -+ // OneKey patch: full-width selector header backgrounds do not change row content insets. -+ recyclerView.addItemDecoration(SelectorBackgroundDecoration(adapter)) - recyclerView.setHasFixedSize(false) - layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { - override fun getSpanSize(position: Int): Int { -@@ -158,11 +194,15 @@ class NativeListView( - ) - contentContainer.addView( - sectionIndexView, -- FrameLayout.LayoutParams(dp(48), FrameLayout.LayoutParams.MATCH_PARENT, Gravity.END), -+ FrameLayout.LayoutParams( -+ dp(SECTION_INDEX_RAIL_WIDTH_DP), -+ FrameLayout.LayoutParams.MATCH_PARENT, -+ Gravity.END, -+ ), - ) - sectionIndexPreview.apply { - gravity = Gravity.CENTER -- textSize = NativeListScale.font(resources, 28f) -+ textSize = NativeListScale.font(resources, 22f) - typeface = NativeListFonts.semibold(context) - visibility = GONE - alpha = 0f -@@ -170,7 +210,13 @@ class NativeListView( - } - contentContainer.addView( - sectionIndexPreview, -- FrameLayout.LayoutParams(dp(72), dp(72), Gravity.CENTER), -+ FrameLayout.LayoutParams( -+ dp(SECTION_INDEX_PREVIEW_SIZE_DP), -+ dp(SECTION_INDEX_PREVIEW_SIZE_DP), -+ Gravity.CENTER_VERTICAL or Gravity.END, -+ ).apply { -+ marginEnd = dp(SECTION_INDEX_PREVIEW_END_MARGIN_DP) -+ }, - ) - addView(contentContainer, LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f)) - footerView.visibility = GONE -@@ -244,17 +290,29 @@ class NativeListView( - invalidateActionAnchor("snapshot") - val previous = config - if (previous != null && canApplyStableContentUpdate(previous, next)) { -+ // OneKey patch: authorize a diff payload only against this exact validated baseline. -+ next.items.forEachIndexed { index, item -> -+ if (item.content != previous.items[index].content && -+ adapter.currentList.getOrNull(index) === previous.items[index] -+ ) { -+ item.selectionUpdateFromContent = previous.items[index].content -+ } -+ } - val changedSummaryKeys = previous.items.indices.mapNotNull { index -> - previous.items[index].key.takeIf { - previous.items[index].content != next.items[index].content - } - }.toSet() - config = next -+ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } -+ adapter.usesSelectorSourceScale = usesSelectorSourceScale - adapter.theme = next.theme - adapter.layout = next.layout - adapter.orientation = next.orientation - adapter.selectedKeys = next.selectedKeys - adapter.submitList(next.items) { -+ // OneKey patch: DiffUtil has dispatched its payloads; release the old serialized rows. -+ next.items.forEach { it.selectionUpdateFromContent = null } - recyclerView.post { - bindVisibleSelection(changedSummaryKeys) - bindFooterSelection() -@@ -264,6 +322,8 @@ class NativeListView( - return - } - config = next -+ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } -+ adapter.usesSelectorSourceScale = usesSelectorSourceScale - endReachedGeneration = null - pendingReorder = null - adapter.theme = next.theme -@@ -327,30 +387,98 @@ class NativeListView( - val newItem = next.items[index] - oldItem.key == newItem.key && - oldItem.type == newItem.type && -- (oldItem.content == newItem.content || isStableSummaryUpdate(oldItem, newItem)) -+ // OneKey patch: controlled echoes can also carry row and checkbox selection state. -+ // (oldItem.content == newItem.content || isStableSummaryUpdate(oldItem, newItem)) -+ (oldItem.content == newItem.content || isStableSelectionUpdate( -+ oldItem, -+ newItem, -+ next.selectionMode == "single" || next.selectionMode == "multiple", -+ )) - } - } - -- private fun isStableSummaryUpdate( -+ // OneKey patch: preserve the original summary-only comparison for upstream reference. -+ // private fun isStableSummaryUpdate( -+ // previous: NativeListItem, -+ // next: NativeListItem, -+ // ): Boolean { -+ // if ( -+ // previous.type != "sectionHeader" || -+ // previous.json.optString("variant") != "summary" || -+ // next.json.optString("variant") != "summary" -+ // ) { -+ // return false -+ // } -+ // val previousStructure = JSONObject(previous.content).apply { -+ // remove("title") -+ // remove("value") -+ // } -+ // val nextStructure = JSONObject(next.content).apply { -+ // remove("title") -+ // remove("value") -+ // } -+ // return previousStructure.toString() == nextStructure.toString() -+ // } -+ -+ private fun isStableSelectionUpdate( - previous: NativeListItem, - next: NativeListItem, -+ controlled: Boolean, - ): Boolean { -- if ( -- previous.type != "sectionHeader" || -- previous.json.optString("variant") != "summary" || -- next.json.optString("variant") != "summary" -- ) { -- return false -+ val previousStructure = selectionComparisonData(previous.json, controlled) ?: return false -+ val nextStructure = selectionComparisonData(next.json, controlled) ?: return false -+ return previousStructure.toString() == nextStructure.toString() -+ } -+ -+ // OneKey patch: ignore only fields refreshed by the lightweight selection binder. -+ private fun selectionComparisonData(data: JSONObject, controlled: Boolean): JSONObject? { -+ val type = data.opt("type") as? String ?: return null -+ val result = JSONObject(data.toString()) -+ if (result.has("selected")) { -+ if (result.opt("selected") !is Boolean) return null -+ result.remove("selected") - } -- val previousStructure = JSONObject(previous.content).apply { -- remove("title") -- remove("value") -+ if (type == "walletGroup") { -+ val parent = data.optJSONObject("parent") ?: return null -+ if (parent.optString("type") != "identity") return null -+ val children = data.optJSONArray("children") ?: return null -+ result.put("parent", selectionComparisonData(parent, false) ?: return null) -+ val normalizedChildren = JSONArray() -+ for (index in 0 until children.length()) { -+ val child = children.optJSONObject(index) ?: return null -+ if (child.optString("type") != "identity") return null -+ normalizedChildren.put(selectionComparisonData(child, false) ?: return null) -+ } -+ result.put("children", normalizedChildren) - } -- val nextStructure = JSONObject(next.content).apply { -- remove("title") -- remove("value") -+ if (type == "sectionHeader" && data.optString("variant") == "summary") { -+ result.remove("title") -+ result.remove("value") - } -- return previousStructure.toString() == nextStructure.toString() -+ if (!controlled) return result -+ fun checkboxData(value: Any?): JSONObject? { -+ val checkbox = value as? JSONObject ?: return null -+ if (checkbox.optString("kind") != "checkbox") return null -+ if (checkbox.has("state") && checkbox.opt("state") !in setOf("checked", "unchecked", "indeterminate")) return null -+ checkbox.remove("state") -+ return checkbox -+ } -+ if (type in setOf("dataRow", "sectionHeader", "action") && result.has("checkbox")) { -+ result.put("checkbox", checkboxData(result.opt("checkbox")) ?: return null) -+ } -+ if (type == "identity" && result.has("trailing")) { -+ val accessories = result.optJSONArray("trailing") ?: return null -+ if (accessories.length() > 2) return null -+ var checkboxCount = 0 -+ for (index in 0 until accessories.length()) { -+ val accessory = accessories.optJSONObject(index) ?: return null -+ if (accessory.optString("kind") == "checkbox") { -+ if (++checkboxCount > 1) return null -+ accessories.put(index, checkboxData(accessory) ?: return null) -+ } -+ } -+ } -+ return result - } - - fun applyPatches(patchesJson: String) { -@@ -389,6 +517,8 @@ class NativeListView( - invalidateActionAnchor("snapshot") - val next = current.copy(items = nextItems, selectedKeys = selected) - config = next -+ usesSelectorSourceScale = next.items.any { it.usesSelectorSourceScale } -+ adapter.usesSelectorSourceScale = usesSelectorSourceScale - adapter.selectedKeys = selected - adapter.submitList(nextItems) { relayoutContents() } - bindFooter(next) -@@ -714,6 +844,7 @@ class NativeListView( - reorderTouchHandler = null - reorderTouchListener?.let(recyclerView::removeOnItemTouchListener) - reorderTouchListener = null -+ recyclerView.removeOnItemTouchListener(pagerGestureTouchListener) - itemTouchHelper?.attachToRecyclerView(null) - itemTouchHelper = null - footerView.recycle() -@@ -731,7 +862,8 @@ class NativeListView( - val horizontalPadding = next.contentPaddingHorizontal ?: defaultPadding - val topPadding = next.contentPaddingTop ?: defaultPadding - val bottomPadding = next.contentPaddingBottom ?: defaultPadding -- val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else 48 -+ // OneKey patch: the section index overlays rows and keeps only an accessory-safe inset. -+ val indexGutter = if (sectionIndexEntries.isEmpty()) 0 else SECTION_INDEX_CONTENT_INSET_DP - recyclerView.setPaddingRelative( - dp(horizontalPadding), - dp(topPadding), -@@ -742,7 +874,7 @@ class NativeListView( - recyclerView.isVerticalScrollBarEnabled = sectionIndexEntries.isEmpty() - - spacingDecoration?.let(recyclerView::removeItemDecoration) -- spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing)).also(recyclerView::addItemDecoration) -+ spacingDecoration = ItemSpacingDecoration(dp(next.itemSpacing), next.itemSpacing, density).also(recyclerView::addItemDecoration) - stickyDecoration?.let(recyclerView::removeItemDecoration) - stickyDecoration = if (next.stickyHeaders && orientation == RecyclerView.VERTICAL) { - StickySectionHeaderDecoration(adapter, context, next.theme, density).also(recyclerView::addItemDecoration) -@@ -769,12 +901,13 @@ class NativeListView( - sectionIndexEntries.map { it.title }, - themeColor(next.theme, "secondaryText", "#646464"), - themeColor(next.theme, "accent", "#108303"), -+ themeColor(next.theme, "inverseText", "#FCFCFC"), - ) - sectionIndexView.visibility = if (sectionIndexEntries.isEmpty()) GONE else VISIBLE - sectionIndexPreview.setTextColor(themeColor(next.theme, "inverseText", "#FCFCFC")) - sectionIndexPreview.background = GradientDrawable().apply { - setColor(themeColor(next.theme, "inverseBackground", "#202020")) -- cornerRadius = dp(16).toFloat() -+ cornerRadius = dp(14).toFloat() - } - sectionIndexView.setActiveIndex( - previousKey?.let { key -> sectionIndexEntries.indexOfFirst { it.key == key }.takeIf { it >= 0 } }, -@@ -846,6 +979,7 @@ class NativeListView( - null, - next.selectedKeys.contains(footer.key), - ::resolveCheckboxState, -+ useSourceScale = usesSelectorSourceScale, - ) - } - } -@@ -898,15 +1032,17 @@ class NativeListView( - origin.sourceView.getLocationInWindow(location) - val record = ActionAnchorRecord(token, origin) - actionAnchor = record -+ // OneKey patch: selector menus anchor to the glyph slot, not its expanded press target. -+ val anchorInset = origin.anchorInsetPixels - return JSONObject() - .put("token", token) - .put( - "windowRect", - JSONObject() -- .put("x", location[0] / density) -- .put("y", location[1] / density) -- .put("width", origin.sourceView.width / density) -- .put("height", origin.sourceView.height / density), -+ .put("x", (location[0] + anchorInset) / density) -+ .put("y", (location[1] + anchorInset) / density) -+ .put("width", (origin.sourceView.width - anchorInset * 2) / density) -+ .put("height", (origin.sourceView.height - anchorInset * 2) / density), - ) - .put("source", origin.source) - .put("generation", generation) -@@ -1037,7 +1173,9 @@ class NativeListView( - current.theme, - current.layout, - position, -- current.selectedKeys.contains(item.key), -+ // OneKey patch: match the full binder when the snapshot carries explicit row selection. -+ // current.selectedKeys.contains(item.key), -+ item.json.optBoolean("selected", false) || current.selectedKeys.contains(item.key), - ::resolveCheckboxState, - ) - } -@@ -1297,12 +1435,14 @@ class NativeListView( - ?.let(recyclerView::getChildViewHolder) - ?.takeIf { holder -> - adapter.itemAt(holder.bindingAdapterPosition)?.let { item -> -- val holderLocation = IntArray(2) -- holder.itemView.getLocationOnScreen(holderLocation) -- item.isReorderable && ( -- item.type != "walletGroup" || -- event.rawY in holderLocation[1].toFloat()..(holderLocation[1] + dp(68)).toFloat() -- ) -+ // OneKey patch: any wallet group member can initiate the group drag. -+ // val holderLocation = IntArray(2) -+ // holder.itemView.getLocationOnScreen(holderLocation) -+ // item.isReorderable && ( -+ // item.type != "walletGroup" || -+ // event.rawY in holderLocation[1].toFloat()..(holderLocation[1] + dp(68)).toFloat() -+ // ) -+ item.isReorderable - } == true - } - if (candidate != null) handler.postDelayed(startDrag, REORDER_LONG_PRESS_MS) -@@ -1460,9 +1600,13 @@ class NativeListView( - ) - } - -- private fun dp(value: Int): Int = NativeListScale.dp(resources, value) -+ private fun dp(value: Int): Int = if (usesSelectorSourceScale) (value * resources.displayMetrics.density).roundToInt() else NativeListScale.dp(resources, value) - - companion object { -+ private const val SECTION_INDEX_CONTENT_INSET_DP = 16 -+ private const val SECTION_INDEX_RAIL_WIDTH_DP = 32 -+ private const val SECTION_INDEX_PREVIEW_SIZE_DP = 48 -+ private const val SECTION_INDEX_PREVIEW_END_MARGIN_DP = 40 - private const val REORDER_LONG_PRESS_MS = 200L - private const val REORDER_ALLOWABLE_MOVEMENT_DP = 10 - private const val REORDER_PLACEHOLDER_INSET_DP = 8 -@@ -1546,7 +1690,9 @@ private class NativeListSectionIndexView( - private var titles: List = emptyList() - private var normalColor = Color.GRAY - private var activeColor = Color.BLACK -+ private var activeTextColor = Color.WHITE - private var lastTouchIndex: Int? = null -+ private val activeBackgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG) - private val normalPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { - textAlign = Paint.Align.CENTER - typeface = NativeListFonts.medium(context) -@@ -1563,10 +1709,16 @@ private class NativeListSectionIndexView( - contentDescription = ACCESSIBILITY_LABEL - } - -- fun configure(titles: List, normalColor: Int, activeColor: Int) { -+ fun configure( -+ titles: List, -+ normalColor: Int, -+ activeColor: Int, -+ activeTextColor: Int, -+ ) { - this.titles = titles - this.normalColor = normalColor - this.activeColor = activeColor -+ this.activeTextColor = activeTextColor - activeIndex = null - updateContentDescription() - invalidate() -@@ -1590,8 +1742,30 @@ private class NativeListSectionIndexView( - activePaint.color = activeColor - activePaint.textSize = textSize - titles.forEachIndexed { index, title -> -- val paint = if (index == activeIndex) activePaint else normalPaint -+ val active = index == activeIndex -+ val paint = if (active) activePaint else normalPaint - val centerY = originY + cellHeight * (index + 0.5f) -+ if (active && cellHeight >= NativeListScale.dp(resources, 12f)) { -+ val badgeWidth = NativeListScale.dp(resources, 20f) -+ val badgeHeight = minOf(cellHeight, NativeListScale.dp(resources, 16f)) -+ activeBackgroundPaint.color = activeColor -+ canvas.drawRoundRect( -+ width / 2f - badgeWidth / 2f, -+ centerY - badgeHeight / 2f, -+ width / 2f + badgeWidth / 2f, -+ centerY + badgeHeight / 2f, -+ badgeHeight / 2f, -+ badgeHeight / 2f, -+ activeBackgroundPaint, -+ ) -+ } -+ paint.color = if (active && cellHeight >= NativeListScale.dp(resources, 12f)) { -+ activeTextColor -+ } else if (active) { -+ activeColor -+ } else { -+ normalColor -+ } - val baseline = centerY - (paint.descent() + paint.ascent()) / 2f - canvas.drawText(title, width / 2f, baseline, paint) - } -@@ -1697,7 +1871,11 @@ private class NativeListSectionIndexView( - } - } - --private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.ItemDecoration() { -+private class ItemSpacingDecoration( -+ private val spacing: Int, -+ private val sourceSpacing: Int, -+ private val density: Float, -+) : RecyclerView.ItemDecoration() { - override fun getItemOffsets( - outRect: android.graphics.Rect, - view: View, -@@ -1706,7 +1884,14 @@ private class ItemSpacingDecoration(private val spacing: Int) : RecyclerView.Ite - ) { - if (spacing <= 0) return - val horizontal = (parent.layoutManager as? LinearLayoutManager)?.orientation == RecyclerView.HORIZONTAL -- if (horizontal) outRect.right = spacing else outRect.bottom = spacing -+ val item = view.tag as? NativeListItem -+ val sourceWallet = item?.type == "identity" && item.json.optString("presentation") == "walletSidebar" && item.json.has("height") -+ // OneKey patch: V1 measures the wallet and its bottom padding as one sortable row. -+ val itemSpacing = if (!horizontal && sourceWallet) { -+ val sourceHeight = item!!.json.optInt("height") -+ ((sourceHeight + sourceSpacing) * density).roundToInt() - (sourceHeight * density).roundToInt() -+ } else spacing -+ if (horizontal) outRect.right = itemSpacing else outRect.bottom = itemSpacing - } - } - -@@ -1733,22 +1918,32 @@ private class StickySectionHeaderDecoration( - for (index in first downTo 0) { - val candidate = adapter.itemAt(index) - if (candidate?.type == "sectionHeader") { -- if (candidate.json.optString("variant") == "summary") continue -+ if (candidate.json.optString("variant") == "summary" || !candidate.json.optBoolean("sticky", true)) continue - header = candidate.takeIf(::isSimpleStickySectionHeader) - break - } - } - val item = header ?: return -+ // OneKey patch: a pinned selector heading must use the same text rasterization as its row. -+ textPaint.flags = if (item.usesSelectorSourceScale) Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG or Paint.LINEAR_TEXT_FLAG else Paint.ANTI_ALIAS_FLAG - val isHistory = item.json.optString("variant") == "history" || - item.sectionKey?.startsWith("history-") == true -- val height = NativeListScale.dp(context.resources, if (isHistory) 16 else 36) -- val textSize = NativeListScale.font(context.resources, if (isHistory) 12f else 14f) -- textPaint.textSize = textSize * density -- val horizontalInset = NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat() -+ val sourceHeight = item.json.optInt("height", 36) * density -+ val height = if (item.usesSelectorSourceScale) { -+ if (item.json.optString("heightRounding") == "nearest") sourceHeight.roundToInt() else sourceHeight.toInt() -+ } else NativeListScale.dp(context.resources, if (isHistory) 16 else 36) -+ val textSize = if (item.usesSelectorSourceScale) 14f else NativeListScale.font(context.resources, if (isHistory) 12f else 14f) -+ textPaint.textSize = if (item.usesSelectorSourceScale) kotlin.math.ceil((textSize * density).toDouble()).toFloat() else textSize * density -+ val horizontalInset = if (item.usesSelectorSourceScale) ((20 * density).toInt() - parent.paddingLeft).toFloat() else NativeListScale.dp(context.resources, if (isHistory) 8 else 20).toFloat() - val left = parent.paddingLeft.toFloat() - val right = (parent.width - parent.paddingRight).toFloat() - canvas.drawRect(left, 0f, right, height.toFloat(), backgroundPaint) -- val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f -+ val baseline = if (item.usesSelectorSourceScale) { -+ val metrics = textPaint.fontMetricsInt -+ val lineHeight = kotlin.math.ceil(20 * density.toDouble()).toInt() -+ val leading = lineHeight - (metrics.descent - metrics.ascent) -+ (height - lineHeight) / 2 - metrics.ascent + kotlin.math.ceil(leading / 2.0).toFloat() -+ } else height / 2f - (textPaint.descent() + textPaint.ascent()) / 2f - val value = item.json.optString("title").let { if (isHistory) it.uppercase() else it } - val isRightToLeft = parent.layoutDirection == View.LAYOUT_DIRECTION_RTL - val textWidth = if (isHistory) { -@@ -1789,6 +1984,29 @@ private class StickySectionHeaderDecoration( - - internal fun isSimpleStickySectionHeader(item: NativeListItem): Boolean = - item.type == "sectionHeader" && -+ item.json.optBoolean("sticky", true) && - item.json.optString("variant") != "summary" && - item.json.optString("value").isEmpty() && - item.json.optJSONObject("checkbox") == null -+ -+// OneKey patch: paint only explicitly requested backgrounds into list side padding. -+private class SelectorBackgroundDecoration(private val adapter: NativeListAdapter) : RecyclerView.ItemDecoration() { -+ private val paint = Paint() -+ override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { -+ for (index in 0 until parent.childCount) { -+ val child = parent.getChildAt(index) -+ val item = adapter.itemAt(parent.getChildAdapterPosition(child)) ?: continue -+ if (!item.json.optBoolean("backgroundFullWidth", false)) continue -+ val color = item.json.optString("backgroundColor") -+ if (color.isEmpty()) continue -+ paint.color = try { parseNativeListColor(color) } catch (_: IllegalArgumentException) { Color.TRANSPARENT } -+ val top = child.y -+ canvas.drawRect(0f, top, parent.width.toFloat(), top + child.height, paint) -+ if (item.type == "system" && item.json.optString("variant") == "warning") { -+ paint.color = try { parseNativeListColor(item.json.optString("borderColor", "#E0E0E0")) } catch (_: IllegalArgumentException) { Color.TRANSPARENT } -+ canvas.drawRect(0f, top, parent.width.toFloat(), top + 1, paint) -+ canvas.drawRect(0f, top + child.height - 1, parent.width.toFloat(), top + child.height, paint) -+ } -+ } -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift -index e0bf60b..81836fa 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListCell.swift -@@ -1,4 +1,6 @@ - import Foundation -+// OneKey patch: preserve native font faces while enabling tabular number features. -+import CoreText - import OneKeyImage - import UIKit - -@@ -8,19 +10,56 @@ final class NativeListActionOrigin { - let bindingEpoch: Int - let source: String - let slot: Int? -+ // OneKey patch: expose the layout slot while preserving the larger hit target. -+ let anchorInset: CGFloat - - init( - sourceView: UIView, - ownerCell: NativeListCell, - bindingEpoch: Int, - source: String, -- slot: Int? = nil -+ slot: Int? = nil, -+ anchorInset: CGFloat = 0 - ) { - self.sourceView = sourceView - self.ownerCell = ownerCell - self.bindingEpoch = bindingEpoch - self.source = source - self.slot = slot -+ self.anchorInset = anchorInset -+ } -+} -+ -+// OneKey patch: explicit summary actions use the source text's physical-pixel line box. -+private final class NativeListAccessoryButton: UIButton { -+ var selectorSummaryLineHeight: CGFloat? { -+ didSet { invalidateIntrinsicContentSize(); setNeedsLayout() } -+ } -+ -+ private var sourcePixelScale: CGFloat { -+ max(1, window?.screen.scale ?? traitCollection.displayScale) -+ } -+ -+ override var intrinsicContentSize: CGSize { -+ var size = super.intrinsicContentSize -+ guard selectorSummaryLineHeight != nil, let title = attributedTitle(for: .normal) else { return size } -+ let width = title.boundingRect( -+ with: CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude), -+ options: [.usesLineFragmentOrigin, .usesFontLeading], -+ context: nil -+ ).width -+ size.width = ceil(width * sourcePixelScale) / sourcePixelScale -+ return size -+ } -+ -+ override func layoutSubviews() { -+ super.layoutSubviews() -+ guard let lineHeight = selectorSummaryLineHeight, let titleLabel else { return } -+ // OneKey patch: position the final source line box after UIKit has measured the button. -+ let top = ceil((bounds.height - lineHeight) / 2 * sourcePixelScale) / sourcePixelScale -+ var frame = titleLabel.frame -+ frame.origin.y = top -+ titleLabel.frame = frame - } - } - -@@ -54,6 +93,24 @@ private final class NativeListDottedUnderlineLabel: UILabel { - didSet { setNeedsLayout() } - } - -+ // OneKey patch: migrated section titles include the source 3-point underline box. -+ var reservesDottedUnderlineSpace = false { -+ didSet { invalidateIntrinsicContentSize(); setNeedsLayout(); setNeedsDisplay() } -+ } -+ -+ override var intrinsicContentSize: CGSize { -+ var size = super.intrinsicContentSize -+ if reservesDottedUnderlineSpace && showsDottedUnderline { size.height += 3 } -+ return size -+ } -+ -+ override func drawText(in rect: CGRect) { -+ let textRect = reservesDottedUnderlineSpace && showsDottedUnderline -+ ? CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: max(0, rect.height - 3)) -+ : rect -+ super.drawText(in: textRect) -+ } -+ - var dottedUnderlineColor: UIColor = .clear { - didSet { - dottedUnderlineLayer.strokeColor = dottedUnderlineColor.cgColor -@@ -101,7 +158,9 @@ private final class NativeListDottedUnderlineLabel: UILabel { - width: bounds.width, - height: bounds.height + 2 + dottedUnderlineVerticalOffset - ) -- let y = bounds.height + 1 + dottedUnderlineVerticalOffset -+ // OneKey patch: explicit header underline occupies the reserved final two points. -+ // let y = bounds.height + 1 + dottedUnderlineVerticalOffset -+ let y = reservesDottedUnderlineSpace ? bounds.height - 1 : bounds.height + 1 + dottedUnderlineVerticalOffset - let path = UIBezierPath() - path.move(to: CGPoint(x: 1, y: y)) - path.addLine(to: CGPoint(x: max(1, textWidth - 1), y: y)) -@@ -257,6 +316,9 @@ private final class NativeListTableColumnView: UIStackView { - - private func textColor(_ tone: String, theme: [String: Any]?) -> UIColor { - switch tone { -+ // OneKey patch: account warnings and hidden balances use existing theme tokens. -+ case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") -+ case "caution": return nativeListColor(theme, "caution", "#AB6400") - case "secondary": return nativeListColor(theme, "secondaryText", "#646464") - case "positive": return nativeListColor(theme, "positive", "#218358") - case "negative": return nativeListColor(theme, "negative", "#CE2C31") -@@ -273,6 +335,13 @@ final class NativeListCell: UICollectionViewCell { - private let leadingOverlayBackground = UIView() - private let leadingCornerIconBackground = UIView() - private let leadingCornerIconImageView = UIImageView() -+ // OneKey patch: selector-only views are reset on every native cell binding. -+ private var selectorViews: [UIView] = [] -+ private var selectorConstraints: [NSLayoutConstraint] = [] -+ private var selectorImages: [OneKeyImageReusableView] = [] -+ private var selectorBorder: CAShapeLayer? -+ private let selectorFullWidthBackground = CALayer() -+ private lazy var selectorTitleTap = UITapGestureRecognizer(target: self, action: #selector(selectorTitlePressed)) - private let secondaryImage = OneKeyImageReusableView(frame: .zero) - private let mediaNetworkImage = OneKeyImageReusableView(frame: .zero) - private let fallbackLabel = UILabel() -@@ -296,7 +365,9 @@ final class NativeListCell: UICollectionViewCell { - private let actionStack = UIStackView() - private let actionButtons = (0..<3).map { _ in UIButton(type: .system) } - private let trailingStack = UIStackView() -- private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) } -+ // OneKey patch: summary actions opt into source typography while other buttons keep UIKit layout. -+ // private let accessoryButtons = (0..<2).map { _ in UIButton(type: .system) } -+ private let accessoryButtons = (0..<2).map { _ in NativeListAccessoryButton(type: .system) } - private let checkboxButton = UIButton(type: .system) - private let spinner = UIActivityIndicatorView(style: .medium) - private let dataStack = UIStackView() -@@ -332,6 +403,8 @@ final class NativeListCell: UICollectionViewCell { - private var leadingSlotConstraints: [NSLayoutConstraint] = [] - private var dataWeightConstraints: [NSLayoutConstraint] = [] - private var accessorySizeConstraints: [NSLayoutConstraint] = [] -+ // OneKey patch: restore selector-only font features before a cell is reused. -+ private var selectorTypographyRestorers: [() -> Void] = [] - private var currentItem: NativeListItem? - private var accessoryActions: [(String, NativeSelectionTarget?)] = [] - private var footerActionKeys: [String] = [] -@@ -345,12 +418,15 @@ final class NativeListCell: UICollectionViewCell { - fallback: .lightGray - ) - private var checkboxCheckedColor = UIColor(nativeListHex: "#202020", fallback: .black) -+ private var checkboxIconColor = UIColor.white - private var checkboxUncheckedColor = UIColor(nativeListHex: "#FCFCFC", fallback: .white) - private var checkboxBorderColor = UIColor(nativeListHex: "#CECECE", fallback: .lightGray) - private var visualBackdropColor = UIColor.white - private var currentLayout = "linear" - private var currentTheme: [String: Any]? - private var currentItemIndex: Int? -+ // OneKey patch: delayed image retries belong to the current reusable cell binding. -+ private var selectorImageRetries: [ObjectIdentifier: DispatchWorkItem] = [:] - private(set) var bindingEpoch = 0 - - var onAction: ((NativeListItem, String, NativeSelectionTarget?, NativeListActionOrigin?) -> Void)? -@@ -615,12 +691,41 @@ final class NativeListCell: UICollectionViewCell { - - override func layoutSubviews() { - super.layoutSubviews() -+ // OneKey patch: extend only the background across the section list outer inset. -+ if currentItem?.data.bool("backgroundFullWidth") == true { -+ selectorFullWidthBackground.frame = CGRect(x: -frame.minX, y: 0, width: superview?.bounds.width ?? bounds.width, height: bounds.height) -+ } - if currentItem?.type == "mediaTile" { - mediaHeight.constant = max(0, contentView.bounds.width - 20) - } -+ if currentItem?.type == "identity", currentItem?.data["height"] != nil, -+ currentItem?.data.string("presentation") == "accountSelector", -+ let accessory = currentItem?.data.dictionaries("trailing").first, -+ accessory.string("kind") == "icon", accessory.string("name") == "PlusSmallOutline" { -+ // OneKey patch: PlusButton's fixed top18 slot and negative7 margin place its frame at11. -+ let button = accessoryButtons[0] -+ button.transform = .identity -+ let origin = button.convert(button.bounds, to: contentView).minY -+ button.transform = CGAffineTransform(translationX: 0, y: 11 - origin) -+ } -+ } -+ -+ // OneKey patch: trim only detached/recycled members, never a live compact -+ // drag proxy or an expanding group. Small groups retain eight reusable cells. -+ private func trimWalletGroupCells(keeping required: Int) { -+ let retained = max(8, required) -+ while walletGroupCells.count > retained { -+ let cell = walletGroupCells.removeLast() -+ rootStack.removeArrangedSubview(cell) -+ cell.removeFromSuperview() -+ cell.prepareForReuse() -+ cell.onAction = nil -+ cell.onBindingInvalidated = nil -+ } - } - - override func prepareForReuse() { -+ let canTrimMembers = !walletGroupCompactAppearanceActive && (rootStack.layer.animationKeys()?.isEmpty ?? true) - super.prepareForReuse() - invalidateCurrentBinding() - isHighlighted = false -@@ -632,6 +737,8 @@ final class NativeListCell: UICollectionViewCell { - walletGroupCompactContainer.alpha = 1 - walletGroupCompactCell?.prepareForReuse() - walletGroupCells.forEach { $0.prepareForReuse() } -+ walletGroupMembers.removeAll() -+ if canTrimMembers { trimWalletGroupCells(keeping: 0) } - leadingImages.forEach { $0.prepareForReuse() } - secondaryImage.prepareForReuse() - mediaNetworkImage.prepareForReuse() -@@ -645,6 +752,8 @@ final class NativeListCell: UICollectionViewCell { - selected: Bool, - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { -+ // OneKey patch: a same-row snapshot refresh must not clear a touch that is still held. -+ let shouldRestoreHighlight = isHighlighted && currentItem?.key == item.key - invalidateCurrentBinding() - bindingEpoch &+= 1 - currentLayout = layout -@@ -664,6 +773,14 @@ final class NativeListCell: UICollectionViewCell { - // Checkbox uses the literal neutral7 alpha token. Applying opacity to the - // opaque primary text color produces a different RGB result. - checkboxBorderColor = UIColor(nativeListHex: "#00000031", fallback: .lightGray) -+ checkboxIconColor = checkboxUncheckedColor -+ if item.data.string("presentation") == "networkSelector" { -+ checkboxCheckedColor = nativeListColor(theme, "checkboxBackground", "#202020") -+ checkboxBorderColor = nativeListColor(theme, "checkboxBorder", "#00000031") -+ checkboxIconColor = nativeListColor(theme, "checkboxIcon", "#FFFFFF") -+ // OneKey patch: the V1 checkbox fills even its unchecked body with iconInverse. -+ checkboxUncheckedColor = checkboxIconColor -+ } - visualBackdropColor = nativeListColor(theme, "rowBackground", "#FFFFFF") - titleLabel.textColor = primary - subtitleLabel.textColor = secondary -@@ -693,6 +810,11 @@ final class NativeListCell: UICollectionViewCell { - pressedBackgroundColor = restingBackgroundColor - } - updateBackgroundColor() -+ if item.data.bool("backgroundFullWidth"), let background = item.data["backgroundColor"] as? String { -+ selectorFullWidthBackground.backgroundColor = UIColor(nativeListHex: background, fallback: .clear).cgColor -+ contentView.layer.insertSublayer(selectorFullWidthBackground, at: 0) -+ clipsToBounds = false -+ } - if layout == "table" { - if item.type == "dataRow" { - rootLeadingConstraint.constant = 20 -@@ -707,8 +829,12 @@ final class NativeListCell: UICollectionViewCell { - } - applyGroupPosition(item.data.string("groupPosition")) - isUserInteractionEnabled = !item.data.bool("disabled") -- contentView.alpha = isUserInteractionEnabled ? 1 : 0.5 -+ // OneKey patch: deprecated wallets remain interactive while dimmed. -+ // contentView.alpha = isUserInteractionEnabled ? 1 : 0.5 -+ contentView.alpha = CGFloat(item.data.double("opacity", default: 1)) * (isUserInteractionEnabled ? 1 : 0.5) - accessibilityLabel = item.data.string("accessibilityLabel", default: item.data.string("title")) -+ // OneKey patch: keep existing selector automation identifiers. -+ accessibilityIdentifier = item.data["testID"] as? String - - switch item.type { - case "walletGroup": bindWalletGroup(item, theme: theme, layout: layout, checkboxState) -@@ -724,6 +850,17 @@ final class NativeListCell: UICollectionViewCell { - case "system": bindSystem(item, theme: theme) - default: break - } -+ applySelectorTypography(item) -+ if shouldRestoreHighlight && isUserInteractionEnabled { -+ isHighlighted = true -+ } -+ if item.type == "sectionHeader", item.data.string("presentation") == "networkSelector", item.data["height"] != nil, item.data.dictionary("checkbox") != nil, !item.data.string("value").isEmpty { -+ // OneKey patch: UIKit must reserve only the total's intrinsic width before the checkbox. -+ let valueWidth = accessoryButtons[0].intrinsicContentSize.width -+ let width = trailingStack.widthAnchor.constraint(equalToConstant: valueWidth + 12 + 20) -+ width.isActive = true -+ selectorConstraints.append(width) -+ } - } - - func updateSelection( -@@ -732,6 +869,8 @@ final class NativeListCell: UICollectionViewCell { - checkboxState: (NativeListItem, NativeSelectionTarget?, String) -> String - ) { - guard currentItem?.key == item.key else { return } -+ restoreSelectorTypography() -+ defer { applySelectorTypography(item) } - if item.type == "walletGroup" { - currentItem = item - let memberData = [item.data.dictionary("parent")].compactMap { $0 } -@@ -777,6 +916,13 @@ final class NativeListCell: UICollectionViewCell { - selected ? "#FFFFFFED" : "#FFFFFFAF" - ) - } -+ // OneKey patch: retain the latest descriptor when a controlled echo avoids full binding. -+ if boundCheckboxData != nil { -+ let latestCheckbox = item.type == "identity" -+ ? item.data.dictionaries("trailing").last { $0.string("kind") == "checkbox" } -+ : item.data.dictionary("checkbox") -+ boundCheckboxData = latestCheckbox ?? boundCheckboxData -+ } - guard let data = boundCheckboxData, let target = boundCheckboxTarget else { return } - updateCheckboxPresentation( - item, -@@ -786,12 +932,55 @@ final class NativeListCell: UICollectionViewCell { - ) - } - -+ // OneKey patch: match SizableText TABULAR_NUMS on every selector text run, retaining its face and size. -+ private func selectorTabularFont(_ font: UIFont) -> UIFont { -+ var settings = font.fontDescriptor.fontAttributes[.featureSettings] as? [[UIFontDescriptor.FeatureKey: Int]] ?? [] -+ settings.removeAll { $0[.type] == kNumberSpacingType } -+ settings.append([.type: kNumberSpacingType, .selector: kMonospacedNumbersSelector]) -+ return UIFont(descriptor: font.fontDescriptor.addingAttributes([.featureSettings: settings]), size: font.pointSize) -+ } -+ -+ private func selectorTabularText(_ original: NSAttributedString) -> NSAttributedString { -+ let result = NSMutableAttributedString(attributedString: original) -+ // OneKey patch: body typography explicitly supplies letterSpacing=0 in Tamagui. -+ result.addAttribute(.kern, value: 0, range: NSRange(location: 0, length: result.length)) -+ original.enumerateAttribute(.font, in: NSRange(location: 0, length: original.length)) { value, range, _ in -+ if let font = value as? UIFont { result.addAttribute(.font, value: self.selectorTabularFont(font), range: range) } -+ } -+ return result -+ } -+ -+ private func restoreSelectorTypography() { -+ selectorTypographyRestorers.reversed().forEach { $0() } -+ selectorTypographyRestorers.removeAll() -+ } -+ -+ private func applySelectorTypography(_ item: NativeListItem) { -+ guard ["accountSelector", "networkSelector", "walletSidebar"].contains(item.data.string("presentation")) || item.type == "system" && item.data.string("variant") == "warning" else { return } -+ func visit(_ view: UIView) { -+ if let button = view as? UIButton { -+ if let original = button.attributedTitle(for: .normal) { -+ selectorTypographyRestorers.append { button.setAttributedTitle(original, for: .normal) } -+ button.setAttributedTitle(selectorTabularText(original), for: .normal) -+ } -+ } else if let label = view as? UILabel, let font = label.font { -+ let original = label.attributedText -+ selectorTypographyRestorers.append { label.font = font; label.attributedText = original } -+ label.font = selectorTabularFont(font) -+ if let original { label.attributedText = selectorTabularText(original) } -+ } -+ for child in view.subviews { visit(child) } -+ } -+ visit(contentView) -+ } -+ - private func updateSummaryText(_ item: NativeListItem) { - let title = item.data.string("title") - titleLabel.isHidden = title.isEmpty - setLineHeight(titleLabel, text: title, lineHeight: 24) - - let value = item.data.string("value") -+ let isExplicitNetworkHeader = item.data.string("presentation") == "networkSelector" && item.data["height"] != nil - let valueButton = accessoryButtons[0] - valueButton.isHidden = value.isEmpty - if value.isEmpty { -@@ -801,7 +990,7 @@ final class NativeListCell: UICollectionViewCell { - setButtonLine( - valueButton, - text: value, -- font: nativeListFont(ofSize: 16), -+ font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), - color: nativeListColor(currentTheme, "secondaryText", "#646464"), - lineHeight: 24 - ) -@@ -826,7 +1015,7 @@ final class NativeListCell: UICollectionViewCell { - // Selection is communicated by the destination state for these source - // components; neither has a persistent selected tile background. - color = nativeListColor(theme, "rowBackground", "#FFFFFF") -- } else if layout == "sectioned" { -+ } else if layout == "sectioned", !item.data.bool("selected") { - // Checkbox-backed section lists in app-monorepo keep rows on $bg; - // selection is represented by the checkbox itself. - color = nativeListColor(theme, "rowBackground", "#FFFFFF") -@@ -836,10 +1025,27 @@ final class NativeListCell: UICollectionViewCell { - !selected { - color = nativeListColor(theme, "subduedBackground", "#F9F9F9") - } -+ // OneKey patch: portfolio group headers retain their source background. -+ if let backgroundColor = item.data["backgroundColor"] as? String { -+ return UIColor(nativeListHex: backgroundColor, fallback: color) -+ } - return color - } - - private func reset() { -+ restoreSelectorTypography() -+ // OneKey patch: remove selector decorations before rebinding recycled cells. -+ selectorViews.forEach { $0.removeFromSuperview() } -+ selectorViews.removeAll() -+ NSLayoutConstraint.deactivate(selectorConstraints) -+ selectorConstraints.removeAll() -+ selectorImages.forEach { $0.prepareForReuse() } -+ selectorImages.removeAll() -+ selectorFullWidthBackground.removeFromSuperlayer() -+ selectorBorder?.removeFromSuperlayer() -+ selectorBorder = nil -+ titleLabel.removeGestureRecognizer(selectorTitleTap) -+ titleLabel.isUserInteractionEnabled = false - walletGroupCompactCell?.invalidateCurrentBinding() - walletGroupCells.forEach { $0.invalidateCurrentBinding() } - isHighlighted = false -@@ -848,6 +1054,10 @@ final class NativeListCell: UICollectionViewCell { - $0.removeFromSuperview() - } - walletGroupMembers.removeAll() -+ // OneKey patch: reset has detached the old hierarchy, including on direct -+ // large-to-small binds that do not pass through UICollectionView reuse. -+ let requiredMembers = currentItem?.type == "walletGroup" ? (currentItem?.data.dictionaries("children").count ?? 0) + 1 : 0 -+ trimWalletGroupCells(keeping: requiredMembers) - walletGroupCompactAppearanceActive = false - walletGroupCompactContainer.isHidden = true - walletGroupCompactContainer.alpha = 1 -@@ -978,11 +1188,13 @@ final class NativeListCell: UICollectionViewCell { - $0.backgroundColor = .clear - } - accessoryButtons.enumerated().forEach { index, button in -+ button.selectorSummaryLineHeight = nil - button.titleLabel?.font = nativeListFont( - ofSize: index == 0 ? 16 : 14, - weight: index == 0 ? .medium : .regular - ) - button.isHidden = true -+ button.accessibilityIdentifier = nil - button.setTitle(nil, for: .normal) - button.setAttributedTitle(nil, for: .normal) - button.setImage(nil, for: .normal) -@@ -994,6 +1206,7 @@ final class NativeListCell: UICollectionViewCell { - button.backgroundColor = .clear - button.layer.cornerRadius = 0 - button.contentEdgeInsets = .zero -+ button.transform = .identity - } - checkboxButton.isHidden = true - checkboxButton.alpha = 1 -@@ -1028,6 +1241,7 @@ final class NativeListCell: UICollectionViewCell { - contentView.clipsToBounds = false - leadingContainer.alpha = 1 - titleLabel.showsDottedUnderline = false -+ titleLabel.reservesDottedUnderlineSpace = false - titleLabel.dottedUnderlineVerticalOffset = 0 - } - -@@ -1073,7 +1287,8 @@ final class NativeListCell: UICollectionViewCell { - while walletGroupCells.count < walletGroupMembers.count { - let memberCell = NativeListCell(frame: .zero) - memberCell.translatesAutoresizingMaskIntoConstraints = false -- memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true -+ // OneKey patch: each member's current height is applied when bound. -+ // memberCell.heightAnchor.constraint(equalToConstant: 68).isActive = true - walletGroupCells.append(memberCell) - } - rootStack.axis = .vertical -@@ -1083,8 +1298,18 @@ final class NativeListCell: UICollectionViewCell { - rootTrailingConstraint.constant = 0 - rootTopConstraint.constant = 0 - rootBottomConstraint.constant = 0 -+ if memberData.first?["height"] != nil { -+ // OneKey patch: the source group's one-point border occupies layout space. -+ rootLeadingConstraint.constant = 1 -+ rootTrailingConstraint.constant = -1 -+ rootTopConstraint.constant = 1 -+ rootBottomConstraint.constant = -1 -+ } - walletGroupMembers.enumerated().forEach { index, member in - let memberCell = walletGroupCells[index] -+ // OneKey patch: badges add a second line within their logical wallet group. -+ memberCell.constraints.filter { $0.firstAttribute == .height && $0.secondItem == nil }.forEach { $0.isActive = false } -+ memberCell.heightAnchor.constraint(equalToConstant: CGFloat(member.data.double("height", default: member.data.dictionaries("badges").isEmpty ? 68 : 92))).isActive = true - memberCell.onAction = { [weak self] source, action, target, origin in - self?.onAction?(source, action, target, origin) - } -@@ -1140,7 +1365,10 @@ final class NativeListCell: UICollectionViewCell { - let point = gesture.location(in: rootStack) - for (index, cell) in walletGroupCells.prefix(walletGroupMembers.count).enumerated() - where cell.frame.contains(point) { -- onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin()) -+ // OneKey patch: group member press gating must not disable accessory controls. -+ if !walletGroupMembers[index].data.bool("pressDisabled") { -+ onAction?(walletGroupMembers[index], "press", nil, cell.rowActionOrigin()) -+ } - return - } - } -@@ -1269,8 +1497,14 @@ final class NativeListCell: UICollectionViewCell { - contentView.clipsToBounds = true - } else { - applyGroupPosition(currentItem?.data.string("groupPosition") ?? "") -- let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0 -+ // OneKey patch: explicit account and network selectors preserve ListItem radius while idle. -+ // let restingRadius: CGFloat = currentItem?.type == "metricCard" ? 12 : 0 -+ let isSelectorIdentity = currentItem?.type == "identity" && currentItem?.data["height"] != nil -+ let isAccountSelector = isSelectorIdentity && ["accountSelector", "networkSelector"].contains(currentItem?.data.string("presentation") ?? "") -+ let isWalletSidebar = isSelectorIdentity && currentItem?.data.string("presentation") == "walletSidebar" -+ let restingRadius: CGFloat = isWalletSidebar ? 20 : currentItem?.type == "metricCard" || isAccountSelector ? 12 : 0 - contentView.layer.cornerRadius = restingRadius -+ contentView.layer.cornerCurve = isWalletSidebar ? .continuous : .circular - contentView.clipsToBounds = restingRadius > 0 - } - } -@@ -1299,6 +1533,17 @@ final class NativeListCell: UICollectionViewCell { - fallbackLabel.font = nativeListFont(ofSize: 28) - addLeading(item.data.dictionary("leading"), key: item.key) - rootStack.addArrangedSubview(mainStack) -+ // OneKey patch: activate width constraints only after both stacks share an ancestor. -+ if item.data["height"] != nil { -+ titleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) -+ titleRowStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) -+ let width = mainStack.widthAnchor.constraint(equalTo: rootStack.widthAnchor) -+ width.isActive = true -+ selectorConstraints.append(width) -+ let titleWidth = titleRowStack.widthAnchor.constraint(lessThanOrEqualTo: mainStack.widthAnchor) -+ titleWidth.isActive = true -+ selectorConstraints.append(titleWidth) -+ } - show(titleLabel, item.data.string("title"), lines: 1) - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 16) - titleLabel.textColor = nativeListColor( -@@ -1306,6 +1551,32 @@ final class NativeListCell: UICollectionViewCell { - selected ? "primaryText" : "secondaryText", - selected ? "#FFFFFFED" : "#FFFFFFAF" - ) -+ // OneKey patch: wallet tags belong below the centered name. -+ let badges = item.data.dictionaries("badges") -+ if !badges.isEmpty { -+ let line = UIStackView() -+ line.axis = .horizontal -+ line.spacing = 4 -+ line.alignment = .center -+ for badge in badges { -+ let label = NativeListInsetLabel() -+ let isSelector = item.data["height"] != nil -+ let isWarning = badge.string("tone") == "warning" -+ label.font = nativeListFont(ofSize: isSelector ? 11 : 12) -+ label.textColor = nativeListColor(theme, isSelector && isWarning ? "caution" : "secondaryText", isSelector && isWarning ? "#AB6400" : "#646464") -+ label.backgroundColor = nativeListColor(theme, isSelector ? (isWarning ? "cautionBackground" : "subduedBackground") : "strongBackground", isSelector && isWarning ? "#FFF8C5" : "#F0F0F0") -+ label.horizontalInset = isSelector ? 6 : 4 -+ label.topInset = 2 -+ label.bottomInset = 2 -+ label.layer.cornerRadius = 4 -+ label.clipsToBounds = true -+ setLineHeight(label, text: badge.string("text"), lineHeight: isSelector ? 14 : 16) -+ line.addArrangedSubview(label) -+ } -+ mainStack.spacing = 4 -+ mainStack.addArrangedSubview(line) -+ selectorViews.append(line) -+ } - return - } - if item.data.string("presentation") == "accountSelector" { -@@ -1344,6 +1615,12 @@ final class NativeListCell: UICollectionViewCell { - rootStack.setCustomSpacing(5, after: leadingActionButton) - } - addLeading(item.data.dictionary("leading"), key: item.key) -+ // OneKey patch: custom network initials match LetterAvatar size 32. -+ if item.data.string("presentation") == "networkSelector", let leading = item.data.dictionary("leading"), leading.dictionary("image") == nil, leading.dictionary("fallbackIcon") == nil, !leading.string("fallbackText").isEmpty { -+ fallbackLabel.font = nativeListFont(ofSize: 19, weight: .semibold) -+ fallbackLabel.textColor = nativeListColor(theme, "inverseText", "#FCFCFC") -+ setLineHeight(fallbackLabel, text: leading.string("fallbackText"), lineHeight: 27) -+ } - rootStack.addArrangedSubview(mainStack) - show(titleLabel, item.data.string("title"), lines: item.data.int("titleLines", default: 1)) - show(subtitleLabel, item.data.string("subtitle"), lines: item.data.int("subtitleLines", default: 1)) -@@ -1353,6 +1630,77 @@ final class NativeListCell: UICollectionViewCell { - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) - setLineHeight(subtitleLabel, text: item.data.string("subtitle"), lineHeight: 20) - } -+ // OneKey patch: preserve independent balance/address truncation and warning tones. -+ let segments = item.data.dictionaries("subtitleSegments") -+ if !segments.isEmpty { -+ subtitleLabel.isHidden = true -+ mainStack.spacing = 0 -+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) -+ let line = UIStackView() -+ line.axis = .horizontal -+ line.alignment = .center -+ line.spacing = 0 -+ for segment in segments { -+ if segment.bool("separatorBefore") { -+ let gap = UIView() -+ gap.translatesAutoresizingMaskIntoConstraints = false -+ let dot = UIView() -+ dot.translatesAutoresizingMaskIntoConstraints = false -+ dot.backgroundColor = nativeListColor(theme, "disabledText", "#8D8D8D") -+ dot.layer.cornerRadius = 2 -+ gap.addSubview(dot) -+ NSLayoutConstraint.activate([ -+ gap.widthAnchor.constraint(equalToConstant: 16), -+ gap.heightAnchor.constraint(equalToConstant: 20), -+ dot.widthAnchor.constraint(equalToConstant: 4), -+ dot.heightAnchor.constraint(equalToConstant: 4), -+ dot.centerXAnchor.constraint(equalTo: gap.centerXAnchor), -+ dot.centerYAnchor.constraint(equalTo: gap.centerYAnchor), -+ ]) -+ line.addArrangedSubview(gap) -+ } -+ let label = UILabel() -+ label.font = nativeListFont(ofSize: 14) -+ label.lineBreakMode = .byTruncatingTail -+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) -+ label.textColor = dataTextColor(segment.string("tone", default: "secondary"), theme: theme) -+ setLineHeight(label, text: segment.string("text"), lineHeight: 20) -+ let runs = segment.dictionaries("textSegments") -+ if !runs.isEmpty { -+ let value = NSMutableAttributedString(string: "") -+ let paragraph = NSMutableParagraphStyle() -+ paragraph.minimumLineHeight = 20 -+ paragraph.maximumLineHeight = 20 -+ for run in runs { -+ value.append(NSAttributedString(string: run.string("text"), attributes: [ -+ .font: nativeListFont(ofSize: run.string("style") == "subscript" ? 9 : 14), -+ .foregroundColor: label.textColor as Any, -+ .paragraphStyle: paragraph, -+ .baselineOffset: max(0, (20 - nativeListFont(ofSize: 14).lineHeight) / 2), -+ ])) -+ } -+ label.attributedText = value -+ } -+ line.addArrangedSubview(label) -+ } -+ let filler = UIView() -+ filler.setContentHuggingPriority(UILayoutPriority(1), for: .horizontal) -+ line.addArrangedSubview(filler) -+ mainStack.insertArrangedSubview(line, at: 2) -+ selectorViews.append(line) -+ } -+ let matches = item.data.dictionaries("titleMatch") -+ if !matches.isEmpty { -+ let text = NSMutableAttributedString(attributedString: titleLabel.attributedText ?? NSAttributedString(string: item.data.string("title"))) -+ for match in matches { -+ let start = match.int("start") -+ let end = match.int("end") -+ if start >= 0 && end > start && end <= text.length { -+ text.addAttribute(.foregroundColor, value: nativeListColor(theme, "info", "#0D74CE"), range: NSRange(location: start, length: end - start)) -+ } -+ } -+ titleLabel.attributedText = text -+ } - tertiaryLabel.textColor = nativeListColor( - theme, - item.data.string("tertiaryTone") == "info" ? "info" : "secondaryText", -@@ -1997,15 +2345,29 @@ final class NativeListCell: UICollectionViewCell { - ) { - rootStack.addArrangedSubview(mainStack) - let variant = item.data.string("variant") -+ // OneKey patch: title help is a separate target from checkbox and value actions. -+ if !item.data.string("titleActionKey").isEmpty { -+ titleLabel.isUserInteractionEnabled = true -+ titleLabel.addGestureRecognizer(selectorTitleTap) -+ titleLabel.setContentHuggingPriority(.required, for: .horizontal) -+ } - let isSummary = variant == "summary" - let isGallery = variant == "gallery" - let isTable = layout == "table" - let isNetworkSelector = item.data.string("presentation") == "networkSelector" -+ let isExplicitNetworkHeader = isNetworkSelector && item.data["height"] != nil -+ if isExplicitNetworkHeader { -+ // OneKey patch: the flexible title consumes spare space before trailing totals. -+ trailingStack.setContentHuggingPriority(.required, for: .horizontal) -+ trailingStack.setContentCompressionResistancePriority(.required, for: .horizontal) -+ } -+ titleLabel.reservesDottedUnderlineSpace = isExplicitNetworkHeader && !item.data.string("titleActionKey").isEmpty - let isHistory = variant == "history" || - item.key.hasPrefix("history-") || - (item.sectionKey?.hasPrefix("history-") ?? false) - let headerWeight: NativeListFontWeight = isSummary - ? .medium -+ : isExplicitNetworkHeader && (item.data.dictionary("checkbox") != nil || item.data.string("titleActionKey").isEmpty) ? .semibold - : isNetworkSelector ? .medium - : isGallery || layout == "sectioned" ? .semibold : .regular - titleLabel.font = nativeListFont( -@@ -2019,8 +2381,8 @@ final class NativeListCell: UICollectionViewCell { - ) - show(titleLabel, item.data.string("title"), lines: 1) - if isNetworkSelector { -- titleLabel.showsDottedUnderline = true -- titleLabel.dottedUnderlineVerticalOffset = 2 -+ titleLabel.showsDottedUnderline = !isExplicitNetworkHeader || !item.data.string("titleActionKey").isEmpty -+ titleLabel.dottedUnderlineVerticalOffset = isExplicitNetworkHeader ? 1 : 2 - titleLabel.dottedUnderlineColor = nativeListColor( - theme, - "secondaryText", -@@ -2028,8 +2390,9 @@ final class NativeListCell: UICollectionViewCell { - ) - rootLeadingConstraint.constant = 12 - rootTrailingConstraint.constant = -12 -- rootTopConstraint.constant = 12 -- rootBottomConstraint.constant = -15 -+ let isAlphabet = isExplicitNetworkHeader && item.data.string("titleActionKey").isEmpty -+ rootTopConstraint.constant = isAlphabet ? 8 : 12 -+ rootBottomConstraint.constant = isAlphabet ? -8 : isExplicitNetworkHeader ? -12 : -15 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) - } else if isHistory { - rootLeadingConstraint.constant = 0 -@@ -2095,6 +2458,7 @@ final class NativeListCell: UICollectionViewCell { - rootTopConstraint.constant = 24 - rootBottomConstraint.constant = -20 - setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 24) -+ accessoryButtons[0].accessibilityIdentifier = item.data["valueActionTestID"] as? String - let valueActionKey = item.data.string("valueActionKey") - let action: (String, NativeSelectionTarget?)? = valueActionKey.isEmpty - ? nil -@@ -2105,11 +2469,11 @@ final class NativeListCell: UICollectionViewCell { - action: action, - color: nativeListColor(theme, "secondaryText", "#646464") - ) -- accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16) -+ accessoryButtons[0].titleLabel?.font = nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular) - setButtonLine( - accessoryButtons[0], - text: item.data.string("value"), -- font: nativeListFont(ofSize: 16), -+ font: nativeListFont(ofSize: 16, weight: isExplicitNetworkHeader ? .medium : .regular), - color: nativeListColor(theme, "secondaryText", "#646464"), - lineHeight: 24 - ) -@@ -2169,6 +2533,28 @@ final class NativeListCell: UICollectionViewCell { - ) - } - } -+ applyValueSegments(item.data.dictionaries("valueSegments"), to: accessoryButtons[0], theme: theme) -+ } -+ -+ // OneKey patch: small zero-count digits remain on the regular amount baseline. -+ private func applyValueSegments(_ segments: [[String: Any]], to button: UIButton, theme: [String: Any]?) { -+ guard !segments.isEmpty else { return } -+ let value = NSMutableAttributedString(string: "") -+ let color = nativeListColor(theme, "primaryText", "#202020") -+ // OneKey patch: rich currency changes font runs without dropping the established line baseline. -+ let current = button.attributedTitle(for: .normal) -+ var attributes = current.flatMap { $0.length > 0 ? $0.attributes(at: 0, effectiveRange: nil) : nil } ?? [:] -+ attributes[.foregroundColor] = color -+ if currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil { -+ let paragraph = (attributes[.paragraphStyle] as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle() -+ paragraph.alignment = .right -+ attributes[.paragraphStyle] = paragraph -+ } -+ for segment in segments { -+ attributes[.font] = nativeListTabularFont(ofSize: segment.string("style") == "subscript" ? 10 : 16, weight: .medium) -+ value.append(NSAttributedString(string: segment.string("text"), attributes: attributes)) -+ } -+ button.setAttributedTitle(value, for: .normal) - } - - private func bindAction( -@@ -2185,6 +2571,8 @@ final class NativeListCell: UICollectionViewCell { - addLeading(icon, key: item.key) - if isAccountSelector { - leadingContainer.layer.cornerCurve = .continuous -+ leadingContainer.layer.cornerRadius = 8 -+ leadingContainer.layer.borderWidth = 0 - } - if icon["backgroundColor"] == nil { - leadingContainer.backgroundColor = .clear -@@ -2195,8 +2583,9 @@ final class NativeListCell: UICollectionViewCell { - rootStack.addArrangedSubview(mainStack) - show(titleLabel, item.data.string("title"), lines: 1) - if isAccountSelector { -- titleLabel.font = nativeListFont(ofSize: 16) -- titleLabel.textColor = nativeListColor(theme, "secondaryText", "#646464") -+ // OneKey patch: ListItem.Text is medium; empty-search actions use regular body text. -+ titleLabel.font = nativeListFont(ofSize: 16, weight: item.data.dictionary("icon") == nil ? .regular : .medium) -+ titleLabel.textColor = nativeListColor(theme, item.data.string("tone") == "primary" ? "primaryText" : "secondaryText", item.data.string("tone") == "primary" ? "#202020" : "#646464") - } else if item.data.string("tone") == "danger" { - titleLabel.textColor = nativeListColor(theme, "negative", "#CE2C31") - } -@@ -2217,8 +2606,19 @@ final class NativeListCell: UICollectionViewCell { - } - } - -+ // OneKey patch: preserve the actual title frame for Popover placement. -+ @objc private func selectorTitlePressed() { -+ guard let item = currentItem else { return } -+ let action = item.data.string("titleActionKey") -+ guard !action.isEmpty else { return } -+ onAction?(item, action, nil, actionOrigin(sourceView: titleLabel, source: "leadingAction")) -+ } -+ - private func dataTextColor(_ tone: String, theme: [String: Any]?) -> UIColor { - switch tone.isEmpty ? "primary" : tone { -+ // OneKey patch: account warnings and hidden balances use existing theme tokens. -+ case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") -+ case "caution": return nativeListColor(theme, "caution", "#AB6400") - case "secondary": return nativeListColor(theme, "secondaryText", "#646464") - case "positive": return nativeListColor(theme, "positive", "#218358") - case "negative": return nativeListColor(theme, "negative", "#CE2C31") -@@ -2230,6 +2630,36 @@ final class NativeListCell: UICollectionViewCell { - rootStack.alignment = .center - rootStack.distribution = .fill - let variant = item.data.string("variant") -+ // OneKey patch: deprecated-wallet warnings stay inside the scrolling list. -+ if variant == "warning" { -+ rootStack.addArrangedSubview(mainStack) -+ rootTopConstraint.constant = 14 -+ rootBottomConstraint.constant = -14 -+ mainStack.spacing = 4 -+ titleLabel.font = nativeListFont(ofSize: 14, weight: .medium) -+ titleLabel.numberOfLines = 0 -+ subtitleLabel.font = nativeListFont(ofSize: 14) -+ subtitleLabel.numberOfLines = 0 -+ show(titleLabel, item.data.string("title"), lines: 0) -+ show(subtitleLabel, item.data.string("message"), lines: 0) -+ setLineHeight(titleLabel, text: item.data.string("title"), lineHeight: 20) -+ setLineHeight(subtitleLabel, text: item.data.string("message"), lineHeight: 20) -+ let borderColor = UIColor(nativeListHex: item.data.string("borderColor", default: "#E0E0E0"), fallback: .lightGray) -+ for top in [true, false] { -+ let border = UIView() -+ border.translatesAutoresizingMaskIntoConstraints = false -+ border.backgroundColor = borderColor -+ contentView.addSubview(border) -+ NSLayoutConstraint.activate([ -+ border.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: -8), -+ border.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 8), -+ border.heightAnchor.constraint(equalToConstant: 1 / UIScreen.main.scale), -+ top ? border.topAnchor.constraint(equalTo: contentView.topAnchor) : border.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), -+ ]) -+ selectorViews.append(border) -+ } -+ return -+ } - if variant == "loading" { - leadingWidth.constant = 40 - leadingHeight.constant = 40 -@@ -2357,7 +2787,105 @@ final class NativeListCell: UICollectionViewCell { - tokenPair: tokenPair, - shape: shape - )) -- bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant) -+ if currentItem?.data.string("presentation") == "networkSelector", currentItem?.data["height"] != nil, visibleSources.count == 1, cornerIcon == nil, visual.dictionaries("overlays").isEmpty { -+ // OneKey patch: a single outer mask matches NetworkAvatar's edge antialiasing. -+ imageView.layer.cornerRadius = 0 -+ imageView.clipsToBounds = false -+ } -+ let fallbackIcon = index == 0 ? visual.dictionary("fallbackIcon") : nil -+ let expectedEpoch = bindingEpoch -+ bindImage(source.data, into: imageView, token: key, slot: index, variant: source.variant, -+ onLoad: fallbackIcon == nil ? nil : { [weak self, weak imageView] in -+ guard let self, self.bindingEpoch == expectedEpoch else { return } -+ imageView?.isHidden = false -+ self.leadingIconImageView.isHidden = true -+ }, -+ onError: fallbackIcon == nil ? nil : { [weak self, weak imageView] in -+ guard let self, self.bindingEpoch == expectedEpoch, let fallbackIcon else { return } -+ imageView?.isHidden = true -+ self.fallbackLabel.isHidden = true -+ self.leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name")) -+ self.leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray) -+ self.leadingIconImageView.isHidden = false -+ }) -+ } -+ // OneKey patch: source-derived wallet decorations may occupy both corners. -+ let overlays = visual.dictionaries("overlays") -+ if !overlays.isEmpty { leadingContainer.clipsToBounds = false } -+ for (index, overlay) in overlays.enumerated() { -+ let size = CGFloat(overlay.double("size", default: 20)) -+ let isWalletText = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil && !overlay.string("text").isEmpty && overlay.dictionary("image") == nil && overlay.string("name").isEmpty -+ let inset = CGFloat(overlay.double("padding", default: 0)) -+ let offsetX = CGFloat(overlay.double("offsetX", default: overlay.double("offset", default: 2))) -+ let offsetY = CGFloat(overlay.double("offsetY", default: overlay.double("offset", default: 2))) -+ let height = CGFloat(overlay.double("height", default: isWalletText ? 16 : Double(size))) -+ let textWidth = (overlay.string("text") as NSString).size(withAttributes: [.font: nativeListTabularFont(ofSize: 12), .kern: 0]).width -+ let naturalTextWidth = ceil(textWidth * UIScreen.main.scale) / UIScreen.main.scale + 4 -+ let width = CGFloat(overlay.double("width", default: isWalletText ? Double(naturalTextWidth) : Double(size))) -+ let frame = UIView() -+ frame.translatesAutoresizingMaskIntoConstraints = false -+ frame.backgroundColor = UIColor(nativeListHex: overlay.string("backgroundColor", default: "#FFFFFF"), fallback: .clear) -+ frame.layer.cornerRadius = min(width, height) / 2 -+ frame.clipsToBounds = true -+ leadingContainer.addSubview(frame) -+ selectorViews.append(frame) -+ let topLeft = overlay.string("position") == "topLeft" -+ leadingSlotConstraints.append(contentsOf: [ -+ frame.widthAnchor.constraint(equalToConstant: width), -+ frame.heightAnchor.constraint(equalToConstant: height), -+ topLeft ? frame.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor, constant: -offsetX) : frame.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor, constant: offsetX), -+ topLeft ? frame.topAnchor.constraint(equalTo: leadingContainer.topAnchor, constant: -offsetY) : frame.bottomAnchor.constraint(equalTo: leadingContainer.bottomAnchor, constant: offsetY), -+ ]) -+ let content: UIView -+ if let image = overlay.dictionary("image") { -+ let imageView = OneKeyImageReusableView(frame: .zero) -+ bindImage(image, into: imageView, token: key, slot: 10 + index, variant: "generic") -+ selectorImages.append(imageView) -+ content = imageView -+ } else if !overlay.string("text").isEmpty { -+ let label = UILabel() -+ label.text = overlay.string("text") -+ label.textAlignment = .center -+ label.font = nativeListFont(ofSize: isWalletText ? 12 : 10, weight: isWalletText ? .regular : .medium) -+ label.textColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray) -+ if isWalletText { setLineHeight(label, text: overlay.string("text"), lineHeight: 16) } -+ content = label -+ } else { -+ let imageView = UIImageView(image: nativeListIcon(named: overlay.string("name"))) -+ imageView.contentMode = .scaleAspectFit -+ imageView.tintColor = UIColor(nativeListHex: overlay.string("tintColor", default: "#646464"), fallback: .darkGray) -+ content = imageView -+ } -+ content.translatesAutoresizingMaskIntoConstraints = false -+ frame.addSubview(content) -+ NSLayoutConstraint.activate([ -+ content.leadingAnchor.constraint(equalTo: frame.leadingAnchor, constant: isWalletText ? 2 : inset), -+ content.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: isWalletText ? -2 : -inset), -+ content.topAnchor.constraint(equalTo: frame.topAnchor, constant: isWalletText ? 0 : inset), -+ content.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: isWalletText ? 0 : -inset), -+ ]) -+ } -+ if let fallbackIcon = visual.dictionary("fallbackIcon"), sources.isEmpty { -+ fallbackLabel.isHidden = true -+ leadingIconImageView.isHidden = false -+ leadingIconImageView.image = nativeListIcon(named: fallbackIcon.string("name")) -+ leadingIconImageView.tintColor = UIColor(nativeListHex: fallbackIcon.string("tintColor", default: "#646464"), fallback: .darkGray) -+ if currentItem?.data.string("presentation") == "walletSidebar", currentItem?.data["height"] != nil, fallbackIcon.string("name") == "LockSolid" { -+ leadingIconWidth.constant = 40 -+ leadingIconHeight.constant = 40 -+ leadingContainer.clipsToBounds = false -+ } -+ } -+ if visual.string("borderStyle") == "dashed" { -+ let border = CAShapeLayer() -+ border.strokeColor = UIColor(nativeListHex: visual.string("borderColor", default: "#8D8D8D"), fallback: .gray).cgColor -+ border.fillColor = UIColor.clear.cgColor -+ border.lineWidth = currentItem?.data.string("presentation") == "walletSidebar" && currentItem?.data["height"] != nil ? 1 : 2 -+ border.lineDashPattern = [4, 4] -+ let borderInset = border.lineWidth / 2 -+ border.path = UIBezierPath(ovalIn: CGRect(x: borderInset, y: borderInset, width: leadingWidth.constant - border.lineWidth, height: leadingHeight.constant - border.lineWidth)).cgPath -+ leadingContainer.layer.addSublayer(border) -+ selectorBorder = border - } - NSLayoutConstraint.activate(leadingSlotConstraints) - } -@@ -2439,6 +2967,11 @@ final class NativeListCell: UICollectionViewCell { - switch accessory.string("kind") { - case "value": - showAccessory(textIndex, accessory.string("text")) -+ if item.data.string("presentation") == "networkSelector" && item.data["height"] != nil { -+ accessoryButtons[textIndex].contentHorizontalAlignment = .trailing -+ accessoryButtons[textIndex].titleLabel?.textAlignment = .right -+ } -+ applyValueSegments(accessory.dictionaries("textSegments"), to: accessoryButtons[textIndex], theme: theme) - textIndex += 1 - case "valuePair": - showValuePairAccessory(textIndex, accessory, theme: theme) -@@ -2519,7 +3052,7 @@ final class NativeListCell: UICollectionViewCell { - state == "unchecked" ? nil : nativeListIcon(named: glyphName), - for: .normal - ) -- checkboxButton.tintColor = checkboxUncheckedColor -+ checkboxButton.tintColor = checkboxIconColor - // A disabled ListItem already applies 0.5 to its complete content. Avoid - // multiplying that opacity on the nested control a second time. - checkboxButton.alpha = item.data.bool("disabled") ? 1 : accessoryDisabled ? 0.5 : 1 -@@ -2543,11 +3076,24 @@ final class NativeListCell: UICollectionViewCell { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = lineHeight - paragraphStyle.maximumLineHeight = lineHeight -+ if currentItem?.data.string("presentation") == "walletSidebar" { -+ // OneKey patch: attributed paragraphs must preserve wallet name alignment and tail ellipsis. -+ paragraphStyle.alignment = label.textAlignment -+ paragraphStyle.lineBreakMode = label.lineBreakMode -+ } - var attributes: [NSAttributedString.Key: Any] = [ - .font: label.font as Any, - .foregroundColor: label.textColor as Any, - .paragraphStyle: paragraphStyle, - ] -+ if (currentItem?.data["height"] != nil && (["accountSelector", "walletSidebar"].contains(currentItem?.data.string("presentation") ?? "") || currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector")) || currentItem?.type == "system" && currentItem?.data.string("variant") == "warning" { -+ // OneKey patch: React Native centers font metrics inside explicit line heights. -+ let baselineOffset = max(0, (lineHeight - label.font.lineHeight) / 2) -+ // OneKey patch: TextKit's 14/20 headings align their baseline to the upper physical pixel. -+ let isSelectorHeading = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && lineHeight == 20 -+ let scale = window?.screen.scale ?? traitCollection.displayScale -+ attributes[.baselineOffset] = isSelectorHeading && scale > 0 ? ceil(baselineOffset * scale) / scale : baselineOffset -+ } - if letterSpacing != 0 { attributes[.kern] = letterSpacing } - label.attributedText = NSAttributedString(string: text, attributes: attributes) - } -@@ -2563,7 +3109,20 @@ final class NativeListCell: UICollectionViewCell { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.minimumLineHeight = lineHeight - paragraphStyle.maximumLineHeight = lineHeight -- paragraphStyle.alignment = .center -+ let isSelectorValue = currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") != "summary" -+ let isSelectorSummary = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil && currentItem?.data.string("variant") == "summary" -+ (button as? NativeListAccessoryButton)?.selectorSummaryLineHeight = isSelectorSummary ? lineHeight : nil -+ // OneKey patch: summary text uses its source line box; currency retains trailing alignment. -+ paragraphStyle.alignment = isSelectorValue ? .right : isSelectorSummary ? .natural : .center -+ if isSelectorSummary { -+ button.contentHorizontalAlignment = .leading -+ button.titleLabel?.textAlignment = .natural -+ } -+ if isSelectorValue { -+ button.contentHorizontalAlignment = .trailing -+ button.titleLabel?.textAlignment = .right -+ } -+ let baselineOffset: CGFloat = currentItem?.type == "sectionHeader" && currentItem?.data.string("presentation") == "networkSelector" && currentItem?.data["height"] != nil ? max(0, (lineHeight - font.lineHeight) / 2) : 0 - button.setAttributedTitle( - NSAttributedString( - string: text, -@@ -2571,6 +3130,7 @@ final class NativeListCell: UICollectionViewCell { - .font: font, - .foregroundColor: color, - .paragraphStyle: paragraphStyle, -+ .baselineOffset: baselineOffset, - ] - ), - for: .normal -@@ -2671,6 +3231,9 @@ final class NativeListCell: UICollectionViewCell { - switch tone.isEmpty ? defaultTone : tone { - case "positive": return nativeListColor(theme, "positive", "#218358") - case "negative": return nativeListColor(theme, "negative", "#CE2C31") -+ // OneKey patch: account warnings and hidden balances use existing theme tokens. -+ case "disabled": return nativeListColor(theme, "disabledText", "#8D8D8D") -+ case "caution": return nativeListColor(theme, "caution", "#AB6400") - case "secondary": return nativeListColor(theme, "secondaryText", "#646464") - default: return nativeListColor(theme, "primaryText", "#202020") - } -@@ -2682,10 +3245,10 @@ final class NativeListCell: UICollectionViewCell { - button.isHidden = false - button.isEnabled = !data.bool("disabled") - button.alpha = button.isEnabled ? 1 : 0.4 -- let tintColor = UIColor( -- nativeListHex: data.string("tintColor", default: "#646464"), -- fallback: .darkGray -- ) -+ let isAccountCreate = currentItem?.data.string("presentation") == "accountSelector" && currentItem?.data["height"] != nil && data.string("name") == "PlusSmallOutline" -+ let tintColor = data["tintColor"] == nil && isAccountCreate -+ ? nativeListColor(currentTheme, "iconSubdued", "#8D8D8D") -+ : UIColor(nativeListHex: data.string("tintColor", default: "#646464"), fallback: .darkGray) - button.tintColor = tintColor - if let image = nativeListIcon(named: data.string("name")) { - button.setImage(image, for: .normal) -@@ -2695,7 +3258,12 @@ final class NativeListCell: UICollectionViewCell { - ) - } - let isDrillIn = data.string("kind") == "chevron" -- let size: CGFloat = isDrillIn ? 24 : 36 -+ let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" && !isDrillIn -+ let size: CGFloat = isDrillIn ? 24 : isAccountIcon && !isAccountCreate ? 38 : 36 -+ if isAccountCreate { button.layer.cornerRadius = 8 } -+ if isAccountIcon { rootStack.setCustomSpacing(5, after: mainStack) } -+ button.accessibilityIdentifier = data["testID"] as? String -+ button.accessibilityLabel = data["accessibilityLabel"] as? String - if !isDrillIn, !data.string("actionKey").isEmpty { - // Reproduce the trailing edge of IconButton's m=-7 while keeping its - // full 36-point frame for padding/highlight behavior. -@@ -2726,8 +3294,15 @@ final class NativeListCell: UICollectionViewCell { - into imageView: OneKeyImageReusableView, - token: String, - slot: Int, -- variant: String -+ variant: String, -+ onLoad: (() -> Void)? = nil, -+ onError: (() -> Void)? = nil, -+ retryAttempt: Int = 0 - ) { -+ let imageID = ObjectIdentifier(imageView) -+ selectorImageRetries.removeValue(forKey: imageID)?.cancel() -+ let expectedEpoch = bindingEpoch -+ let retryLimit = max(0, source.int("retryTimes", default: 0)) - let headersJson: String? - if let headers = source.dictionary("headers"), - JSONSerialization.isValidJSONObject(headers), -@@ -2743,10 +3318,27 @@ final class NativeListCell: UICollectionViewCell { - contentFit: source.string("contentFit", default: "cover"), - cachePolicy: source.string("cachePolicy", default: "memory-disk"), - autoplay: source.bool("autoplay"), -- recyclingKey: "\(token):\(slot)", -- optimizeTos: source["optimizeTos"] == nil || source.bool("optimizeTos"), -+ recyclingKey: retryAttempt == 0 ? "\(token):\(slot)" : "\(token):\(slot):retry:\(retryAttempt)", -+ optimizeTos: retryAttempt == 0 && (source["optimizeTos"] == nil || source.bool("optimizeTos")), - overscan: source["overscan"] == nil ? 1.1 : source.double("overscan"), -- loadingStrategy: source.string("loadingStrategy", default: "static") -+ loadingStrategy: source.string("loadingStrategy", default: "static"), -+ onLoad: retryLimit == 0 ? onLoad : { [weak self] in -+ guard let self, self.bindingEpoch == expectedEpoch else { return } -+ self.selectorImageRetries.removeValue(forKey: imageID)?.cancel() -+ onLoad?() -+ }, -+ onError: retryLimit == 0 ? onError : { [weak self, weak imageView] in -+ guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return } -+ guard retryAttempt < retryLimit else { onError?(); return } -+ guard self.selectorImageRetries[imageID] == nil else { return } -+ let retry = DispatchWorkItem { [weak self, weak imageView] in -+ guard let self, self.bindingEpoch == expectedEpoch, let imageView else { return } -+ self.selectorImageRetries.removeValue(forKey: imageID) -+ self.bindImage(source, into: imageView, token: token, slot: slot, variant: variant, onLoad: onLoad, onError: onError, retryAttempt: retryAttempt + 1) -+ } -+ self.selectorImageRetries[imageID] = retry -+ DispatchQueue.main.asyncAfter(deadline: .now() + Double(Int.random(in: 0...2)), execute: retry) -+ } - ) - } - -@@ -2837,12 +3429,17 @@ final class NativeListCell: UICollectionViewCell { - source: String, - slot: Int? = nil - ) -> NativeListActionOrigin { -- NativeListActionOrigin( -+ let isAccountIcon = currentItem?.data.string("presentation") == "accountSelector" -+ && source == "trailingAccessory" -+ && accessoryButtons.contains { $0 === sourceView } -+ && sourceView.bounds.width == 38 -+ return NativeListActionOrigin( - sourceView: sourceView, - ownerCell: self, - bindingEpoch: bindingEpoch, - source: source, -- slot: slot -+ slot: slot, -+ anchorInset: isAccountIcon ? 7 : 0 - ) - } - -@@ -2851,6 +3448,8 @@ final class NativeListCell: UICollectionViewCell { - } - - private func invalidateCurrentBinding() { -+ selectorImageRetries.values.forEach { $0.cancel() } -+ selectorImageRetries.removeAll() - guard currentItem != nil else { return } - onBindingInvalidated?(self, bindingEpoch) - bindingEpoch &+= 1 -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift -index 7dd7368..3f3b90a 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/NativeListDesignAssets.swift -@@ -76,8 +76,31 @@ func nativeListTabularFont( - } - - func nativeListIcon(named name: String) -> UIImage? { -+ // OneKey patch: the connection indicator is a solid circle at its requested size. -+ if name == "Circle" { -+ return UIGraphicsImageRenderer(size: CGSize(width: 24, height: 24)).image { _ in -+ UIColor.black.setFill() -+ UIBezierPath(ovalIn: CGRect(x: 0, y: 0, width: 24, height: 24)).fill() -+ }.withRenderingMode(.alwaysTemplate) -+ } - let assetName: String - switch name { -+ // OneKey patch: official selector icon geometry. -+ case "AccountErrorCustom": assetName = "onekey_selector_account_error_custom" -+ // OneKey patch: official selector icon geometry. -+ case "CrossedSmallSolid": assetName = "onekey_selector_crossed_small_solid" -+ // OneKey patch: official selector icon geometry. -+ case "AllNetworksSolid": assetName = "onekey_selector_all_networks_solid" -+ // OneKey patch: official selector icon geometry. -+ case "BotIllus": assetName = "onekey_selector_bot_illus" -+ // OneKey patch: official selector icon geometry. -+ case "AppleBrand": assetName = "onekey_selector_apple_brand" -+ // OneKey patch: official selector icon geometry. -+ case "GoogleIllus": assetName = "onekey_selector_google_illus" -+ // OneKey patch: official selector icon geometry. -+ case "LockSolid": assetName = "onekey_selector_lock_solid" -+ // OneKey patch: official selector icon geometry. -+ case "GlobusOutline": assetName = "onekey_selector_globus_outline" - case "ArrowBottomOutline": assetName = "onekey_arrow_bottom" - case "ArrowTopOutline": assetName = "onekey_arrow_top" - case "ChartTrendingUpOutline": assetName = "onekey_chart_trending_up" -@@ -106,5 +129,5 @@ func nativeListIcon(named name: String) -> UIImage? { - default: return nil - } - return UIImage(named: assetName, in: NativeListResources.bundle, compatibleWith: nil)? -- .withRenderingMode(.alwaysTemplate) -+ .withRenderingMode(["GoogleIllus", "BotIllus", "AccountErrorCustom"].contains(name) ? .alwaysOriginal : .alwaysTemplate) - } -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift -index d89c4c2..8d13e87 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/RNCNativeListView.swift -@@ -1,4 +1,5 @@ - import Foundation -+import CoreFoundation - import UIKit - import UniformTypeIdentifiers - -@@ -77,6 +78,8 @@ final class NativeListView: UIView { - target: self, - action: #selector(reorderLongPressChanged(_:)) - ) -+ // OneKey patch: claim only vertical drags so held rows and ancestor pagers stay responsive. -+ private lazy var listBodyGestureGuard = UIPanGestureRecognizer(target: nil, action: nil) - private var interactiveReorderSource: (key: String, index: Int)? - private weak var interactiveReorderCell: NativeListCell? - private var interactiveReorderCompactKey: String? -@@ -92,7 +95,10 @@ final class NativeListView: UIView { - private let actionAnchorInstanceID = UUID().uuidString - private var actionAnchorCounter = 0 - -- private static let sectionIndexGutter: CGFloat = 44 -+ private static let sectionIndexContentInset: CGFloat = 16 -+ private static let sectionIndexRailWidth: CGFloat = 32 -+ private static let sectionIndexPreviewSize: CGFloat = 48 -+ private static let sectionIndexPreviewEndMargin: CGFloat = 40 - - override init(frame: CGRect) { - super.init(frame: frame) -@@ -102,6 +108,11 @@ final class NativeListView: UIView { - collectionView.dragDelegate = self - collectionView.dropDelegate = self - collectionView.alwaysBounceVertical = true -+ // OneKey patch: keep list-body drags from being claimed by an ancestor modal sheet. -+ listBodyGestureGuard.cancelsTouchesInView = false -+ listBodyGestureGuard.isEnabled = false -+ listBodyGestureGuard.delegate = self -+ collectionView.addGestureRecognizer(listBodyGestureGuard) - reorderLongPress.minimumPressDuration = ReorderAnimation.longPressDuration - reorderLongPress.allowableMovement = ReorderAnimation.allowableMovement - reorderLongPress.delegate = self -@@ -136,11 +147,14 @@ final class NativeListView: UIView { - sectionIndexView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor), - sectionIndexView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), - sectionIndexView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), -- sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexGutter), -- sectionIndexPreview.centerXAnchor.constraint(equalTo: collectionView.centerXAnchor), -+ sectionIndexView.widthAnchor.constraint(equalToConstant: Self.sectionIndexRailWidth), -+ sectionIndexPreview.trailingAnchor.constraint( -+ equalTo: safeAreaLayoutGuide.trailingAnchor, -+ constant: -Self.sectionIndexPreviewEndMargin -+ ), - sectionIndexPreview.centerYAnchor.constraint(equalTo: collectionView.centerYAnchor), -- sectionIndexPreview.widthAnchor.constraint(equalToConstant: 72), -- sectionIndexPreview.heightAnchor.constraint(equalToConstant: 72), -+ sectionIndexPreview.widthAnchor.constraint(equalToConstant: Self.sectionIndexPreviewSize), -+ sectionIndexPreview.heightAnchor.constraint(equalToConstant: Self.sectionIndexPreviewSize), - ]) - - sectionIndexView.isHidden = true -@@ -152,11 +166,11 @@ final class NativeListView: UIView { - } - sectionIndexPreview.isHidden = true - sectionIndexPreview.alpha = 0 -- sectionIndexPreview.layer.cornerRadius = 16 -+ sectionIndexPreview.layer.cornerRadius = 14 - sectionIndexPreview.layer.masksToBounds = true - sectionIndexPreview.textAlignment = .center - sectionIndexPreview.adjustsFontForContentSizeCategory = true -- sectionIndexPreview.font = nativeListFont(ofSize: 28, weight: .semibold) -+ sectionIndexPreview.font = nativeListFont(ofSize: 22, weight: .semibold) - sectionIndexPreview.isAccessibilityElement = false - - footerCell.onAction = { [weak self] item, action, target, origin in -@@ -472,7 +486,8 @@ final class NativeListView: UIView { - - private func configureLayout(_ config: NativeListConfig) { - let isHorizontal = config.orientation == "horizontal" -- let indexGutter = sectionIndexEntries.isEmpty ? 0 : Self.sectionIndexGutter -+ // OneKey patch: the section index overlays rows and keeps only an accessory-safe inset. -+ let indexGutter = sectionIndexEntries.isEmpty ? 0 : Self.sectionIndexContentInset - let isRightToLeft = effectiveUserInterfaceLayoutDirection == .rightToLeft - flowLayout.scrollDirection = isHorizontal ? .horizontal : .vertical - flowLayout.minimumLineSpacing = config.itemSpacing -@@ -485,7 +500,7 @@ final class NativeListView: UIView { - ) - flowLayout.stickyItemIndexes = config.stickyHeaders - ? Set(config.items.enumerated().compactMap { -- $0.element.type == "sectionHeader" && $0.element.data.string("variant") != "summary" -+ $0.element.type == "sectionHeader" && $0.element.data.bool("sticky", default: true) && $0.element.data.string("variant") != "summary" - ? $0.offset - : nil - }) -@@ -493,6 +508,7 @@ final class NativeListView: UIView { - flowLayout.invalidateLayout() - collectionView.alwaysBounceHorizontal = isHorizontal - collectionView.alwaysBounceVertical = !isHorizontal -+ listBodyGestureGuard.isEnabled = !isHorizontal - collectionView.showsVerticalScrollIndicator = sectionIndexEntries.isEmpty - collectionView.dragInteractionEnabled = false - lastLayoutDirection = effectiveUserInterfaceLayoutDirection -@@ -512,11 +528,12 @@ final class NativeListView: UIView { - interactiveReorderCell = nil - return - } -- if item.type == "walletGroup", gesture.location(in: cell).y > 68 { -- interactiveReorderSource = nil -- interactiveReorderCell = nil -- return -- } -+ // OneKey patch: a hidden wallet child starts dragging its whole logical group. -+ // if item.type == "walletGroup", gesture.location(in: cell).y > 68 { -+ // interactiveReorderSource = nil -+ // interactiveReorderCell = nil -+ // return -+ // } - interactiveReorderSource = (item.key, indexPath.item) - interactiveReorderCell = cell - interactiveReorderUsesAtomicTargeting = item.type == "identity" && -@@ -838,7 +855,8 @@ final class NativeListView: UIView { - sectionIndexView.configure( - titles: sectionIndexEntries.map(\.title), - textColor: nativeListColor(config.theme, "secondaryText", "#646464"), -- activeColor: nativeListColor(config.theme, "accent", "#108303") -+ activeColor: nativeListColor(config.theme, "accent", "#108303"), -+ activeTextColor: nativeListColor(config.theme, "inverseText", "#FCFCFC") - ) - sectionIndexView.isHidden = sectionIndexEntries.isEmpty - sectionIndexPreview.backgroundColor = nativeListColor( -@@ -932,7 +950,7 @@ final class NativeListView: UIView { - theme: config.theme, - layout: config.layout, - itemIndex: itemIndex, -- selected: config.selectedKeys.contains(item.key), -+ selected: item.data.bool("selected") || config.selectedKeys.contains(item.key), - checkboxState: { [weak self] item, target, fallback in - self?.resolveCheckboxState(item: item, target: target, fallback: fallback) ?? fallback - } -@@ -943,7 +961,8 @@ final class NativeListView: UIView { - } - - private func handleRowPress(_ item: NativeListItem, origin: NativeListActionOrigin?) { -- guard let config, !item.data.bool("disabled") else { return } -+ // OneKey patch: missing-address rows keep accessory actions available. -+ guard let config, !item.data.bool("disabled"), !item.data.bool("pressDisabled") else { return } - if config.rowPressToggles && item.isSelectable && config.selectionMode != "none" { - updateSelection(target: NativeSelectionTarget(scope: "row", key: item.key), sourceKey: item.key) - return -@@ -1037,7 +1056,7 @@ final class NativeListView: UIView { - let cell = collectionView.cellForItem(at: indexPath) as? NativeListCell else { continue } - cell.updateSelection( - item: item, -- selected: config.selectedKeys.contains(item.key), -+ selected: item.data.bool("selected") || config.selectedKeys.contains(item.key), - checkboxState: checkboxState - ) - } -@@ -1080,19 +1099,73 @@ final class NativeListView: UIView { - return zip(current.items, next.items).allSatisfy { old, new in - guard old.key == new.key, old.type == new.type else { return false } - if old.content == new.content { return true } -- guard old.type == "sectionHeader", -- old.data.string("variant") == "summary", -- new.data.string("variant") == "summary" else { return false } -- var oldData = old.data -- var newData = new.data -- oldData.removeValue(forKey: "title") -- oldData.removeValue(forKey: "value") -- newData.removeValue(forKey: "title") -- newData.removeValue(forKey: "value") -+ // OneKey patch: controlled echoes can also update row and checkbox selection fields. -+ // guard old.type == "sectionHeader", -+ // old.data.string("variant") == "summary", -+ // new.data.string("variant") == "summary" else { return false } -+ // var oldData = old.data -+ // var newData = new.data -+ // oldData.removeValue(forKey: "title") -+ // oldData.removeValue(forKey: "value") -+ // newData.removeValue(forKey: "title") -+ // newData.removeValue(forKey: "value") -+ let controlled = next.selectionMode == "single" || next.selectionMode == "multiple" -+ guard let oldData = selectionComparisonData(old.data, controlled: controlled), -+ let newData = selectionComparisonData(new.data, controlled: controlled) else { return false } - return jsonData(oldData) == jsonData(newData) - } - } - -+ // OneKey patch: remove only fields that the existing lightweight binder refreshes. -+ private func selectionComparisonData(_ data: [String: Any], controlled: Bool) -> [String: Any]? { -+ guard let type = data["type"] as? String else { return nil } -+ var result = data -+ if let selected = result["selected"] { -+ guard CFGetTypeID(selected as CFTypeRef) == CFBooleanGetTypeID() else { return nil } -+ result.removeValue(forKey: "selected") -+ } -+ if type == "walletGroup" { -+ guard let parent = data["parent"] as? [String: Any], parent["type"] as? String == "identity", -+ let children = data["children"] as? [[String: Any]], -+ let normalizedParent = selectionComparisonData(parent, controlled: false) else { return nil } -+ var normalizedChildren: [[String: Any]] = [] -+ for child in children { -+ guard child["type"] as? String == "identity", -+ let normalized = selectionComparisonData(child, controlled: false) else { return nil } -+ normalizedChildren.append(normalized) -+ } -+ result["parent"] = normalizedParent -+ result["children"] = normalizedChildren -+ } -+ if type == "sectionHeader", data["variant"] as? String == "summary" { -+ result.removeValue(forKey: "title") -+ result.removeValue(forKey: "value") -+ } -+ guard controlled else { return result } -+ func checkboxData(_ value: Any) -> [String: Any]? { -+ guard var checkbox = value as? [String: Any], checkbox["kind"] as? String == "checkbox" else { return nil } -+ if let state = checkbox["state"] { -+ guard let state = state as? String, ["checked", "unchecked", "indeterminate"].contains(state) else { return nil } -+ } -+ checkbox.removeValue(forKey: "state") -+ return checkbox -+ } -+ if ["dataRow", "sectionHeader", "action"].contains(type), let checkbox = result["checkbox"] { -+ guard let normalized = checkboxData(checkbox) else { return nil } -+ result["checkbox"] = normalized -+ } -+ if type == "identity", let trailing = result["trailing"] { -+ guard var accessories = trailing as? [[String: Any]], accessories.count <= 2, -+ accessories.filter({ $0["kind"] as? String == "checkbox" }).count <= 1 else { return nil } -+ for index in accessories.indices where accessories[index]["kind"] as? String == "checkbox" { -+ guard let normalized = checkboxData(accessories[index]) else { return nil } -+ accessories[index] = normalized -+ } -+ result["trailing"] = accessories -+ } -+ return result -+ } -+ - private func dictionariesEqual(_ lhs: [String: Any]?, _ rhs: [String: Any]?) -> Bool { - switch (lhs, rhs) { - case (nil, nil): return true -@@ -1107,16 +1180,36 @@ final class NativeListView: UIView { - } - - private func rowHeight(_ item: NativeListItem) -> CGFloat { -+ // OneKey patch: honor selector baseline geometry; keep compact drag sizing. -+ if item.type != "walletGroup", item.data["height"] != nil { return CGFloat(item.data.double("height")) } - if item.type == "system", item.data.string("variant") == "spacer" { - return CGFloat(item.data.int("height")) - } -+ // OneKey patch: warning height follows the current native font and available width. -+ if item.type == "system", item.data.string("variant") == "warning" { -+ let textWidth = max(1, collectionView.bounds.width - (config?.contentPaddingHorizontal ?? 0) * 2 - 24) -+ func textHeight(_ key: String, weight: NativeListFontWeight) -> CGFloat { -+ let paragraph = NSMutableParagraphStyle() -+ paragraph.minimumLineHeight = 20 -+ paragraph.maximumLineHeight = 20 -+ return ceil((item.data.string(key) as NSString).boundingRect(with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), options: [.usesLineFragmentOrigin, .usesFontLeading], attributes: [.font: nativeListFont(ofSize: 14, weight: weight), .paragraphStyle: paragraph], context: nil).height / 20) * 20 -+ } -+ return 32 + textHeight("title", weight: .medium) + textHeight("message", weight: .regular) -+ } - if item.type == "walletGroup" { - if item.key == interactiveReorderCompactKey { return 68 } - let childCount = item.data.dictionaries("children").count -- return CGFloat((childCount + 1) * 68 + childCount * 12) -+ // OneKey patch: wallet badges contribute their own member heights. -+ // return CGFloat((childCount + 1) * 68 + childCount * 12) -+ let members = [item.data.dictionary("parent")].compactMap { $0 } + item.data.dictionaries("children") -+ return members.reduce(CGFloat(childCount * 12 + (members.first?["height"] != nil ? 2 : 0))) { total, data in -+ total + CGFloat(data.double("height", default: data.dictionaries("badges").isEmpty ? 68 : 92)) -+ } - } - if item.type == "identity", item.data.string("presentation") == "walletSidebar" { -- return 68 -+ // OneKey patch: default sidebar badge geometry is 24 points taller. -+ // return 68 -+ return item.data.dictionaries("badges").isEmpty ? 68 : 92 - } - if item.type == "identity", item.data.string("presentation") == "networkSelector" { - return 47 -@@ -1268,7 +1361,7 @@ final class NativeListView: UIView { - actionAnchorCounter &+= 1 - let generation = config?.generation ?? 0 - let token = "\(actionAnchorInstanceID):\(generation):\(actionAnchorCounter):\(origin.bindingEpoch)" -- let rect = sourceView.convert(sourceView.bounds, to: window) -+ let rect = sourceView.convert(sourceView.bounds, to: window).insetBy(dx: origin.anchorInset, dy: origin.anchorInset) - let record = ActionAnchorRecord(token: token, origin: origin) - actionAnchor = record - var anchor: [String: Any] = [ -@@ -1371,10 +1464,18 @@ final class NativeListView: UIView { - if let lastKey = config.items.last?.key { payload["lastKey"] = lastKey } - emit(onEndReached, payload) - } -+ -+ override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { -+ guard gestureRecognizer === listBodyGestureGuard, -+ let pan = gestureRecognizer as? UIPanGestureRecognizer else { return true } -+ let velocity = pan.velocity(in: collectionView) -+ return abs(velocity.y) > abs(velocity.x) -+ } - } - - extension NativeListView: UIGestureRecognizerDelegate { - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { -+ if gestureRecognizer === listBodyGestureGuard { return true } - var view = touch.view - while let current = view, current !== footerCell { - if current is UIControl { return false } -@@ -1387,7 +1488,26 @@ extension NativeListView: UIGestureRecognizerDelegate { - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { -- gestureRecognizer.view === footerCell || otherGestureRecognizer.view === footerCell -+ if gestureRecognizer === listBodyGestureGuard { -+ guard let otherView = otherGestureRecognizer.view else { return false } -+ return otherView === collectionView || otherView.isDescendant(of: collectionView) -+ } -+ if otherGestureRecognizer === listBodyGestureGuard { -+ guard let gestureView = gestureRecognizer.view else { return false } -+ return gestureView === collectionView || gestureView.isDescendant(of: collectionView) -+ } -+ return gestureRecognizer.view === footerCell || otherGestureRecognizer.view === footerCell -+ } -+ -+ func gestureRecognizer( -+ _ gestureRecognizer: UIGestureRecognizer, -+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer -+ ) -> Bool { -+ guard gestureRecognizer === listBodyGestureGuard, -+ otherGestureRecognizer is UIPanGestureRecognizer, -+ let otherView = otherGestureRecognizer.view, -+ otherView !== collectionView else { return false } -+ return collectionView.isDescendant(of: otherView) - } - } - -@@ -1604,13 +1724,16 @@ private struct NativeListSectionIndexEntry { - let position: Int - } - --private final class NativeListSectionIndexView: UIControl { -+// OneKey patch: arbitrate index scrubbing against ancestor dismissal gestures. -+// private final class NativeListSectionIndexView: UIControl { -+private final class NativeListSectionIndexView: UIControl, UIGestureRecognizerDelegate { - var onSelect: ((Int, Bool) -> Void)? - var onInteractionEnded: (() -> Void)? - private var titles: [String] = [] - private var labels: [UILabel] = [] - private var textColor: UIColor = .secondaryLabel - private var activeColor: UIColor = .tintColor -+ private var activeTextColor: UIColor = .white - private var lastTouchIndex: Int? - private(set) var activeIndex: Int? - -@@ -1620,16 +1743,43 @@ private final class NativeListSectionIndexView: UIControl { - accessibilityLabel = "Section index" - accessibilityTraits = [.adjustable] - isExclusiveTouch = true -+ -+ // UIControl tracking alone cannot prevent an ancestor sheet pan from taking the touch. -+ // Recognize immediately, but keep delivering touches to the existing tracking methods. -+ let scrubGesture = UILongPressGestureRecognizer(target: nil, action: nil) -+ scrubGesture.minimumPressDuration = 0 -+ scrubGesture.allowableMovement = .greatestFiniteMagnitude -+ scrubGesture.cancelsTouchesInView = false -+ scrubGesture.delaysTouchesEnded = false -+ scrubGesture.delegate = self -+ addGestureRecognizer(scrubGesture) -+ } -+ -+ func gestureRecognizer( -+ _ gestureRecognizer: UIGestureRecognizer, -+ shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer -+ ) -> Bool { -+ guard otherGestureRecognizer is UIPanGestureRecognizer, -+ let otherView = otherGestureRecognizer.view, -+ otherView !== self else { return false } -+ // The dependency applies only to touches starting in this index, including moves outside it. -+ return isDescendant(of: otherView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - -- func configure(titles: [String], textColor: UIColor, activeColor: UIColor) { -+ func configure( -+ titles: [String], -+ textColor: UIColor, -+ activeColor: UIColor, -+ activeTextColor: UIColor -+ ) { - self.titles = titles - self.textColor = textColor - self.activeColor = activeColor -+ self.activeTextColor = activeTextColor - labels.forEach { $0.removeFromSuperview() } - labels = titles.map { title in - let label = UILabel() -@@ -1660,9 +1810,9 @@ private final class NativeListSectionIndexView: UIControl { - let originY = (bounds.height - height * CGFloat(labels.count)) / 2 - for (index, label) in labels.enumerated() { - label.frame = CGRect( -- x: 0, -+ x: 6, - y: originY + CGFloat(index) * height, -- width: bounds.width, -+ width: 20, - height: height - ) - } -@@ -1718,7 +1868,10 @@ private final class NativeListSectionIndexView: UIControl { - private func updateLabelStyles() { - for (index, label) in labels.enumerated() { - let active = index == activeIndex -- label.textColor = active ? activeColor : textColor -+ label.textColor = active ? activeTextColor : textColor -+ label.backgroundColor = active ? activeColor : .clear -+ label.layer.cornerRadius = 8 -+ label.layer.masksToBounds = active - label.font = nativeListFont(ofSize: 10, weight: active ? .semibold : .medium) - } - } -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/icon.svg -new file mode 100644 -index 0000000..ba0aa29 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_account_error_custom.imageset/icon.svg -@@ -0,0 +1,14 @@ -+ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/icon.svg -new file mode 100644 -index 0000000..fe9dd70 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_all_networks_solid.imageset/icon.svg -@@ -0,0 +1,12 @@ -+ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/icon.svg -new file mode 100644 -index 0000000..64ec28d ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_apple_brand.imageset/icon.svg -@@ -0,0 +1,6 @@ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/icon.svg -new file mode 100644 -index 0000000..53b5043 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_bot_illus.imageset/icon.svg -@@ -0,0 +1,15 @@ -+ -+ -+ -+ -+ -+ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/icon.svg -new file mode 100644 -index 0000000..0c5feb4 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_crossed_small_solid.imageset/icon.svg -@@ -0,0 +1,3 @@ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/icon.svg -new file mode 100644 -index 0000000..d388f1e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_globus_outline.imageset/icon.svg -@@ -0,0 +1,7 @@ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/icon.svg -new file mode 100644 -index 0000000..ab862c4 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_google_illus.imageset/icon.svg -@@ -0,0 +1,18 @@ -+ -+ -+ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/Contents.json b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/Contents.json -new file mode 100644 -index 0000000..ae4d23e ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/Contents.json -@@ -0,0 +1,15 @@ -+{ -+ "images": [ -+ { -+ "filename": "icon.svg", -+ "idiom": "universal" -+ } -+ ], -+ "info": { -+ "author": "xcode", -+ "version": 1 -+ }, -+ "properties": { -+ "preserves-vector-representation": true -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/icon.svg b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/icon.svg -new file mode 100644 -index 0000000..893a7a3 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/ios/Resources/NativeListIcons.xcassets/onekey_selector_lock_solid.imageset/icon.svg -@@ -0,0 +1,7 @@ -+ -+ -+ -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js -index a172bff..82ca339 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.js -@@ -1,6 +1,8 @@ - "use strict"; - --import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; -+import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; -+import { OneKeyImageCache, OneKeyImageCachePolicy } from '@onekeyfe/react-native-image'; -+import { NativeAvatarPrefetchModel, NativeAvatarPrefetchQueue } from "./avatarPrefetch.js"; - import { callback, getHostComponent } from 'react-native-nitro-modules'; - import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, resolveLocationIndex, scrollFailure, validateOffset } from "./scrolling.js"; - import { serializePatches, serializeSnapshot } from "./validation.js"; -@@ -10,7 +12,7 @@ const NativeListHost = getHostComponent('NativeList', () => NativeListConfig); - function parsePayload(payloadJson) { - return JSON.parse(payloadJson); - } --export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ -+export const NativeList = forwardRef(function NativeList({ - snapshot, - webVirtualizationEnabled: _webVirtualizationEnabled, - onRowAction, -@@ -50,6 +52,46 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - }; - const snapshotRef = useRef(snapshot); - snapshotRef.current = snapshot; -+ // OneKey patch: range events already cross the bridge only when row indices -+ // change. Prefetch is optional work; scrolling and visible loads stay native. -+ const avatarQueueRef = useRef(null); -+ const avatarRangeRef = useRef(undefined); -+ // OneKey patch: a changed prop is a complete snapshot; unrelated renders must -+ // not erase image patches already dispatched through the imperative handle. -+ // const avatarPrefetchPaused = useRef(false); -+ const avatarModelRef = useRef(null); -+ const avatarPropSnapshotRef = useRef(snapshot); -+ const avatarLifecycle = useRef('pending'); -+ if (!avatarModelRef.current) avatarModelRef.current = new NativeAvatarPrefetchModel(snapshot.rows);else if (avatarPropSnapshotRef.current !== snapshot) avatarModelRef.current.replaceSnapshot(snapshot.rows); -+ avatarPropSnapshotRef.current = snapshot; -+ const updateAvatarPrefetch = () => { -+ const range = avatarRangeRef.current; -+ if (!range) return; -+ avatarQueueRef.current?.update(avatarModelRef.current?.window(range.first, range.last, range.direction) ?? []); -+ }; -+ const updateAvatarPrefetchRef = useRef(updateAvatarPrefetch); -+ updateAvatarPrefetchRef.current = updateAvatarPrefetch; -+ useEffect(() => { -+ avatarLifecycle.current = 'mounted'; -+ const queue = new NativeAvatarPrefetchQueue(source => OneKeyImageCache.preload([{ -+ uri: source.uri, -+ headers: source.headers, -+ resizeWidth: source.width, -+ resizeHeight: source.height, -+ optimizeTos: false, -+ cachePolicy: source.cachePolicy === 'memory' ? OneKeyImageCachePolicy.MEMORY : source.cachePolicy === 'disk' ? OneKeyImageCachePolicy.DISK : OneKeyImageCachePolicy.MEMORY_DISK -+ }])); -+ avatarQueueRef.current = queue; -+ updateAvatarPrefetchRef.current(); -+ return () => { -+ queue.dispose(); -+ avatarQueueRef.current = null; -+ avatarLifecycle.current = 'unmounted'; -+ }; -+ }, []); -+ useEffect(() => { -+ updateAvatarPrefetchRef.current(); -+ }, [snapshot]); - const initialScrollRef = useRef(initialScrollIndex === undefined && initialScrollKey === undefined ? undefined : initialScrollIndex !== undefined ? normalizeIndexScroll({ - index: initialScrollIndex, - animated: false, -@@ -90,10 +132,26 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - useImperativeHandle(forwardedRef, () => ({ - applySnapshot(nextSnapshot) { - snapshotRef.current = nextSnapshot; -- nativeRef.current?.applySnapshot(serializeSnapshot(nextSnapshot)); -+ const native = nativeRef.current; -+ if (native) { -+ native.applySnapshot(serializeSnapshot(nextSnapshot)); -+ if (avatarLifecycle.current !== 'unmounted') { -+ avatarModelRef.current?.replaceSnapshot(nextSnapshot.rows); -+ updateAvatarPrefetchRef.current(); -+ } -+ } - }, - applyPatches(patches) { -- if (patches.length > 0) nativeRef.current?.applyPatches(serializePatches(patches)); -+ // OneKey patch: image updates replace pending prefetch instead of disabling it. -+ // if (imageFieldsChanged) { avatarPrefetchPaused.current = true; avatarQueueRef.current?.update([]); } -+ if (!patches.length) return; -+ const native = nativeRef.current; -+ const dispatch = native ? () => native.applyPatches(serializePatches(patches)) : undefined; -+ if (avatarLifecycle.current === 'unmounted') { -+ dispatch?.(); -+ return; -+ } -+ if (avatarModelRef.current?.applyPatches(patches, dispatch)) updateAvatarPrefetchRef.current(); - }, - reconcileSelection(selectedKeys) { - nativeRef.current?.reconcileSelection(JSON.stringify(selectedKeys)); -@@ -171,7 +229,15 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - callbacksRef.current.onEndReached?.(parsePayload(payloadJson)); - }), - onVisibleRangeChanged: callback(payloadJson => { -- callbacksRef.current.onVisibleRangeChanged?.(parsePayload(payloadJson)); -+ const payload = parsePayload(payloadJson); -+ const previous = avatarRangeRef.current; -+ avatarRangeRef.current = { -+ first: payload.firstIndex, -+ last: payload.lastIndex, -+ direction: previous && payload.firstIndex !== previous.first ? Math.sign(payload.firstIndex - previous.first) : previous?.direction ?? 1 -+ }; -+ updateAvatarPrefetchRef.current(); -+ callbacksRef.current.onVisibleRangeChanged?.(payload); - }) - }), []); - return /*#__PURE__*/_jsx(NativeListHost, { -@@ -180,4 +246,3 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - snapshotJson: snapshotJson - }); - }); --//# sourceMappingURL=NativeList.js.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js -index ce0b82d..438d88b 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/NativeList.web.js -@@ -6,7 +6,7 @@ import { normalizeIndexScroll, normalizeKeyScroll, normalizePositionScroll, reso - import { serializePatches, validateSnapshot } from "./validation.js"; - import { NativeListWebEngine } from "./web/NativeListWebEngine.js"; - import { jsx as _jsx } from "react/jsx-runtime"; --export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ -+export const NativeList = forwardRef(function NativeList({ - snapshot, - webVirtualizationEnabled = true, - onRowAction, -@@ -29,7 +29,14 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - webVirtualizationEnabledRef.current = webVirtualizationEnabled; - const validatedSnapshot = useMemo(() => validateSnapshot(snapshot), [snapshot]); - const snapshotRef = useRef(validatedSnapshot); -- snapshotRef.current = validatedSnapshot; -+ const snapshotPropRef = useRef(validatedSnapshot); -+ // An imperative snapshot survives host mounting until the snapshot prop changes. -+ if (snapshotPropRef.current !== validatedSnapshot) { -+ snapshotPropRef.current = validatedSnapshot; -+ snapshotRef.current = validatedSnapshot; -+ } -+ // OneKey patch: React refs can be ready before the DOM engine is mounted. -+ const pendingPatchesRef = useRef(undefined); - const appliedSnapshotRef = useRef(undefined); - const callbacksRef = useRef({}); - callbacksRef.current = { -@@ -61,6 +68,9 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - const engine = new NativeListWebEngine(host, snapshotRef.current, callbacksRef.current, webVirtualizationEnabledRef.current); - engineRef.current = engine; - appliedSnapshotRef.current = snapshotRef.current; -+ const pending = pendingPatchesRef.current; -+ pendingPatchesRef.current = undefined; -+ if (pending?.snapshot === snapshotRef.current) pending.batches.forEach(patches => engine.applyPatches(patches)); - const initial = initialScrollRef.current; - if (initial && !didApplyInitialScroll.current) { - didApplyInitialScroll.current = true; -@@ -89,6 +99,7 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - useImperativeHandle(forwardedRef, () => ({ - applySnapshot(nextSnapshot) { - const next = validateSnapshot(nextSnapshot); -+ pendingPatchesRef.current = undefined; - snapshotRef.current = next; - appliedSnapshotRef.current = next; - engineRef.current?.applySnapshot(next); -@@ -96,7 +107,14 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - applyPatches(patches) { - if (patches.length > 0) { - serializePatches(patches); -- engineRef.current?.applyPatches(patches); -+ // engineRef.current?.applyPatches(patches); -+ if (engineRef.current) engineRef.current.applyPatches(patches);else { -+ if (pendingPatchesRef.current?.snapshot !== snapshotRef.current) pendingPatchesRef.current = { -+ snapshot: snapshotRef.current, -+ batches: [] -+ }; -+ pendingPatchesRef.current.batches.push(patches); -+ } - } - }, - reconcileSelection(selectedKeys) { -@@ -164,4 +182,3 @@ export const NativeList = /*#__PURE__*/forwardRef(function NativeList({ - ref: setHostRef - }); - }); --//# sourceMappingURL=NativeList.web.js.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js -new file mode 100644 -index 0000000..6501c8c ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/avatarPrefetch.js -@@ -0,0 +1,171 @@ -+"use strict"; -+ -+// OneKey patch: share a bounded URI-only window across renderers. No bitmap or -+// account-specific data enters list snapshots or the UI runtime. -+ -+const PREFIX = 'onekey-avatar://blockie/v1/'; -+const MAX_CANDIDATES = 64; -+const IMAGE_PATCH_FIELDS = ['leading', 'secondaryLeading', 'image', 'networkImage', 'thumbnail', 'visual']; -+export function avatarPrefetchWindow(rows, first, last, direction, resolveRow) { -+ if (first < 0 || last < first || first >= rows.length) return []; -+ last = Math.min(last, rows.length - 1); -+ const result = []; -+ const seen = new Set(); -+ let limit = MAX_CANDIDATES; -+ const add = (source, priority) => { -+ if (!source?.uri.startsWith(PREFIX) || source.cachePolicy === 'none' || seen.has(source.uri) || result.length >= limit) return; -+ seen.add(source.uri); -+ result.push({ -+ source, -+ priority -+ }); -+ }; -+ const visual = (value, priority) => { -+ if (!value) return; -+ if ('image' in value) add(value.image, priority); -+ if ('networkImage' in value) add(value.networkImage, priority); -+ if ('images' in value) value.images.forEach(image => add(image, priority)); -+ if ('overlays' in value) value.overlays?.forEach(overlay => add(overlay.image, priority)); -+ }; -+ const row = (value, priority) => { -+ value = resolveRow?.(value) ?? value; -+ if ('leading' in value) visual(value.leading, priority); -+ if ('secondaryLeading' in value) visual(value.secondaryLeading, priority); -+ if ('image' in value) add(value.image, priority); -+ if ('networkImage' in value) add(value.networkImage, priority); -+ if ('thumbnail' in value) add(value.thumbnail, priority); -+ if (value.type === 'walletGroup') { -+ visual(value.parent.leading, priority); -+ for (const child of value.children.slice(0, MAX_CANDIDATES)) { -+ if (result.length >= limit) break; -+ visual(child.leading, priority); -+ } -+ } -+ }; -+ // Bound row examination too: a giant non-avatar group must not scan all data. -+ for (let index = first; index <= last && index < first + 64; index += 1) row(rows[index], 0); -+ const span = Math.min(24, (last - first + 1) * 2); -+ const step = direction < 0 ? -1 : 1; -+ const aheadStart = step > 0 ? last + 1 : first - 1; -+ const aheadLimit = Math.min(MAX_CANDIDATES, result.length + 24); -+ limit = aheadLimit; -+ for (let distance = 0; distance < span && result.length < aheadLimit; distance += 1) { -+ const index = aheadStart + distance * step; -+ if (index < 0 || index >= rows.length) break; -+ row(rows[index], 1); -+ } -+ const behindStart = step > 0 ? first - 1 : last + 1; -+ const behindLimit = Math.min(MAX_CANDIDATES, result.length + 8); -+ limit = behindLimit; -+ for (let distance = 0; distance < Math.min(8, span) && result.length < behindLimit; distance += 1) { -+ const index = behindStart - distance * step; -+ if (index < 0 || index >= rows.length) break; -+ row(rows[index], 2); -+ } -+ return result; -+} -+ -+// OneKey patch: imperative image patches update a sparse prefetch overlay, not -+// the readonly prop or a full cloned snapshot. The key index is rebuilt only for -+// a complete snapshot; ordinary balance patches retain no extra row copies. -+export class NativeAvatarPrefetchModel { -+ imageRows = new Map(); -+ constructor(rows) { -+ this.rows = rows; -+ this.rowByKey = new Map(rows.map(row => [row.key, row])); -+ } -+ replaceSnapshot(rows) { -+ this.rows = rows; -+ this.rowByKey = new Map(rows.map(row => [row.key, row])); -+ this.imageRows.clear(); -+ } -+ applyPatches(patches, dispatch) { -+ // Native methods have no acknowledgement. Mirror only dispatched batches; -+ // a missing host or a synchronous serialization/dispatch failure changes nothing. -+ if (!dispatch) return false; -+ dispatch(); -+ const seen = new Set(); -+ for (const patch of patches) { -+ const row = this.rowByKey.get(patch.key); -+ // Both native renderers reject the whole batch for an unknown key/type. -+ if (!row || row.type !== patch.type || seen.has(patch.key)) return false; -+ seen.add(patch.key); -+ } -+ let changed = false; -+ for (const patch of patches) { -+ const changes = patch.changes; -+ let imageChanges; -+ for (const key of IMAGE_PATCH_FIELDS) { -+ if (changes[key] !== undefined) { -+ imageChanges ??= {}; -+ imageChanges[key] = changes[key]; -+ } -+ } -+ // JSON.stringify omits undefined top-level fields. Native therefore keeps -+ // them unchanged; an explicit replacement visual without image clears it. -+ if (!imageChanges) continue; -+ const row = this.imageRows.get(patch.key) ?? this.rowByKey.get(patch.key); -+ // Preserve the validated row discriminant, matching applyRowPatches' shallow merge. -+ this.imageRows.set(patch.key, { -+ ...row, -+ ...imageChanges, -+ key: row.key, -+ type: row.type -+ }); -+ changed = true; -+ } -+ return changed; -+ } -+ window(first, last, direction) { -+ return avatarPrefetchWindow(this.rows, first, last, direction, row => this.imageRows.get(row.key) ?? row); -+ } -+} -+ -+// Native preload has no cancellation token. One in-flight source is the bound; -+// direction changes replace all not-yet-started work rather than appending it. -+export class NativeAvatarPrefetchQueue { -+ pending = []; -+ disposed = false; -+ recent = new Map(); -+ constructor(preload) { -+ this.preload = preload; -+ } -+ update(candidates) { -+ if (this.disposed) return; -+ const now = Date.now(); -+ this.pending = candidates.filter(({ -+ source, -+ priority -+ }) => priority !== 0 && source.uri !== this.activeUri && now - (this.recent.get(source.uri) ?? 0) > 30_000).map(({ -+ source -+ }) => source); -+ this.schedule(); -+ } -+ dispose() { -+ this.disposed = true; -+ this.pending = []; -+ this.recent.clear(); -+ if (this.timer !== undefined) clearTimeout(this.timer); -+ this.timer = undefined; -+ } -+ schedule() { -+ if (this.disposed || this.activeUri || this.timer !== undefined || !this.pending.length) return; -+ // Yield between short batches without imposing a frame-per-image throughput cap. -+ this.timer = setTimeout(() => { -+ this.timer = undefined; -+ const source = this.pending.shift(); -+ if (!source || this.disposed) return; -+ this.activeUri = source.uri; -+ Promise.resolve().then(() => this.preload(source)).then(success => { -+ if (success && !this.disposed) { -+ this.recent.delete(source.uri); -+ this.recent.set(source.uri, Date.now()); -+ if (this.recent.size > 128) this.recent.delete(this.recent.keys().next().value); -+ } -+ }).catch(() => {/* Visible loading retains its own failure/retry behavior. */}).finally(() => { -+ this.activeUri = undefined; -+ this.schedule(); -+ }); -+ }, 0); -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js -index ef38e33..eabec38 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/models.js -@@ -1,2 +1,3 @@ - "use strict"; --//# sourceMappingURL=models.js.map -\ No newline at end of file -+ -+export {}; -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js -index f9ed44b..eae597c 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/validation.js -@@ -124,6 +124,26 @@ function assertLeadingVisual(visual, path) { - assertImage(visual.image, `${path}.image`); - assertVisualShape(visual.shape, `${path}.shape`); - assertText(visual.cornerIcon?.name, `${path}.cornerIcon.name`); -+ // OneKey patch: validate optional selector overlays at the JSON boundary. -+ assertText(visual.fallbackIcon?.name, `${path}.fallbackIcon.name`); -+ if ((visual.overlays?.length ?? 0) > 2) fail(`${path}.overlays`, 'supports at most two overlays'); -+ visual.overlays?.forEach((overlay, index) => { -+ if (!['topLeft', 'bottomRight'].includes(overlay.position)) fail(`${path}.overlays[${index}].position`, 'must be topLeft or bottomRight'); -+ if (overlay.size !== undefined && (overlay.size <= 0 || overlay.size > 40)) fail(`${path}.overlays[${index}].size`, 'must be within 1...40'); -+ if (overlay.padding !== undefined && (!Number.isFinite(overlay.padding) || overlay.padding < 0 || overlay.padding * 2 >= (overlay.size ?? 20))) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); -+ if (overlay.offset !== undefined && (!Number.isFinite(overlay.offset) || overlay.offset < 0 || overlay.offset > 20)) fail(`${path}.overlays[${index}].offset`, 'must be within 0...20'); -+ for (const field of ['width', 'height']) { -+ const value = overlay[field]; -+ if (value !== undefined && (!Number.isFinite(value) || value <= 0 || value > 40)) fail(`${path}.overlays[${index}].${field}`, 'must be within 1...40'); -+ } -+ for (const field of ['offsetX', 'offsetY']) { -+ const value = overlay[field]; -+ if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 20)) fail(`${path}.overlays[${index}].${field}`, 'must be within 0...20'); -+ } -+ if (overlay.padding !== undefined && overlay.padding * 2 >= Math.min(overlay.width ?? overlay.size ?? 20, overlay.height ?? overlay.size ?? 20)) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); -+ assertImage(overlay.image, `${path}.overlays[${index}].image`); -+ assertText(overlay.text, `${path}.overlays[${index}].text`); -+ }); - if (visual.kind === 'token') { - assertImage(visual.networkImage, `${path}.networkImage`); - } -@@ -169,6 +189,10 @@ function assertWalletGroup(row, path) { - } - function assertRow(row, index, path = `rows[${index}]`) { - assertKey(row.key, `${path}.key`); -+ // OneKey patch: explicit dimensions and opacity cannot corrupt list layout. -+ if (row.height !== undefined && (row.height < 0 || row.height > 4096)) fail(`${path}.height`, 'must be within 0...4096'); -+ if (row.heightRounding !== undefined && (row.height === undefined || !['floor', 'nearest'].includes(row.heightRounding))) fail(`${path}.heightRounding`, 'requires an explicit height and must be floor or nearest'); -+ if (row.opacity !== undefined && (row.opacity < 0 || row.opacity > 1)) fail(`${path}.opacity`, 'must be within 0...1'); - if (row.groupId && !row.groupPosition) { - fail(`${path}.groupPosition`, 'is required when groupId is present'); - } -@@ -186,6 +210,16 @@ function assertRow(row, index, path = `rows[${index}]`) { - } - assertText(row.title, `${path}.title`); - assertText(row.subtitle, `${path}.subtitle`); -+ // OneKey patch: selector text segments truncate independently. -+ row.subtitleSegments?.forEach((segment, segmentIndex) => { -+ assertText(segment.text, `${path}.subtitleSegments[${segmentIndex}].text`); -+ if (segment.tone !== undefined && !['primary', 'secondary', 'disabled', 'caution', 'positive', 'negative'].includes(segment.tone)) fail(`${path}.subtitleSegments[${segmentIndex}].tone`, 'invalid selector text tone'); -+ }); -+ let previousMatchEnd = 0; -+ row.titleMatch?.forEach(match => { -+ if (!Number.isInteger(match.start) || !Number.isInteger(match.end) || match.start < previousMatchEnd || match.end <= match.start || match.end > row.title.length) fail(`${path}.titleMatch`, 'must contain ordered, non-overlapping UTF-16 ranges inside title'); -+ previousMatchEnd = match.end; -+ }); - assertText(row.tertiary, `${path}.tertiary`); - if (row.tertiaryTone !== undefined && !['secondary', 'info'].includes(row.tertiaryTone)) { - fail(`${path}.tertiaryTone`, 'must be secondary or info'); -@@ -293,9 +327,12 @@ function assertRow(row, index, path = `rows[${index}]`) { - assertTrailingAccessories(row.trailing, `${path}.trailing`); - break; - case 'system': -- if (!['loading', 'retry', 'noMatch', 'end', 'spacer'].includes(row.variant)) { -- fail(`${path}.variant`, 'must be loading, retry, noMatch, end, or spacer'); -+ if ( -+ // OneKey patch: deprecated-wallet warnings retain the original scrolling semantics. -+ !['loading', 'retry', 'noMatch', 'end', 'spacer', 'warning'].includes(row.variant)) { -+ fail(`${path}.variant`, 'must be loading, retry, noMatch, end, spacer, or warning'); - } -+ if (row.variant === 'warning') assertText(row.title, `${path}.title`); - if (row.variant !== 'spacer') { - assertText(row.message, `${path}.message`); - } -@@ -397,6 +434,12 @@ export function validateSnapshot(snapshot) { - } - function assertPatchChanges(patch, index) { - const path = `patches[${index}].changes`; -+ // OneKey patch: partial balance updates retain a valid, current accessibility label. -+ if ('accessibilityLabel' in patch.changes) { -+ assertText(patch.changes.accessibilityLabel, `${path}.accessibilityLabel`); -+ } -+ // OneKey patch: partial updates may refer to an existing height but still require a valid policy. -+ if ('heightRounding' in patch.changes && patch.changes.heightRounding !== undefined && !['floor', 'nearest'].includes(patch.changes.heightRounding)) fail(`${path}.heightRounding`, 'must be floor or nearest'); - if (patch.changes.revision !== undefined && (!Number.isSafeInteger(patch.changes.revision) || patch.changes.revision < 0)) { - fail(`${path}.revision`, 'must be a non-negative safe integer'); - } -@@ -559,4 +602,3 @@ export function serializeSnapshot(snapshot) { - export function serializePatches(patches) { - return JSON.stringify(validatePatches(patches)); - } --//# sourceMappingURL=validation.js.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js -new file mode 100644 -index 0000000..5c27e23 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListAvatarWorker.js -@@ -0,0 +1,443 @@ -+// OneKey patch: avatar generation and PNG bytes stay in this external worker. -+// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64 -+// Permission is hereby granted, free of charge, to any person obtaining a copy -+// of this software and associated documentation files (the "Software"), to deal -+// in the Software without restriction, including without limitation the rights -+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -+// copies of the Software, and to permit persons to whom the Software is -+// furnished to do so, subject to the following conditions: -+// The above copyright notice and this permission notice shall be included in -+// all copies or substantial portions of the Software. -+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -+// THE SOFTWARE. -+ -+const PREFIX = 'onekey-avatar://blockie/v1/'; -+const DATABASE = 'onekey-native-list-avatar-v1'; -+const MAX_DISK_BYTES = 32 * 1024 * 1024; -+const MAX_DISK_ENTRIES = 2048; -+const MAX_CONCURRENT_LOADS = 2; -+const requests = new Map(); -+const images = new Map(); -+const jobs = new Map(); -+const queue = []; -+let activeLoads = 0; -+let databasePromise; -+// OneKey patch: persistence never occupies either display-load slot. Pending bytes -+// bridge the last-lease/reacquire window until a bounded background write finishes. -+const MAX_PENDING_WRITES = 32; -+const MAX_PENDING_BYTES = 4 * 1024 * 1024; -+const WRITE_DEADLINE_MS = 32; -+const READ_BUDGET_MS = 8; -+const MAX_PENDING_READS = 2; -+const DISK_IDLE_MS = 100; -+const pendingBlobs = new Map(); -+const diskQueue = []; -+const touches = new Map(); -+const priorities = new Map(); -+let pendingBytes = 0; -+let writing = false; -+let diskTimer; -+let lastAcquire = 0; -+let pendingReads = 0; -+ -+function diskIsIdle() { -+ return !activeLoads && !queue.length && !pendingReads && Date.now() - lastAcquire >= DISK_IDLE_MS; -+} -+ -+function scheduleDiskWork() { -+ if (writing || diskTimer !== undefined || activeLoads || queue.length || pendingReads || (!diskQueue.length && !touches.size)) return; -+ const delay = Math.max(0, DISK_IDLE_MS - (Date.now() - lastAcquire)); -+ diskTimer = setTimeout(() => { diskTimer = undefined; void drainDiskQueue(); }, delay); -+} -+ -+function persistLater(uri, blob) { -+ if (pendingBlobs.has(uri) || blob.size > MAX_PENDING_BYTES) return; -+ // OneKey patch: a long scroll retains the most recent bounded write window, -+ // rather than filling it once and dropping every later result. Never evict I/O in flight. -+ // if (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) return; -+ while (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) { -+ const oldest = diskQueue.shift(); -+ if (!oldest) return; -+ if (pendingBlobs.get(oldest.uri) === oldest.blob) pendingBlobs.delete(oldest.uri); -+ pendingBytes -= oldest.blob.size; -+ } -+ pendingBlobs.set(uri, blob); -+ pendingBytes += blob.size; -+ diskQueue.push({ uri, blob }); -+ scheduleDiskWork(); -+} -+ -+async function drainDiskQueue() { -+ // OneKey patch: a momentary empty load queue is not a scroll-idle window. -+ // if (writing || activeLoads || queue.length) return; -+ if (writing) return; -+ if (!diskIsIdle()) { scheduleDiskWork(); return; } -+ writing = true; -+ try { -+ if (diskQueue.length) { -+ const { uri, blob } = diskQueue.shift(); -+ let result; -+ try { result = await writeDisk(uri, blob); } catch { /* Persistence remains best-effort. */ } -+ if (result === 'deferred') { -+ diskQueue.unshift({ uri, blob }); -+ } else { -+ if (pendingBlobs.get(uri) === blob) pendingBlobs.delete(uri); -+ pendingBytes -= blob.size; -+ const image = images.get(uri); -+ if (result === true && image?.blob === blob) image.persisted = true; -+ } -+ } else if (touches.size) { -+ await flushTouches(); -+ } -+ } finally { -+ writing = false; -+ scheduleDiskWork(); -+ } -+} -+ -+// OneKey patch: abort is a request, not a bounded browser store-lock release. -+// Display reads have their own budget below, even if this transaction is committing. -+function cacheWriteDeadline(transaction, resolve) { -+ let timer; -+ const check = () => { -+ if (!activeLoads && !queue.length && Date.now() - lastAcquire >= DISK_IDLE_MS) { timer = setTimeout(check, WRITE_DEADLINE_MS); return; } -+ try { transaction.abort(); } catch { /* The browser already started committing. */ } -+ }; -+ timer = setTimeout(check, WRITE_DEADLINE_MS); -+ const finish = (success) => { clearTimeout(timer); resolve(success); }; -+ transaction.oncomplete = () => finish(true); -+ transaction.onabort = () => finish(false); -+} -+ -+function touchLater(uri) { -+ touches.delete(uri); -+ touches.set(uri, Date.now()); -+ if (touches.size > 128) touches.delete(touches.keys().next().value); -+ // OneKey patch: touches share the same idle gate and writer as persistence. -+ // if (touchTimer === undefined) touchTimer = setTimeout(flushTouches, 250); -+ scheduleDiskWork(); -+} -+ -+async function flushTouches() { -+ const database = await openDatabase(); -+ // Opening the database can yield across a new acquire; recheck before starting I/O. -+ if (!diskIsIdle()) return; -+ const batch = [...touches]; -+ touches.clear(); -+ if (database && batch.length) await new Promise((resolve) => { -+ try { -+ const transaction = database.transaction('images', 'readwrite'); -+ cacheWriteDeadline(transaction, resolve); -+ const store = transaction.objectStore('images'); -+ batch.forEach(([uri, accessed]) => { -+ const request = store.get(uri); -+ request.onsuccess = () => { -+ if (request.result) store.put({ ...request.result, accessed: Math.max(request.result.accessed || 0, accessed) }); -+ }; -+ }); -+ } catch { resolve(false); } -+ }); -+} -+ -+function jobPriority(job) { -+ let priority = 2; -+ job.ids.forEach((id) => { priority = Math.min(priority, priorities.get(id) ?? 2); }); -+ return priority; -+} -+ -+function openDatabase() { -+ if (!databasePromise) { -+ databasePromise = new Promise((resolve, reject) => { -+ const request = indexedDB.open(DATABASE, 1); -+ request.onupgradeneeded = () => { -+ const store = request.result.createObjectStore('images', { keyPath: 'uri' }); -+ store.createIndex('accessed', 'accessed'); -+ request.result.createObjectStore('metadata'); -+ }; -+ let blocked = false; -+ request.onsuccess = () => { -+ const database = request.result; -+ if (blocked) { database.close(); return; } -+ database.onversionchange = () => { database.close(); databasePromise = undefined; }; -+ resolve(database); -+ }; -+ request.onerror = () => reject(request.error); -+ request.onblocked = () => { -+ blocked = true; -+ reject(new Error('Avatar cache database is blocked')); -+ }; -+ }).catch(() => undefined); -+ } -+ return databasePromise; -+} -+ -+async function readDisk(uri) { -+ // OneKey patch: the cache is optional. A slow read must not retain a display -+ // load slot while abort/commit waits on browser I/O. Keep unfinished reads -+ // separately bounded, including an unresolved shared database open. -+ if (pendingReads >= MAX_PENDING_READS) return undefined; -+ pendingReads += 1; -+ return new Promise((resolve) => { -+ let transaction; -+ let blob; -+ let settled = false; -+ let finished = false; -+ let abortRequested = false; -+ const finish = (success) => { -+ if (finished) return; -+ finished = true; -+ pendingReads -= 1; -+ clearTimeout(timer); -+ if (!settled) { -+ settled = true; -+ if (success && blob) touchLater(uri); -+ resolve(success ? blob : undefined); -+ } -+ scheduleDiskWork(); -+ }; -+ const timeout = () => { -+ if (finished) return; -+ if (!settled) { settled = true; resolve(undefined); } -+ // Do not release pendingReads here: an aborted IDB transaction can still -+ // hold its store lock for hundreds of milliseconds before onabort arrives. -+ if (transaction && !abortRequested) { -+ abortRequested = true; -+ try { transaction.abort(); } catch { /* Already committing; ignore late callbacks. */ } -+ } -+ }; -+ const timer = setTimeout(timeout, READ_BUDGET_MS); -+ Promise.resolve().then(openDatabase).then((database) => { -+ if (settled || !database) { finish(false); return; } -+ try { -+ transaction = database.transaction('images', 'readonly'); -+ transaction.oncomplete = () => finish(true); -+ transaction.onabort = () => finish(false); -+ transaction.onerror = timeout; -+ const request = transaction.objectStore('images').get(uri); -+ request.onsuccess = () => { -+ if (settled) return; -+ const record = request.result; -+ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) blob = record.blob; -+ // A completed readonly get can serve display before the transaction ends. -+ // Its physical permit still belongs to oncomplete/onabort. -+ settled = true; -+ if (blob) touchLater(uri); -+ resolve(blob); -+ }; -+ } catch { if (transaction) timeout(); else finish(false); } -+ }).catch(() => finish(false)); -+ }); -+} -+ -+async function writeDisk(uri, blob) { -+ const database = await openDatabase(); -+ if (!database || blob.size > MAX_DISK_BYTES) return false; -+ if (!diskIsIdle()) return 'deferred'; -+ return new Promise((resolve) => { -+ try { -+ const transaction = database.transaction(['images', 'metadata'], 'readwrite'); -+ cacheWriteDeadline(transaction, resolve); -+ const store = transaction.objectStore('images'); -+ const metadata = transaction.objectStore('metadata'); -+ const previous = store.get(uri); -+ previous.onsuccess = () => { -+ const counter = metadata.get('size'); -+ counter.onsuccess = () => { -+ const size = counter.result || { bytes: 0, count: 0 }; -+ size.bytes += blob.size - (previous.result?.blob?.size || 0); -+ size.count += previous.result ? 0 : 1; -+ store.put({ uri, blob, accessed: Date.now() }); -+ const save = () => metadata.put(size, 'size'); -+ if (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES) { save(); return; } -+ const cursor = store.index('accessed').openCursor(); -+ cursor.onsuccess = () => { -+ const item = cursor.result; -+ if (!item || (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES)) { save(); return; } -+ if (item.value.uri !== uri) { -+ size.bytes -= item.value.blob.size; -+ size.count -= 1; -+ item.delete(); -+ } -+ item.continue(); -+ }; -+ }; -+ }; -+ // OneKey patch: completion/abort clears the deadline before releasing pending bytes. -+ // transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); -+ } catch { resolve(); } -+ }); -+} -+ -+// PRNG and HSL conversion adapted from ethereum-blockies-base64 1.0.2 (MIT), MyCrypto. -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/main.js -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/hsl2rgb.js -+// Preserve signed shifts, color order, and RGB rounding to match V1's decoded pixels. -+async function generateBlob(seed) { -+ const state = [0, 0, 0, 0]; -+ for (let i = 0; i < seed.length; i += 1) { -+ state[i % 4] = (state[i % 4] << 5) - state[i % 4] + seed.charCodeAt(i); -+ } -+ const rand = () => { -+ const t = state[0] ^ (state[0] << 11); -+ state[0] = state[1]; -+ state[1] = state[2]; -+ state[2] = state[3]; -+ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8); -+ return (state[3] >>> 0) / ((1 << 31) >>> 0); -+ }; -+ const hue = (p, q, value) => { -+ let t = value; -+ if (t < 0) t += 1; -+ if (t > 1) t -= 1; -+ if (t < 1 / 6) return p + (q - p) * 6 * t; -+ if (t < 1 / 2) return q; -+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; -+ return p; -+ }; -+ const color = () => { -+ const h = Math.floor(rand() * 360) / 360; -+ const s = (rand() * 60 + 40) / 100; -+ const l = ((rand() + rand() + rand() + rand()) * 25) / 100; -+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s; -+ const p = 2 * l - q; -+ const rgb = s === 0 ? [l, l, l] : [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)]; -+ return `rgb(${rgb.map((value) => Math.round(value * 255)).join(',')})`; -+ }; -+ const foreground = color(); -+ const background = color(); -+ const spot = color(); -+ const canvas = new OffscreenCanvas(128, 128); -+ const context = canvas.getContext('2d'); -+ if (!context) throw new Error('Avatar canvas is unavailable'); -+ context.fillStyle = background; -+ context.fillRect(0, 0, 128, 128); -+ for (let row = 0; row < 8; row += 1) { -+ for (let column = 0; column < 4; column += 1) { -+ const value = Math.floor(rand() * 2.3); -+ if (value === 0) continue; -+ context.fillStyle = value === 1 ? foreground : spot; -+ context.fillRect(column * 16, row * 16, 16, 16); -+ context.fillRect((7 - column) * 16, row * 16, 16, 16); -+ } -+ } -+ return canvas.convertToBlob({ type: 'image/png' }); -+} -+ -+function release(id) { -+ const uri = requests.get(id); -+ requests.delete(id); -+ priorities.delete(id); -+ const image = images.get(uri); -+ image?.references.delete(id); -+ if (image && image.references.size === 0) { -+ URL.revokeObjectURL(image.url); -+ images.delete(uri); -+ } -+ const job = jobs.get(uri); -+ job?.ids.delete(id); -+ if (job && job.ids.size === 0 && !job.active) { -+ jobs.delete(uri); -+ const index = queue.indexOf(job); -+ if (index !== -1) queue.splice(index, 1); -+ } -+} -+ -+async function runJob(job) { -+ // OneKey patch: reuse generated bytes even if the last UI lease was released -+ // while persistence is still in flight; these bytes already passed generation. -+ const pending = pendingBlobs.get(job.uri); -+ let blob = pending ?? await readDisk(job.uri); -+ let persisted = !!blob && !pending; -+ if (blob && !pending) { -+ try { -+ const bitmap = await createImageBitmap(blob); -+ const valid = bitmap.width === 128 && bitmap.height === 128; -+ bitmap.close(); -+ if (!valid) blob = undefined; -+ } catch { blob = undefined; } -+ } -+ if (!blob && job.ids.size) { -+ persisted = false; -+ blob = await generateBlob(job.seed); -+ // OneKey patch: notify clients and free the load slot without awaiting I/O. -+ // await writeDisk(job.uri, blob); -+ persistLater(job.uri, blob); -+ } -+ if (!blob || !job.ids.size) return; -+ const image = { url: URL.createObjectURL(blob), blob, persisted, references: new Set() }; -+ images.set(job.uri, image); -+ job.ids.forEach((id) => { -+ if (requests.get(id) !== job.uri) return; -+ image.references.add(id); -+ postMessage({ type: 'resolved', id, url: image.url }); -+ }); -+ if (!image.references.size) { URL.revokeObjectURL(image.url); images.delete(job.uri); } -+} -+ -+function pump() { -+ while (activeLoads < MAX_CONCURRENT_LOADS && queue.length) { -+ // OneKey patch: queued leases can be promoted without restarting their load. -+ // const job = queue.shift(); -+ let next = 0; -+ for (let index = 1; index < queue.length; index += 1) { -+ if (jobPriority(queue[index]) < jobPriority(queue[next])) next = index; -+ } -+ const [job] = queue.splice(next, 1); -+ if (jobs.get(job.uri) !== job || !job.ids.size) continue; -+ job.active = true; -+ activeLoads += 1; -+ runJob(job).catch(() => { -+ job.ids.forEach((id) => { -+ if (requests.get(id) === job.uri) postMessage({ type: 'error', id }); -+ }); -+ }).finally(() => { -+ if (jobs.get(job.uri) === job) jobs.delete(job.uri); -+ activeLoads -= 1; -+ pump(); -+ void drainDiskQueue(); -+ }); -+ } -+} -+ -+onmessage = ({ data }) => { -+ if (!data || !Number.isSafeInteger(data.id)) return; -+ if (data.type === 'release') { release(data.id); return; } -+ if (data.type === 'priority') { -+ if (requests.has(data.id)) priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); -+ return; -+ } -+ if (data.type !== 'acquire') return; -+ release(data.id); -+ lastAcquire = Date.now(); -+ scheduleDiskWork(); -+ try { -+ if (typeof data.uri !== 'string' || !data.uri.startsWith(PREFIX)) throw new Error('Invalid avatar URI'); -+ const seed = decodeURIComponent(data.uri.slice(PREFIX.length)).toLowerCase(); -+ if (!seed) throw new Error('Empty avatar seed'); -+ const uri = PREFIX + encodeURIComponent(seed); -+ requests.set(data.id, uri); -+ priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); -+ const image = images.get(uri); -+ if (image) { -+ // Reacquiring a live Blob can retry best-effort persistence after queue pressure. -+ if (!image.persisted) persistLater(uri, image.blob); -+ image.references.add(data.id); -+ postMessage({ type: 'resolved', id: data.id, url: image.url }); -+ return; -+ } -+ let job = jobs.get(uri); -+ if (!job) { -+ job = { uri, seed, ids: new Set(), active: false }; -+ jobs.set(uri, job); -+ queue.push(job); -+ } -+ job.ids.add(data.id); -+ pump(); -+ } catch { postMessage({ type: 'error', id: data.id }); } -+}; -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js -new file mode 100644 -index 0000000..f896e62 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebAvatarCache.js -@@ -0,0 +1,266 @@ -+"use strict"; -+ -+// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. -+const AVATAR_PREFIX = 'onekey-avatar://blockie/v1/'; -+const MAX_RETAINED_AVATARS = 128; -+export function canonicalNativeListAvatarUri(uri) { -+ if (!uri.startsWith(AVATAR_PREFIX)) return undefined; -+ try { -+ const seed = decodeURIComponent(uri.slice(AVATAR_PREFIX.length)); -+ return seed ? AVATAR_PREFIX + encodeURIComponent(seed.toLowerCase()) : undefined; -+ } catch { -+ return undefined; -+ } -+} -+class NativeListWebAvatarCache { -+ entries = new Map(); -+ requests = new Map(); -+ nextId = 0; -+ // OneKey patch: one cancellable startup serves every pending avatar lease. -+ workerGeneration = 0; -+ acquire(uri, resolve, reject, priority = 2) { -+ let entry = this.entries.get(uri); -+ const isNew = !entry; -+ if (!entry) { -+ entry = { -+ id: ++this.nextId, -+ uri, -+ references: 0, -+ listeners: new Set() -+ }; -+ this.entries.set(uri, entry); -+ this.requests.set(entry.id, entry); -+ } -+ const current = entry; -+ this.entries.delete(uri); -+ this.entries.set(uri, current); -+ current.references += 1; -+ const listener = { -+ resolve, -+ reject, -+ priority -+ }; -+ if (current.url) resolve(current.url); -+ // OneKey patch: keep lease priority after resolution as well as while queued. -+ // else current.listeners.add(listener); -+ current.listeners.add(listener); -+ if (isNew) { -+ // OneKey patch: file workers need async asset loading; keep pending requests in this cache. -+ // try { -+ // if (!this.worker) { -+ // // Deliberately avoid *.worker.js and its inline Blob-worker loader. -+ // this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { -+ // name: 'onekey-native-list-avatar', -+ // }); -+ // this.worker.addEventListener('message', this.handleMessage); -+ // this.worker.addEventListener('error', this.handleFailure); -+ // this.worker.addEventListener('messageerror', this.handleFailure); -+ // } -+ // this.worker.postMessage({ type: 'acquire', id: current.id, uri, priority }); -+ // } catch { -+ // queueMicrotask(this.handleFailure); -+ // } -+ if (this.worker) { -+ this.sendAcquire(current); -+ } else { -+ this.startWorker(); -+ } -+ } -+ const updatePriority = () => { -+ if (current.url) return; -+ const priorities = [...current.listeners].map(value => value.priority); -+ this.worker?.postMessage({ -+ type: 'priority', -+ id: current.id, -+ priority: Math.min(2, ...priorities) -+ }); -+ }; -+ updatePriority(); -+ this.trim(); -+ let disposed = false; -+ const release = () => { -+ if (disposed) return; -+ disposed = true; -+ current.listeners.delete(listener); -+ current.references = Math.max(0, current.references - 1); -+ if (current.references === 0 && !current.url && this.entries.get(uri) === current) { -+ this.entries.delete(uri); -+ this.requests.delete(current.id); -+ this.worker?.postMessage({ -+ type: 'release', -+ id: current.id -+ }); -+ if (this.requests.size === 0 && this.startingWorker) { -+ const starting = this.startingWorker; -+ this.startingWorker = undefined; -+ this.workerGeneration += 1; -+ starting.controller?.abort(); -+ } -+ } -+ updatePriority(); -+ this.trim(); -+ }; -+ return Object.assign(release, { -+ setPriority: next => { -+ if (disposed || listener.priority === next) return; -+ listener.priority = next; -+ updatePriority(); -+ } -+ }); -+ } -+ -+ // OneKey patch: HTTP/extension keeps the bundler's original Worker entry. Electron's -+ // file interceptor can serve the same self-contained source as an asset for a Blob worker. -+ startWorker() { -+ if (this.worker || this.startingWorker) return; -+ const starting = { -+ generation: ++this.workerGeneration -+ }; -+ this.startingWorker = starting; -+ const assetURL = new URL('./NativeListAvatarWorker.js', import.meta.url); -+ if (assetURL.protocol === 'file:') { -+ starting.controller = new AbortController(); -+ void (async () => { -+ const response = await fetch(assetURL, { -+ signal: starting.controller?.signal -+ }); -+ if (!response.ok) throw new Error('NativeList avatar worker asset failed to load'); -+ const source = await response.blob(); -+ if (this.startingWorker !== starting) return; -+ const sourceURL = URL.createObjectURL(source); -+ try { -+ this.activateWorker(new Worker(sourceURL, { -+ name: 'onekey-native-list-avatar' -+ }), starting); -+ } finally { -+ // Worker construction captures the script URL; the document must not retain its bytes. -+ URL.revokeObjectURL(sourceURL); -+ } -+ })().catch(() => { -+ if (this.startingWorker === starting || this.workerGeneration === starting.generation) { -+ this.handleFailure(); -+ } -+ }); -+ return; -+ } -+ try { -+ this.activateWorker(new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { -+ name: 'onekey-native-list-avatar' -+ }), starting); -+ } catch { -+ queueMicrotask(() => { -+ if (this.workerGeneration === starting.generation) this.handleFailure(); -+ }); -+ } -+ } -+ activateWorker(worker, starting) { -+ if (this.startingWorker !== starting) { -+ worker.terminate(); -+ return; -+ } -+ this.startingWorker = undefined; -+ this.worker = worker; -+ // OneKey patch: queued events from a failed instance cannot affect its replacement. -+ worker.addEventListener('message', event => { -+ if (this.worker === worker) this.handleMessage(event); -+ }); -+ const failed = () => { -+ if (this.worker === worker) this.handleFailure(); -+ }; -+ worker.addEventListener('error', failed); -+ worker.addEventListener('messageerror', failed); -+ this.requests.forEach(entry => { -+ if (entry.references > 0 && !entry.url) this.sendAcquire(entry); -+ }); -+ } -+ sendAcquire(entry) { -+ const worker = this.worker; -+ try { -+ worker?.postMessage({ -+ type: 'acquire', -+ id: entry.id, -+ uri: entry.uri, -+ priority: Math.min(2, ...[...entry.listeners].map(listener => listener.priority)) -+ }); -+ } catch { -+ queueMicrotask(() => { -+ if (this.worker === worker) this.handleFailure(); -+ }); -+ } -+ } -+ handleMessage = event => { -+ const response = event.data; -+ if (!response || !Number.isSafeInteger(response.id)) return; -+ const entry = this.requests.get(response.id); -+ if (!entry) { -+ this.worker?.postMessage({ -+ type: 'release', -+ id: response.id -+ }); -+ return; -+ } -+ if (response.type === 'resolved' && typeof response.url === 'string' && response.url.startsWith('blob:')) { -+ entry.url = response.url; -+ entry.listeners.forEach(listener => listener.resolve(response.url)); -+ } else { -+ this.entries.delete(entry.uri); -+ this.requests.delete(entry.id); -+ this.worker?.postMessage({ -+ type: 'release', -+ id: entry.id -+ }); -+ entry.listeners.forEach(listener => listener.reject()); -+ } -+ // OneKey patch: retain resolved lease priorities until their explicit release. -+ // entry.listeners.clear(); -+ if (!entry.url) entry.listeners.clear(); -+ this.trim(); -+ }; -+ handleFailure = () => { -+ // OneKey patch: detach the failed generation before callbacks can acquire a replacement. -+ // this.worker?.terminate(); -+ // this.worker = undefined; -+ // this.requests.forEach((entry) => { -+ // if (!entry.url) entry.listeners.forEach((listener) => listener.reject()); -+ // entry.listeners.clear(); -+ // }); -+ // this.requests.clear(); -+ // this.entries.clear(); -+ const failedEntries = [...this.requests.values()]; -+ const starting = this.startingWorker; -+ this.startingWorker = undefined; -+ this.workerGeneration += 1; -+ starting?.controller?.abort(); -+ this.worker?.terminate(); -+ this.worker = undefined; -+ this.requests.clear(); -+ this.entries.clear(); -+ failedEntries.forEach(entry => { -+ const listeners = [...entry.listeners]; -+ entry.listeners.clear(); -+ if (!entry.url) listeners.forEach(listener => listener.reject()); -+ }); -+ }; -+ trim() { -+ for (const [uri, entry] of this.entries) { -+ if (this.entries.size <= MAX_RETAINED_AVATARS) break; -+ if (entry.references === 0) { -+ this.entries.delete(uri); -+ this.requests.delete(entry.id); -+ this.worker?.postMessage({ -+ type: 'release', -+ id: entry.id -+ }); -+ } -+ } -+ } -+} -+const documentCaches = new WeakMap(); -+export function acquireNativeListAvatar(document, uri, resolve, reject, priority = 2) { -+ let cache = documentCaches.get(document); -+ if (!cache) { -+ cache = new NativeListWebAvatarCache(); -+ documentCaches.set(document, cache); -+ } -+ return cache.acquire(uri, resolve, reject, priority); -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -index 625a913..d93e221 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/module/web/NativeListWebEngine.js -@@ -3,7 +3,13 @@ - import { checkboxStateForKeys, checkboxStateForSection, isSelectableRow, reduceSelection, selectionStateFromSnapshot } from "../selection.js"; - import { calculateAlignedScrollOffset, resolveLocationIndex, scrollFailure, validateOffset } from "../scrolling.js"; - import { applyRowPatches, validateSnapshot } from "../validation.js"; --const SECTION_INDEX_GUTTER = 44; -+import { avatarPrefetchWindow } from "../avatarPrefetch.js"; -+import { acquireNativeListAvatar, canonicalNativeListAvatarUri } from "./NativeListWebAvatarCache.js"; -+const SECTION_INDEX_CONTENT_INSET = 16; -+const SECTION_INDEX_RAIL_WIDTH = 32; -+const SECTION_INDEX_EDGE_PADDING = 8; -+const SECTION_INDEX_MIN_LABEL_SPACING = 14; -+const SECTION_INDEX_MIN_HEIGHT = 120; - const DEFAULT_VIEWPORT_WIDTH = 320; - const DEFAULT_VIEWPORT_HEIGHT = 640; - const OVERSCAN_VIEWPORTS = 1; -@@ -147,9 +153,22 @@ function approximateMessageHeight(row, availableWidth) { - return 32 + titleLines * 20 + bodyLines * 20 + 22; - } - export function estimateWebRowHeight(row, snapshot, availableWidth) { -- if (row.type === 'system' && row.variant === 'spacer') return row.height; -- if (row.type === 'walletGroup') return (row.children.length + 1) * 68 + row.children.length * 12; -- if (row.type === 'identity' && row.presentation === 'walletSidebar') return 68; -+ // OneKey patch: explicit selector height takes precedence over presets. -+ // if (row.type === 'system' && row.variant === 'spacer') return row.height; -+ if (row.height !== undefined) return row.height; -+ if (row.type === 'system' && row.variant === 'warning') { -+ const width = Math.max(1, availableWidth - 24); -+ const lines = text => Math.max(1, Math.ceil(Array.from(text).reduce((length, char) => length + (char.charCodeAt(0) > 255 ? 14 : 7), 0) / width)); -+ return 32 + 20 * (lines(row.title) + lines(row.message)); -+ } -+ if (row.type === 'walletGroup') -+ // OneKey patch: wallet badges participate in the outer group height. -+ // return (row.children.length + 1) * 68 + row.children.length * 12; -+ return [row.parent, ...row.children].reduce((height, member) => height + estimateWebRowHeight(member, snapshot, availableWidth), 0) + row.children.length * 12 + (row.parent.height !== undefined ? 2 : 0); -+ // OneKey patch: reserve the source badge line below wallet names. -+ // if (row.type === 'identity' && row.presentation === 'walletSidebar') -+ // return 68; -+ if (row.type === 'identity' && row.presentation === 'walletSidebar') return 68 + (row.badges?.length ? 24 : 0); - if (row.type === 'identity' && row.presentation === 'networkSelector') return 47; - if (row.type === 'identity' && row.presentation === 'accountSelector') return 58; - let base; -@@ -208,9 +227,10 @@ export function computeWebListLayout(snapshot, viewportWidth, viewportHeight, co - const horizontal = snapshot.layout.orientation === 'horizontal'; - const spacing = snapshot.layout.itemSpacing ?? 0; - const padding = paddingValues(snapshot); -- const indexGutter = sectionIndexEnabled(snapshot) ? SECTION_INDEX_GUTTER : 0; - const width = Math.max(1, viewportWidth || DEFAULT_VIEWPORT_WIDTH); - const height = Math.max(1, viewportHeight || DEFAULT_VIEWPORT_HEIGHT); -+ // OneKey patch: the index overlays the content and only keeps a small accessory-safe inset. -+ const indexGutter = sectionIndexEnabled(snapshot) && height >= SECTION_INDEX_MIN_HEIGHT ? SECTION_INDEX_CONTENT_INSET : 0; - const availableWidth = Math.max(1, width - padding.horizontal * 2 - indexGutter); - const availableHeight = Math.max(1, height - padding.top - padding.bottom); - const items = []; -@@ -371,7 +391,10 @@ function rowWithoutSelectionState(row) { - export function webRowRenderSignature(row) { - return JSON.stringify(rowWithoutSelectionState(row)); - } -+ -+// OneKey patch: selector names must shrink before the sidebar clips their contents. - export const WEB_LIST_CSS = ` -+[data-native-list-selector="walletSidebar"] .ok-native-list-title{max-width:100%;min-width:0} - .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} - .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} - .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -382,6 +405,7 @@ export const WEB_LIST_CSS = ` - .ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} - .ok-native-list-item[data-native-list-disabled="true"]>.ok-native-list-row{opacity:.5;cursor:default} - .ok-native-list-item:not([data-native-list-disabled="true"]):hover>.ok-native-list-row{background:var(--nl-pressed)} -+.ok-native-list-item:not([data-native-list-disabled="true"]):active>.ok-native-list-row{background:var(--nl-pressed)} - .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):hover>.ok-native-list-wallet-row{background:var(--nl-strong)} - .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):active>.ok-native-list-wallet-row{background:var(--nl-pressed)} - .ok-native-list-item[data-native-list-selected="true"]:hover>.ok-native-list-wallet-row{background:var(--nl-selected)} -@@ -424,9 +448,29 @@ export const WEB_LIST_CSS = ` - .ok-native-list-media{display:block;padding:0 5px;background:transparent;border-radius:16px}.ok-native-list-media-image{display:block;width:100%;aspect-ratio:1;border-radius:10px;background:var(--nl-strong);object-fit:cover}.ok-native-list-media-image[data-state="empty"]{background:transparent}.ok-native-list-media-image[data-state="error"]{display:flex;align-items:center;justify-content:center;color:var(--nl-icon-subdued);font-size:24px}.ok-native-list-media-meta{padding-top:7px}.ok-native-list-media-subtitle-row{display:flex;align-items:center;gap:6px}.ok-native-list-media-subtitle{flex:1;min-width:0;font-size:12px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-network{width:14px;height:14px;border-radius:50%}.ok-native-list-media-title{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-close{position:absolute;right:9px;top:4px;border:0;background:color-mix(in srgb,var(--nl-inverse) 72%,transparent);color:var(--nl-inverse-text);width:24px;height:24px;border-radius:50%;font:18px/20px inherit;cursor:pointer} - .ok-native-list-metric{display:flex;flex-direction:column;align-items:flex-start;padding:12px;border-radius:12px;gap:5px;background:var(--nl-row)}.ok-native-list-metric-value{font-size:22px;line-height:28px;font-weight:700}.ok-native-list-composite{display:flex;flex-direction:column;align-items:stretch;padding:14px;border-radius:12px;gap:12px;background:var(--nl-subdued)}.ok-native-list-composite-heading{font-size:14px;letter-spacing:1px;color:var(--nl-secondary)}.ok-native-list-composite-row{display:flex;gap:12px}.ok-native-list-composite-cell{flex:1;min-width:0}.ok-native-list-composite-cell[data-shaded="true"]{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--nl-primary) 5%,transparent)}.ok-native-list-composite-value{font-size:18px;font-weight:600}.ok-native-list-divider{height:1px;background:var(--nl-separator)}.ok-native-list-progress{height:4px;border-radius:2px;overflow:hidden;background:var(--nl-negative)}.ok-native-list-progress>span{display:block;height:100%;border-radius:2px;background:var(--nl-positive)} - .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} --.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} -+.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:6px;display:flex;width:20px;height:16px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:8px;background:transparent;color:var(--nl-secondary);font:600 10px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-accent);color:var(--nl-inverse-text)}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-accent);outline-offset:1px}.ok-native-list-index-preview{position:absolute;z-index:8;right:40px;top:50%;display:flex;width:48px;height:48px;align-items:center;justify-content:center;transform:translateY(-50%) scale(.92);border-radius:14px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:22px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translateY(-50%) scale(1)} - .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} -+.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} -+.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} - @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner{animation:none}} -+/* OneKey patch: selector controls follow their original semantic colors and geometry. */ -+.ok-native-list-checkbox[data-selector="networkSelector"]{padding:0;border-radius:4px;border-color:var(--nl-checkbox-border,var(--nl-separator));background:var(--nl-checkbox-icon,var(--nl-inverse-text))} -+.ok-native-list-checkbox[data-selector="networkSelector"]::after{display:none} -+.ok-native-list-checkbox[data-selector="networkSelector"]>svg{display:none;width:16px;height:16px;color:var(--nl-checkbox-icon,var(--nl-inverse-text));flex-shrink:0} -+.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]{border-color:transparent;background:var(--nl-checkbox-background,var(--nl-primary))} -+.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"]>svg[data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]>svg[data-state="indeterminate"]{display:block} -+/* OneKey patch: source WalletListItem uses four-point padding on every side. */ -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"]{border-radius:12px;padding:4px} -+/* OneKey patch: selected group members use the same primary title color as standalone wallets. */ -+.ok-native-list-wallet-member[data-native-list-selected="true"]>.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-title{color:var(--nl-primary)} -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges{height:18px} -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge{font-size:11px;line-height:14px;font-weight:400;height:18px;padding:2px 6px;border-radius:4px;background:var(--nl-subdued);color:var(--nl-secondary)} -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge[data-tone="warning"]{background:var(--nl-caution-background);color:var(--nl-caution)} -+.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>.ok-native-list-icon-button{box-sizing:border-box;flex:0 0 38px;width:38px;height:38px;margin:-7px;padding:7px} -+/* OneKey patch: AccountSelectorAccountListItem fixes the borderless Plus slot at top18/right20. */ -+.ok-native-list-account-row[data-native-list-selector="accountSelector"]>.ok-native-list-accessories[data-native-list-account-control="createAddress"]{position:absolute;top:18px;right:12px} -+.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>[data-native-list-account-control="createAddress"]{flex-basis:36px;width:36px;height:36px;padding:6px;border-radius:8px} -+.ok-native-list-account-action-row{padding-left:12px;padding-right:12px}.ok-native-list-account-action-row .ok-native-list-action-title{font-size:16px;line-height:24px;font-weight:400}.ok-native-list-account-action-row .ok-native-list-action-title[data-tone="primary"]{color:var(--nl-primary)} - `; - function createElement(document, tag, className, text) { - const element = document.createElement(tag); -@@ -442,12 +486,105 @@ function safeImageUri(uri) { - if (/^(https?:|data:image\/|blob:|file:)/i.test(trimmed) || trimmed.startsWith('/')) return trimmed; - return undefined; - } -+ -+// OneKey patch: retries belong to an image binding and must not outlive recycled rows. -+const webImageRetryCleanup = new WeakMap(); -+const webAvatarCleanup = new WeakMap(); -+const webAvatarSources = new WeakMap(); -+function disposeWebImageRetries(element) { -+ let disposed = false; -+ const images = element.matches('img') ? [element] : element.querySelectorAll('img'); -+ images.forEach(image => { -+ const avatarCleanup = webAvatarCleanup.get(image); -+ const cleanup = webImageRetryCleanup.get(image); -+ if (!cleanup && !avatarCleanup) return; -+ avatarCleanup?.(); -+ webAvatarCleanup.delete(image); -+ webAvatarSources.delete(image); -+ cleanup?.(); -+ webImageRetryCleanup.delete(image); -+ disposed = true; -+ }); -+ return disposed; -+} -+function configureWebImageRetry(image, source, initialUri) { -+ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; -+ const retryLimit = Number.isFinite(source.retryTimes) ? Math.max(0, Math.floor(source.retryTimes ?? 0)) : 0; -+ if (!fallbackUri && retryLimit === 0) return; -+ const view = image.ownerDocument.defaultView; -+ let currentUri = initialUri; -+ let usedFallback = false; -+ let retryCount = 0; -+ let retryTimer; -+ let disposed = false; -+ const clearRetry = () => { -+ if (retryTimer !== undefined) view?.clearTimeout(retryTimer); -+ retryTimer = undefined; -+ }; -+ const handleError = event => { -+ if (disposed || !image.isConnected || retryTimer !== undefined) { -+ event.stopImmediatePropagation(); -+ return; -+ } -+ if (fallbackUri && !usedFallback && fallbackUri !== currentUri) { -+ event.stopImmediatePropagation(); -+ usedFallback = true; -+ currentUri = fallbackUri; -+ image.src = currentUri; -+ return; -+ } -+ if (retryCount >= retryLimit || !view) return; -+ event.stopImmediatePropagation(); -+ retryCount += 1; -+ retryTimer = view.setTimeout(() => { -+ retryTimer = undefined; -+ if (disposed || !image.isConnected) return; -+ image.removeAttribute('src'); -+ image.src = currentUri; -+ }, Math.floor(Math.random() * 3) * 1000); -+ }; -+ image.addEventListener('error', handleError); -+ image.addEventListener('load', clearRetry); -+ webImageRetryCleanup.set(image, () => { -+ disposed = true; -+ clearRetry(); -+ image.removeEventListener('error', handleError); -+ image.removeEventListener('load', clearRetry); -+ }); -+} -+function configureWebAvatar(image, source, uri) { -+ const dispose = acquireNativeListAvatar(image.ownerDocument, uri, resolvedUri => { -+ configureWebImageRetry(image, source, resolvedUri); -+ image.src = resolvedUri; -+ }, () => { -+ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; -+ if (fallbackUri) { -+ configureWebImageRetry(image, source, fallbackUri); -+ image.src = fallbackUri; -+ return; -+ } -+ const ImageEvent = image.ownerDocument.defaultView?.Event; -+ if (ImageEvent) image.dispatchEvent(new ImageEvent('error')); -+ }); -+ webAvatarSources.set(image, { -+ uri, -+ source -+ }); -+ webAvatarCleanup.set(image, dispose); -+} - function createImage(context, source, className) { -- const uri = safeImageUri(source.uri); -+ const avatarUri = canonicalNativeListAvatarUri(source.uri); -+ const uri = avatarUri ?? safeImageUri(source.uri); - if (!uri) return undefined; - const image = context.document.createElement('img'); - if (className) image.className = className; -- image.src = uri; -+ // OneKey patch: consume recoverable errors before the visual's final fallback listener. -+ if (avatarUri) { -+ configureWebAvatar(image, source, avatarUri); -+ } else { -+ configureWebImageRetry(image, source, uri); -+ image.src = uri; -+ } - image.alt = ''; - image.draggable = false; - image.loading = 'lazy'; -@@ -455,6 +592,247 @@ function createImage(context, source, className) { - image.style.objectFit = source.contentFit === 'fill' ? 'fill' : source.contentFit ?? 'cover'; - return image; - } -+ -+// OneKey patch: React Native Web paints selector images as centered CSS backgrounds. -+// The image keeps its loading/error lifecycle; only its replaced-element pixels are hidden. -+function paintSelectorImageBackground(image, frame, inset = 0) { -+ const paint = createElement(image.ownerDocument, 'span', 'ok-native-list-selector-image-background'); -+ paint.style.cssText = 'position:absolute;pointer-events:none;border-radius:inherit;background-position:center;background-repeat:no-repeat'; -+ paint.style.inset = String(inset) + 'px'; -+ paint.style.backgroundSize = image.style.objectFit === 'fill' ? '100% 100%' : image.style.objectFit === 'center' ? 'auto' : image.style.objectFit; -+ image.style.opacity = '0'; -+ const update = () => { -+ paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; -+ }; -+ image.addEventListener('load', update); -+ image.addEventListener('error', () => { -+ paint.style.backgroundImage = 'none'; -+ }); -+ frame.insertBefore(paint, image); -+ if (image.complete && image.naturalWidth > 0) update(); -+} -+ -+// OneKey patch: use source SVG paths for selector actions and wallet provider marks. -+const selectorIcons = { -+ "GlobusOutline": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361", -+ "fill": "currentColor", -+ "fillRule": "evenodd", -+ "opacity": 1.0 -+ }] -+ }, -+ "LockSolid": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3", -+ "fill": "currentColor", -+ "fillRule": "evenodd", -+ "opacity": 1.0 -+ }] -+ }, -+ "GoogleIllus": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09", -+ "fill": "#4285F4", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23", -+ "fill": "#34A853", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z", -+ "fill": "#FBBC05", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53", -+ "fill": "#EA4335", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "AppleBrand": { -+ "viewBox": "0 0 16 20", -+ "paths": [{ -+ "d": "M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "BotIllus": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z", -+ "fill": "#8897A5", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z", -+ "fill": "#3FA9F5", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", -+ "fill": "#8897A5", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z", -+ "fill": "#8897A5", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", -+ "fill": "#10243E", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0", -+ "fill": "#10243E", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06", -+ "fill": "#10243E", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "AllNetworksSolid": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }, { -+ "d": "M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z", -+ "fill": "currentColor", -+ "fillRule": "evenodd", -+ "opacity": 1.0 -+ }] -+ }, -+ "CrossedSmallSolid": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "AccountErrorCustom": { -+ "viewBox": "0 0 18 18", -+ "paths": [{ -+ "d": "M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5", -+ "fill": "#000", -+ "fillRule": "nonzero", -+ "opacity": 0.447 -+ }, { -+ "d": "M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5", -+ "fill": "#000", -+ "fillRule": "evenodd", -+ "opacity": 0.447 -+ }] -+ }, -+ "PlusSmallOutline": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M13 11h5v2h-5v5h-2v-5H6v-2h5V6h2z", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "DotHorOutline": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M6 14H2v-4h4zm8 0h-4v-4h4zm8 0h-4v-4h4z", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "ChevronRightSmallOutline": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M15.414 12 10 17.414 8.586 16l4-4-4-4L10 6.586z", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "DragOutline": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M11 21H7v-4h4zm6 0h-4v-4h4zm-6-7H7v-4h4zm6 0h-4v-4h4zm-6-7H7V3h4zm6 0h-4V3h4z", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1.0 -+ }] -+ }, -+ "PencilOutline": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M22.414 7.5 7.914 22H2v-5.914l14.5-14.5zM4 16.914V20h3.086l9.5-9.5L13.5 7.414zM14.914 6 18 9.086 19.586 7.5 16.5 4.414z", -+ "fill": "currentColor", -+ "fillRule": "evenodd", -+ "opacity": 1.0 -+ }] -+ }, -+ "CheckboxCheckedCustom": { -+ "viewBox": "0 0 16 16", -+ "paths": [{ -+ "d": "M12.204 5.043a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414l1.293 1.293 3.793-3.793a1 1 0 0 1 1.414 0", -+ "fill": "currentColor", -+ "fillRule": "evenodd", -+ "opacity": 1.0 -+ }] -+ }, -+ "CheckboxIndeterminateCustom": { -+ "viewBox": "0 0 16 16", -+ "paths": [{ -+ "d": "M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1", -+ "fill": "currentColor", -+ "fillRule": "evenodd", -+ "opacity": 1.0 -+ }] -+ }, -+ "Circle": { -+ "viewBox": "0 0 24 24", -+ "paths": [{ -+ "d": "M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0", -+ "fill": "currentColor", -+ "fillRule": "nonzero", -+ "opacity": 1 -+ }] -+ } -+}; -+function applySelectorIcon(element, name) { -+ const icon = selectorIcons[name]; -+ if (!icon) return; -+ element.textContent = ''; -+ const svg = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); -+ svg.setAttribute('viewBox', icon.viewBox); -+ svg.setAttribute('width', '24'); -+ svg.setAttribute('height', '24'); -+ svg.setAttribute('aria-hidden', 'true'); -+ icon.paths.forEach(path => { -+ const child = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); -+ child.setAttribute('d', path.d); -+ child.setAttribute('fill', path.fill); -+ child.setAttribute('fill-rule', path.fillRule); -+ child.setAttribute('fill-opacity', String(path.opacity)); -+ svg.appendChild(child); -+ }); -+ element.appendChild(svg); -+} - function iconGlyph(name) { - const normalized = name.toLocaleLowerCase(); - if (normalized.includes('chevron')) { -@@ -484,7 +862,7 @@ function visualFromRow(row) { - if (row.type === 'metricCard') return row.visual; - return undefined; - } --function createVisual(context, visual) { -+function createVisual(context, visual, selectorPresentation) { - if (!visual) return undefined; - if (visual.kind === 'stackedImages') { - const stack = createElement(context.document, 'span', 'ok-native-list-stacked'); -@@ -500,6 +878,7 @@ function createVisual(context, visual) { - if (visual.kind === 'icon') { - frame.style.background = visual.backgroundColor ?? 'var(--nl-strong)'; - const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback', iconGlyph(visual.name)); -+ applySelectorIcon(fallback, visual.name); - if (visual.tintColor) fallback.style.color = visual.tintColor; - frame.appendChild(fallback); - return frame; -@@ -510,6 +889,7 @@ function createVisual(context, visual) { - if (image) { - image.className = 'ok-native-list-visual-main'; - frame.appendChild(image); -+ if (selectorPresentation) paintSelectorImageBackground(image, frame); - } else { - frame.appendChild(createElement(context.document, 'span', 'ok-native-list-visual-fallback', 'fallbackText' in visual ? visual.fallbackText ?? '' : '')); - } -@@ -520,8 +900,64 @@ function createVisual(context, visual) { - const corner = createElement(context.document, 'span', 'ok-native-list-visual-corner ok-native-list-visual-fallback', iconGlyph(visual.cornerIcon.name)); - if (visual.cornerIcon.tintColor) corner.style.color = visual.cornerIcon.tintColor; - if (visual.cornerIcon.backgroundColor) corner.style.background = visual.cornerIcon.backgroundColor; -+ applySelectorIcon(corner, visual.cornerIcon.name); - frame.appendChild(corner); - } -+ // OneKey patch: image failure uses the same source-derived fallback as v1. -+ if ('fallbackIcon' in visual && visual.fallbackIcon) { -+ const icon = visual.fallbackIcon; -+ const showFallback = () => { -+ if (image) { -+ disposeWebImageRetries(image); -+ image.remove(); -+ } -+ frame.querySelector('.ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)')?.remove(); -+ const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback'); -+ applySelectorIcon(fallback, icon.name); -+ if (icon.tintColor) fallback.style.color = icon.tintColor; -+ frame.prepend(fallback); -+ }; -+ if (image) image.addEventListener('error', showFallback, { -+ once: true -+ });else showFallback(); -+ } -+ if ('borderStyle' in visual && visual.borderStyle === 'dashed') { -+ frame.style.border = '2px dashed ' + (visual.borderColor ?? 'var(--nl-disabled)'); -+ frame.style.boxSizing = 'border-box'; -+ } -+ if ('overlays' in visual) visual.overlays?.forEach(overlay => { -+ const corner = createElement(context.document, 'span', 'ok-native-list-visual-overlay', overlay.text); -+ const size = overlay.size ?? 20; -+ const isWalletText = selectorPresentation === 'walletSidebar' && !!overlay.text && !overlay.image && !overlay.name; -+ corner.style.width = isWalletText && overlay.width === undefined ? 'auto' : String(overlay.width ?? size) + 'px'; -+ corner.style.height = String(overlay.height ?? (isWalletText ? 16 : size)) + 'px'; -+ corner.style.padding = isWalletText ? '0 2px' : String(overlay.padding ?? 0) + 'px'; -+ if (isWalletText) { -+ corner.style.fontSize = '12px'; -+ corner.style.lineHeight = '16px'; -+ corner.style.fontWeight = '400'; -+ } -+ if (isWalletText || overlay.width !== undefined || overlay.height !== undefined) corner.style.borderRadius = '9999px'; -+ const offsetX = String(-(overlay.offsetX ?? overlay.offset ?? 2)) + 'px'; -+ const offsetY = String(-(overlay.offsetY ?? overlay.offset ?? 2)) + 'px'; -+ corner.style.background = overlay.backgroundColor ?? 'transparent'; -+ corner.style.color = overlay.tintColor ?? 'var(--nl-secondary)'; -+ if (overlay.position === 'topLeft') { -+ corner.style.left = offsetX; -+ corner.style.top = offsetY; -+ } else { -+ corner.style.right = offsetX; -+ corner.style.bottom = offsetY; -+ } -+ if (overlay.image) { -+ const overlayImage = createImage(context, overlay.image); -+ if (overlayImage) { -+ corner.appendChild(overlayImage); -+ if (selectorPresentation) paintSelectorImageBackground(overlayImage, corner, overlay.padding ?? 0); -+ } -+ } else if (overlay.name) applySelectorIcon(corner, overlay.name); -+ frame.appendChild(corner); -+ }); - return frame; - } - function toneColor(tone, fallback) { -@@ -567,6 +1003,19 @@ function createCheckbox(context, rowKey, accessory) { - setData(element, 'checkboxFallback', accessory.state); - setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); - setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); -+ const row = context.snapshot.rows[context.itemIndex]; -+ if (row && 'presentation' in row && row.presentation === 'networkSelector') { -+ setData(element, 'selector', 'networkSelector'); -+ for (const [state, name] of [['checked', 'CheckboxCheckedCustom'], ['indeterminate', 'CheckboxIndeterminateCustom']]) { -+ const holder = createElement(context.document, 'span'); -+ applySelectorIcon(holder, name); -+ const svg = holder.firstElementChild; -+ if (svg) { -+ svg.setAttribute('data-state', state); -+ element.appendChild(svg); -+ } -+ } -+ } - if (accessory.target?.scope === 'section') setData(element, 'selectionKey', accessory.target.sectionKey);else if (accessory.target?.scope === 'row') setData(element, 'selectionKey', rowKey); - return element; - } -@@ -576,6 +1025,7 @@ function createIconAction(context, name, actionKey, disabled, tintColor) { - element.setAttribute('type', 'button'); - element.toggleAttribute('disabled', Boolean(disabled)); - } -+ applySelectorIcon(element, name); - if (actionKey) setData(element, 'nativeListAction', actionKey); - if (tintColor) element.style.color = tintColor; - return element; -@@ -584,6 +1034,23 @@ function markActionAnchorSource(element, source, slot) { - setData(element, 'nativeListAnchorSource', source); - if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); - } -+ -+// OneKey patch: the compact zero-count digits share the amount baseline. -+function applyValueSegments(element, segments, fontSize = 16, lineHeight = 24, weight = 500) { -+ if (!segments?.length) return; -+ element.textContent = ''; -+ element.style.fontSize = String(fontSize) + 'px'; -+ element.style.lineHeight = String(lineHeight) + 'px'; -+ element.style.fontWeight = String(weight); -+ segments.forEach(segment => { -+ const span = createElement(element.ownerDocument, 'span', undefined, segment.text); -+ if (segment.style === 'subscript') { -+ span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; -+ span.style.lineHeight = String(fontSize) + 'px'; -+ } -+ element.appendChild(span); -+ }); -+} - function createAccessory(context, rowKey, accessory, slot) { - if (accessory.kind === 'checkbox') { - const element = createCheckbox(context, rowKey, accessory); -@@ -593,6 +1060,18 @@ function createAccessory(context, rowKey, accessory, slot) { - if (accessory.kind === 'icon') { - const element = createIconAction(context, accessory.name, accessory.actionKey, accessory.disabled, accessory.tintColor); - markActionAnchorSource(element, 'trailingAccessory', slot); -+ setData(element, 'testid', accessory.testID); -+ if (accessory.hoverActionKey) setData(element, 'nativeListHoverAction', accessory.hoverActionKey); -+ if (accessory.accessibilityLabel) element.setAttribute('aria-label', accessory.accessibilityLabel); -+ const row = context.snapshot.rows[context.itemIndex]; -+ if (row && 'presentation' in row && row.presentation === 'accountSelector') { -+ setData(element, 'nativeListAnchorInset', 7); -+ // OneKey patch: the create-address button omits IconButton's one-point border. -+ if (row.height !== undefined && accessory.name === 'PlusSmallOutline') { -+ setData(element, 'nativeListAccountControl', 'createAddress'); -+ if (!accessory.tintColor) element.style.color = 'var(--nl-icon-subdued)'; -+ } -+ } - return element; - } - if (accessory.kind === 'spinner') { -@@ -608,6 +1087,7 @@ function createAccessory(context, rowKey, accessory, slot) { - switch (accessory.kind) { - case 'value': - element.textContent = accessory.text; -+ applyValueSegments(element, accessory.textSegments); - if (accessory.secondary) element.classList.add('ok-native-list-accessory-secondary'); - break; - case 'valuePair': -@@ -646,6 +1126,13 @@ function createAccessory(context, rowKey, accessory, slot) { - function appendAccessories(parent, context, rowKey, accessories) { - if (!accessories?.length) return; - const container = createElement(context.document, 'span', 'ok-native-list-accessories'); -+ const row = context.snapshot.rows[context.itemIndex]; -+ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'networkSelector') { -+ container.style.gap = accessories.some(accessory => accessory.kind === 'checkbox') ? '12px' : '20px'; -+ } -+ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'accountSelector' && accessories.length === 1 && accessories[0]?.kind === 'icon' && accessories[0].name === 'PlusSmallOutline') { -+ setData(container, 'nativeListAccountControl', 'createAddress'); -+ } - accessories.forEach((accessory, slot) => container.appendChild(createAccessory(context, rowKey, accessory, slot))); - parent.appendChild(container); - } -@@ -673,9 +1160,83 @@ function createSectionHeader(context, row) { - markActionAnchorSource(titleIcon, 'leadingAction'); - body.appendChild(titleIcon); - } -- body.appendChild(createTextColumn(context, row.title, row.subtitle)); -+ // OneKey patch: section title help has its own measurable action target. -+ // body.appendChild(createTextColumn(context, row.title, row.subtitle)); -+ const column = createTextColumn(context, row.title, row.subtitle); -+ const title = column.firstElementChild; -+ title.classList.add('ok-native-list-section-title'); -+ if (row.titleActionKey) { -+ setData(title, 'nativeListAction', row.titleActionKey); -+ markActionAnchorSource(title, 'leadingAction'); -+ title.setAttribute('role', 'button'); -+ title.tabIndex = 0; -+ title.style.alignSelf = 'flex-start'; -+ title.style.maxWidth = '100%'; -+ // OneKey patch: explicit network headers reserve a separate 3-point underline area. -+ if (row.presentation !== 'networkSelector' || row.height === undefined) { -+ title.style.textDecoration = 'underline dotted'; -+ title.style.textUnderlineOffset = '6px'; -+ } -+ if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); -+ } -+ if (row.presentation === 'networkSelector' && row.height !== undefined) { -+ body.style.padding = row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; -+ body.style.backgroundColor = 'var(--nl-bg)'; -+ body.style.gap = row.checkbox ? '12px' : '8px'; -+ title.style.fontSize = row.variant === 'summary' ? '16px' : '14px'; -+ title.style.lineHeight = row.variant === 'summary' ? '24px' : '20px'; -+ title.style.fontWeight = row.variant === 'summary' || row.titleActionKey && !row.checkbox ? '500' : '600'; -+ if (row.titleActionKey) { -+ const text = createElement(context.document, 'span', 'ok-native-list-section-title-text', row.title); -+ text.style.overflow = 'hidden'; -+ text.style.textOverflow = 'ellipsis'; -+ text.style.maxWidth = '100%'; -+ const dotted = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); -+ dotted.setAttribute('height', '2'); -+ dotted.style.cssText = 'display:block;position:absolute;left:0;bottom:0;width:100%;height:2px;color:var(--nl-secondary)'; -+ const line = context.document.createElementNS('http://www.w3.org/2000/svg', 'line'); -+ for (const [key, value] of Object.entries({ -+ x1: '1', -+ y1: '1', -+ x2: '100%', -+ y2: '1', -+ stroke: 'currentColor', -+ 'stroke-width': '1.5', -+ 'stroke-dasharray': '0,4', -+ 'stroke-linecap': 'round' -+ })) line.setAttribute(key, value); -+ // OneKey patch: keep both round caps inside the original full-width viewport. -+ const lineViewport = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); -+ lineViewport.setAttribute('width', 'calc(100% - 1px)'); -+ lineViewport.setAttribute('height', '2'); -+ lineViewport.setAttribute('overflow', 'visible'); -+ lineViewport.appendChild(line); -+ dotted.appendChild(lineViewport); -+ // OneKey patch: SVG intrinsic width must not expand the title action beyond its text. -+ title.style.display = 'block'; -+ title.style.position = 'relative'; -+ title.style.width = 'fit-content'; -+ title.style.paddingBottom = '3px'; -+ text.style.display = 'block'; -+ title.replaceChildren(text, dotted); -+ } -+ } -+ body.appendChild(column); - if (row.value) { - const value = createElement(context.document, row.valueActionKey ? 'button' : 'span', row.valueActionKey ? 'ok-native-list-action-button ok-native-list-section-value' : 'ok-native-list-value ok-native-list-section-value', row.value); -+ applyValueSegments(value, row.valueSegments); -+ if (row.presentation === 'networkSelector' && row.height !== undefined) { -+ value.style.fontFamily = 'inherit'; -+ value.style.fontSize = '16px'; -+ value.style.lineHeight = '24px'; -+ value.style.fontWeight = '500'; -+ if (row.valueActionKey) { -+ value.style.color = 'var(--nl-secondary)'; -+ value.style.padding = '0'; -+ value.style.flexShrink = '0'; -+ } -+ } -+ if (row.valueActionTestID) setData(value, 'testid', row.valueActionTestID); - if (row.valueActionKey) { - value.setAttribute('type', 'button'); - setData(value, 'nativeListAction', row.valueActionKey); -@@ -696,6 +1257,7 @@ function createActionRow(context, row) { - if (row.icon) body.appendChild(createVisual(context, row.icon)); - const title = createElement(context.document, 'span', 'ok-native-list-action-title', row.title); - setData(title, 'tone', row.tone); -+ if (row.presentation === 'accountSelector' && row.icon) title.style.fontWeight = '500'; - body.appendChild(title); - if (row.checkbox) body.appendChild(createCheckbox(context, row.key, row.checkbox)); - appendAccessories(body, context, row.key, row.trailing); -@@ -704,6 +1266,13 @@ function createActionRow(context, row) { - function createSystemRow(context, row) { - const body = createElement(context.document, 'div', 'ok-native-list-row ok-native-list-system'); - setData(body, 'variant', row.variant); -+ if (row.variant === 'warning') { -+ body.classList.add('ok-native-list-warning'); -+ body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; -+ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-title', row.title)); -+ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-message', row.message)); -+ return body; -+ } - if (row.variant === 'loading') body.appendChild(createElement(context.document, 'span', 'ok-native-list-spinner')); - const message = row.variant === 'spacer' ? '' : row.message ?? (row.variant === 'end' ? 'End' : ''); - if (message) body.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', message)); -@@ -852,12 +1421,31 @@ function createDataRow(context, row) { - function createIdentityActivityOrMessageRow(context, row) { - const presentation = row.type === 'identity' ? row.presentation : undefined; - const body = createElement(context.document, 'div', ['ok-native-list-row', 'ok-native-list-standard', row.type === 'identity' && !presentation ? 'ok-native-list-identity-row' : '', presentation === 'networkSelector' ? 'ok-native-list-network-row' : '', presentation === 'walletSidebar' ? 'ok-native-list-wallet-row' : '', presentation === 'accountSelector' ? 'ok-native-list-account-row' : ''].filter(Boolean).join(' ')); -+ setData(body, 'nativeListSelector', row.height !== undefined ? presentation : undefined); -+ if (row.type === 'identity' && row.titleActionKey && row.titleActionOnHover) { -+ setData(body, 'nativeListHoverAction', row.titleActionKey); -+ markActionAnchorSource(body, 'leadingAction'); -+ } - if (row.type === 'identity' && row.leadingAction) { - const action = createIconAction(context, row.leadingAction.name, row.leadingAction.actionKey, row.leadingAction.disabled, row.leadingAction.tintColor); - markActionAnchorSource(action, 'leadingAction'); - body.appendChild(action); - } -- const visual = createVisual(context, visualFromRow(row)); -+ const visual = createVisual(context, visualFromRow(row), row.height !== undefined ? presentation : undefined); -+ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'fallbackIcon' in row.leading && row.leading.fallbackIcon?.name === 'LockSolid') { -+ // OneKey patch: hidden-wallet locks use WalletAvatar's full 40-point icon. -+ const icon = visual.querySelector('.ok-native-list-visual-fallback svg'); -+ if (icon) { -+ icon.style.width = '40px'; -+ icon.style.height = '40px'; -+ } -+ const fallback = visual.querySelector('.ok-native-list-visual-fallback'); -+ if (fallback) { -+ fallback.style.borderRadius = '0'; -+ fallback.style.overflow = 'visible'; -+ } -+ } -+ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'borderStyle' in row.leading && row.leading.borderStyle === 'dashed') visual.style.borderWidth = '1px'; - if (visual) body.appendChild(visual); - if (row.type === 'activity' && row.secondaryLeading) { - const secondVisual = createVisual(context, row.secondaryLeading); -@@ -866,7 +1454,46 @@ function createIdentityActivityOrMessageRow(context, row) { - if (row.type === 'message' && row.unread) body.appendChild(createElement(context.document, 'span', 'ok-native-list-unread')); - const title = row.title; - const subtitle = row.type === 'identity' ? row.subtitle : row.type === 'activity' ? row.description : row.body; -- const column = createTextColumn(context, title, subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, row.type === 'identity' ? row.badges : undefined); -+ const column = createTextColumn(context, title, subtitle, row.type === 'identity' ? row.tertiary : undefined, row.type === 'identity' ? row.tertiaryTone : undefined, row.type === 'identity' && presentation !== 'walletSidebar' ? row.badges : undefined); -+ // OneKey patch: match existing search, subtitle fragments, and sidebar badges. -+ if (row.type === 'identity') { -+ const titleElement = column.firstElementChild; -+ if (row.titleMatch?.length) { -+ const firstText = titleElement.firstChild; -+ if (firstText) firstText.remove(); -+ const fragment = context.document.createDocumentFragment(); -+ let offset = 0; -+ row.titleMatch.forEach(({ -+ start, -+ end -+ }) => { -+ fragment.appendChild(context.document.createTextNode(row.title.slice(offset, start))); -+ const match = createElement(context.document, 'span', 'ok-native-list-info', row.title.slice(start, end)); -+ fragment.appendChild(match); -+ offset = end; -+ }); -+ fragment.appendChild(context.document.createTextNode(row.title.slice(offset))); -+ titleElement.prepend(fragment); -+ } -+ if (row.subtitleSegments?.length) { -+ column.querySelector('.ok-native-list-secondary')?.remove(); -+ const segments = createElement(context.document, 'span', 'ok-native-list-subtitle-segments'); -+ row.subtitleSegments.forEach(segment => { -+ if (segment.separatorBefore) segments.appendChild(createElement(context.document, 'span', 'ok-native-list-subtitle-dot')); -+ const text = createElement(context.document, 'span', 'ok-native-list-secondary', segment.text); -+ applyValueSegments(text, segment.textSegments, 14, 20, 400); -+ setData(text, 'tone', segment.tone); -+ text.style.color = segment.tone === 'disabled' ? 'var(--nl-disabled)' : segment.tone === 'caution' ? 'var(--nl-caution)' : toneColor(segment.tone, 'secondary'); -+ segments.appendChild(text); -+ }); -+ column.insertBefore(segments, titleElement.nextSibling); -+ } -+ if (presentation === 'walletSidebar' && row.badges?.length) { -+ const badges = createElement(context.document, 'span', 'ok-native-list-wallet-badges'); -+ row.badges.forEach(badge => badges.appendChild(createBadge(context, badge))); -+ column.appendChild(badges); -+ } -+ } - if (row.type === 'activity' && row.status) column.appendChild(createElement(context.document, 'span', 'ok-native-list-secondary', row.status)); - if (row.type === 'activity' && row.footerActions?.length) { - const actions = createElement(context.document, 'span', 'ok-native-list-actions'); -@@ -898,14 +1525,32 @@ function createIdentityActivityOrMessageRow(context, row) { - } - return body; - } -+ -+// OneKey patch: SizableText enables tabular digits without replacing its font family. -+function applySelectorTabularNumbers(body, row) { -+ if (!('presentation' in row) || !['accountSelector', 'networkSelector', 'walletSidebar'].includes(row.presentation ?? '')) return; -+ body.style.fontVariantNumeric = 'tabular-nums'; -+ body.querySelectorAll('span,button').forEach(text => { -+ text.style.fontVariantNumeric = 'tabular-nums'; -+ }); -+} - function createWalletGroupRow(context, row) { - const body = createElement(context.document, 'div', 'ok-native-list-wallet-group'); - [row.parent, ...row.children].forEach((member, memberIndex) => { - const memberElement = createElement(context.document, 'div', 'ok-native-list-wallet-member'); - setData(memberElement, 'nativeListGroupMemberKey', member.key); -+ setData(memberElement, 'testid', member.testID); - setData(memberElement, 'nativeListGroupParent', memberIndex === 0); - setData(memberElement, 'nativeListSelected', member.selected); -- memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); -+ // OneKey patch: grouped members have the same selector typography as standalone wallets. -+ // memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); -+ const memberBody = createIdentityActivityOrMessageRow(context, member); -+ applySelectorTabularNumbers(memberBody, member); -+ memberElement.appendChild(memberBody); -+ // OneKey patch: group children use their own measured badge height. -+ memberElement.style.flexBasis = String(member.height ?? 68 + (member.badges?.length ? 24 : 0)) + 'px'; -+ memberElement.style.height = memberElement.style.flexBasis; -+ memberElement.style.opacity = String(member.opacity ?? 1); - body.appendChild(memberElement); - }); - return body; -@@ -945,6 +1590,11 @@ export class NativeListWebEngine { - }; - mounted = new Map(); - pool = []; -+ // OneKey patch: URI leases outlive DOM overscan only inside this bounded window. -+ avatarLeases = new Map(); -+ avatarOffset = 0; -+ avatarDirection = 1; -+ sectionIndexEntries = []; - suppressClickUntil = 0; - pullDistance = 0; - actionAnchorInstanceId = String(++webNativeListInstanceCounter); -@@ -953,6 +1603,8 @@ export class NativeListWebEngine { - lastViewportWidth = -1; - lastViewportHeight = -1; - destroyed = false; -+ // OneKey patch: warning banners are measured after normal browser text wrapping. -+ measuredWarningHeights = new Map(); - constructor(host, snapshot, callbacks, virtualizationEnabled = true) { - this.document = host.ownerDocument; - this.snapshot = validateSnapshot(snapshot); -@@ -991,6 +1643,9 @@ export class NativeListWebEngine { - passive: true - }); - this.root.addEventListener('click', this.handleClick); -+ // OneKey patch: preserve web tooltip hover for section titles. -+ this.root.addEventListener('pointerover', this.handleTitlePointerOver); -+ this.root.addEventListener('pointerout', this.handleTitlePointerOut); - this.root.addEventListener('keydown', this.handleKeyDown); - this.viewport.addEventListener('pointerdown', this.handleReorderPointerDown, { - passive: false -@@ -1125,7 +1780,7 @@ export class NativeListWebEngine { - scrollToLocation(params, scroll) { - const index = resolveLocationIndex(this.snapshot.rows, params); - if (index === undefined) { -- const sectionCount = this.snapshot.rows.filter(row => row.type === 'sectionHeader' && row.variant !== 'summary').length; -+ const sectionCount = this.snapshot.rows.filter(row => row.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary').length; - this.emitScrollFailure(params.itemIndex, params.sectionIndex >= sectionCount ? 'section-out-of-range' : 'item-out-of-range'); - return; - } -@@ -1172,6 +1827,8 @@ export class NativeListWebEngine { - this.document.defaultView?.removeEventListener('resize', this.handleWindowResize); - this.viewport.removeEventListener('scroll', this.handleScroll); - this.root.removeEventListener('click', this.handleClick); -+ this.root.removeEventListener('pointerover', this.handleTitlePointerOver); -+ this.root.removeEventListener('pointerout', this.handleTitlePointerOut); - this.root.removeEventListener('keydown', this.handleKeyDown); - this.cancelPointerReorder(true); - this.viewport.removeEventListener('pointerdown', this.handleReorderPointerDown); -@@ -1189,19 +1846,23 @@ export class NativeListWebEngine { - this.viewport.removeEventListener('pointerup', this.handlePullEnd); - this.viewport.removeEventListener('pointercancel', this.handlePullEnd); - const host = this.root.parentElement; -+ disposeWebImageRetries(this.root); -+ this.pool.forEach(disposeWebImageRetries); - this.root.remove(); - this.hideReorderPreview(); - this.reorderPreview.remove(); - if (host) host.style.position = this.previousHostPosition; - this.mounted.clear(); - this.pool.length = 0; -+ this.avatarLeases.forEach(release => release()); -+ this.avatarLeases.clear(); - } - setSnapshot(snapshot, selectedKeys) { -+ this.measuredWarningHeights.clear(); - this.snapshot = snapshot; - this.rows = effectiveRows(snapshot); - this.selectedKeys = selectedKeys ?? selectionStateFromSnapshot(snapshot).selectedKeys; - if (this.reachedGeneration !== snapshot.generation) this.reachedGeneration = undefined; -- this.renderSectionIndex(); - this.applyTheme(); - this.recomputeLayout(); - this.renderFooter(); -@@ -1219,6 +1880,11 @@ export class NativeListWebEngine { - '--nl-primary': theme.primaryText, - '--nl-secondary': theme.secondaryText, - '--nl-disabled': theme.disabledText, -+ '--nl-caution': theme.caution ?? '#AB6400', -+ '--nl-caution-background': theme.cautionBackground, -+ '--nl-checkbox-background': theme.checkboxBackground, -+ '--nl-checkbox-border': theme.checkboxBorder, -+ '--nl-checkbox-icon': theme.checkboxIcon, - '--nl-icon': theme.icon, - '--nl-icon-subdued': theme.iconSubdued, - '--nl-separator': theme.separator, -@@ -1242,13 +1908,22 @@ export class NativeListWebEngine { - const viewportHeight = this.viewport.clientHeight; - if (this.lastViewportWidth >= 0 && (viewportWidth !== this.lastViewportWidth || viewportHeight !== this.lastViewportHeight)) { - this.invalidateActionAnchor('layout'); -+ this.measuredWarningHeights.clear(); - } - this.lastViewportWidth = viewportWidth; - this.lastViewportHeight = viewportHeight; - const previousHorizontal = this.layout.horizontal; -- this.layout = computeWebListLayout(this.snapshot, viewportWidth, viewportHeight, this.reorderCompactKey); -+ const measuredSnapshot = { -+ ...this.snapshot, -+ rows: this.snapshot.rows.map(row => row.type === 'system' && row.variant === 'warning' && row.height === undefined && this.measuredWarningHeights.has(row.key) ? { -+ ...row, -+ height: this.measuredWarningHeights.get(row.key) -+ } : row) -+ }; -+ this.layout = computeWebListLayout(measuredSnapshot, viewportWidth, viewportHeight, this.reorderCompactKey); - this.content.style.width = String(this.layout.contentWidth) + 'px'; - this.content.style.height = String(this.layout.contentHeight) + 'px'; -+ this.renderSectionIndex(viewportHeight || DEFAULT_VIEWPORT_HEIGHT); - if (previousHorizontal !== this.layout.horizontal) { - this.viewport.scrollLeft = 0; - this.viewport.scrollTop = 0; -@@ -1256,7 +1931,44 @@ export class NativeListWebEngine { - this.renderWindow(); - this.performPendingScroll(); - }; -+ updateAvatarWindow() { -+ const offset = this.currentOffset(); -+ if (offset !== this.avatarOffset) this.avatarDirection = Math.sign(offset - this.avatarOffset); -+ this.avatarOffset = offset; -+ const visible = visibleWebLayoutItems(this.layout, offset, this.viewportLength(), 0); -+ const first = visible[0]?.index ?? -1; -+ const last = visible[visible.length - 1]?.index ?? -1; -+ const candidates = avatarPrefetchWindow(this.rows, first, last, this.avatarDirection); -+ const desired = new Set(candidates.map(({ -+ source -+ }) => canonicalNativeListAvatarUri(source.uri)).filter(uri => uri !== undefined)); -+ this.avatarLeases.forEach((release, uri) => { -+ if (!desired.has(uri)) { -+ release(); -+ this.avatarLeases.delete(uri); -+ } -+ }); -+ candidates.forEach(({ -+ source, -+ priority -+ }) => { -+ const uri = canonicalNativeListAvatarUri(source.uri); -+ if (!uri) return; -+ const existing = this.avatarLeases.get(uri); -+ if (existing) existing.setPriority(priority);else { -+ let lease; -+ lease = acquireNativeListAvatar(this.document, uri, () => {}, () => { -+ if (this.avatarLeases.get(uri) === lease) { -+ lease?.(); -+ this.avatarLeases.delete(uri); -+ } -+ }, priority); -+ this.avatarLeases.set(uri, lease); -+ } -+ }); -+ } - renderWindow() { -+ this.updateAvatarWindow(); - const viewportLength = this.viewportLength(); - const visible = webLayoutItemsForMount(this.layout, this.currentOffset(), viewportLength, this.virtualizationEnabled, viewportLength * OVERSCAN_VIEWPORTS); - const desired = new Set(visible.map(item => item.index)); -@@ -1264,6 +1976,7 @@ export class NativeListWebEngine { - if (!desired.has(index)) { - this.invalidateActionAnchorForElement(element); - this.mounted.delete(index); -+ if (disposeWebImageRetries(element)) element.removeAttribute('data-render-signature'); - element.remove(); - this.pool.push(element); - } -@@ -1285,6 +1998,20 @@ export class NativeListWebEngine { - element.dataset.renderSignature = signature; - } - }); -+ let measuredWarningChanged = false; -+ this.mounted.forEach((element, index) => { -+ const row = this.rows[index]; -+ if (row?.type !== 'system' || row.variant !== 'warning' || row.height !== undefined) return; -+ const height = element.querySelector('.ok-native-list-warning')?.offsetHeight ?? 0; -+ if (height > 0 && height !== this.measuredWarningHeights.get(row.key)) { -+ this.measuredWarningHeights.set(row.key, height); -+ measuredWarningChanged = true; -+ } -+ }); -+ if (measuredWarningChanged) { -+ this.recomputeLayout(); -+ return; -+ } - this.updateVisibleSelection(); - this.updateVisibleState(); - } -@@ -1295,12 +2022,16 @@ export class NativeListWebEngine { - } - renderElement(element, index, row, overlay = false) { - this.invalidateActionAnchorForElement(element); -+ disposeWebImageRetries(element); - const bindingEpoch = String(++this.bindingEpochCounter); - element.className = overlay ? 'ok-native-list-item ok-native-list-sticky' : 'ok-native-list-item'; - setData(element, 'nativeListRowKey', row.key); - setData(element, 'nativeListBindingEpoch', bindingEpoch); - setData(element, 'nativeListRowIndex', index); - setData(element, 'nativeListDisabled', Boolean(row.disabled)); -+ setData(element, 'testid', row.testID); -+ // OneKey patch: deprecation dims a row without disabling its actions. -+ element.style.opacity = String(row.opacity ?? 1); - setData(element, 'nativeListReorderable', this.isReorderable(row)); - setData(element, 'nativeListDragging', this.pointerReorder?.active && this.pointerReorder.sourceKey === row.key); - setData(element, 'separator', row.separator); -@@ -1320,11 +2051,67 @@ export class NativeListWebEngine { - selectedKeys: this.selectedKeys, - itemIndex: index - }; -- element.replaceChildren(createRowBody(context, row)); -+ const body = createRowBody(context, row); -+ applySelectorTabularNumbers(body, row); -+ // OneKey patch: explicit selector fields preserve original page geometry. -+ element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; -+ if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; -+ if (row.backgroundFullWidth && row.backgroundColor) { -+ const bleed = paddingValues(this.snapshot).horizontal; -+ body.style.position = 'relative'; -+ body.style.overflow = 'visible'; -+ body.style.boxShadow = String(-bleed) + 'px 0 ' + row.backgroundColor + ',' + String(bleed) + 'px 0 ' + row.backgroundColor; -+ } -+ if (row.type === 'identity' && row.height !== undefined) { -+ const title = body.querySelector('.ok-native-list-title'); -+ if (row.presentation === 'accountSelector') { -+ body.style.gap = '12px'; -+ body.style.borderRadius = '12px'; -+ if ('shape' in row.leading && row.leading.shape === 'rounded') { -+ const visual = body.querySelector('.ok-native-list-visual'); -+ if (visual) visual.style.borderRadius = '8px'; -+ } -+ if (title) title.style.lineHeight = '24px'; -+ } -+ if (row.presentation === 'networkSelector') { -+ body.style.borderRadius = '12px'; -+ const visual = body.querySelector('.ok-native-list-visual'); -+ if (visual) { -+ visual.style.width = '32px'; -+ visual.style.height = '32px'; -+ visual.style.flexBasis = '32px'; -+ } -+ if (row.leading.kind === 'network' && !row.leading.image && !row.leading.fallbackIcon && row.leading.fallbackText) { -+ const fallback = visual?.querySelector('.ok-native-list-visual-fallback'); -+ if (fallback) { -+ fallback.style.fontSize = '19px'; -+ fallback.style.lineHeight = '27px'; -+ fallback.style.fontWeight = '600'; -+ fallback.style.color = 'var(--nl-inverse-text)'; -+ } -+ } -+ visual?.querySelectorAll('.ok-native-list-visual-main').forEach(image => { -+ image.style.width = '32px'; -+ image.style.height = '32px'; -+ }); -+ if (title) { -+ title.style.fontSize = '16px'; -+ title.style.lineHeight = '24px'; -+ title.style.fontWeight = '500'; -+ } -+ body.querySelectorAll('.ok-native-list-accessory').forEach(value => { -+ value.style.fontSize = '16px'; -+ value.style.lineHeight = '24px'; -+ value.style.fontWeight = '500'; -+ }); -+ } -+ } -+ element.replaceChildren(body); - } - renderFooter() { - const row = this.snapshot.fixedFooter; - this.invalidateActionAnchorForElement(this.footer); -+ disposeWebImageRetries(this.footer); - this.footer.replaceChildren(); - if (!row) return; - const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -1335,20 +2122,52 @@ export class NativeListWebEngine { - element.style.height = String(estimateWebRowHeight(row, this.snapshot, this.viewport.clientWidth || DEFAULT_VIEWPORT_WIDTH)) + 'px'; - this.footer.appendChild(element); - } -- renderSectionIndex() { -+ sectionIndexVisibleEntryIndices(viewportHeight) { -+ const entryCount = this.sectionIndexEntries.length; -+ if (entryCount <= 1) return entryCount ? [0] : []; -+ const availableHeight = Math.max(0, viewportHeight - SECTION_INDEX_EDGE_PADDING * 2); -+ const maxVisible = Math.max(2, Math.floor(availableHeight / SECTION_INDEX_MIN_LABEL_SPACING) + 1); -+ if (entryCount <= maxVisible) { -+ return Array.from({ -+ length: entryCount -+ }, (_, index) => index); -+ } -+ const result = new Set(); -+ for (let slot = 0; slot < maxVisible; slot += 1) { -+ result.add(Math.round(slot * (entryCount - 1) / (maxVisible - 1))); -+ } -+ return [...result].sort((left, right) => left - right); -+ } -+ renderSectionIndex(viewportHeight) { - this.indexRail.replaceChildren(); - if (!sectionIndexEnabled(this.snapshot)) { -+ this.sectionIndexEntries = []; -+ this.indexRail.hidden = true; -+ return; -+ } -+ this.sectionIndexEntries = this.snapshot.rows.flatMap((row, position) => row.type === 'sectionHeader' && row.indexTitle ? [{ -+ key: row.key, -+ title: row.indexTitle, -+ position -+ }] : []); -+ if (this.sectionIndexEntries.length === 0 || viewportHeight < SECTION_INDEX_MIN_HEIGHT) { - this.indexRail.hidden = true; - return; - } -+ const visibleEntryIndices = this.sectionIndexVisibleEntryIndices(viewportHeight); -+ setData(this.indexRail, 'compact', visibleEntryIndices.length < this.sectionIndexEntries.length); - const fragment = this.document.createDocumentFragment(); -- this.snapshot.rows.forEach((row, index) => { -- if (row.type !== 'sectionHeader' || !row.indexTitle) return; -- const button = createElement(this.document, 'button', 'ok-native-list-index-button', row.indexTitle); -+ visibleEntryIndices.forEach(entryIndex => { -+ const entry = this.sectionIndexEntries[entryIndex]; -+ if (!entry) return; -+ const button = createElement(this.document, 'button', 'ok-native-list-index-button', entry.title); - button.setAttribute('type', 'button'); -- button.setAttribute('aria-label', 'Jump to ' + row.indexTitle); -- setData(button, 'sectionPosition', index); -- setData(button, 'sectionKey', row.key); -+ button.setAttribute('aria-label', 'Jump to ' + entry.title); -+ setData(button, 'sectionEntryIndex', entryIndex); -+ setData(button, 'sectionPosition', entry.position); -+ setData(button, 'sectionKey', entry.key); -+ const progress = this.sectionIndexEntries.length === 1 ? 0.5 : entryIndex / (this.sectionIndexEntries.length - 1); -+ button.style.top = String(SECTION_INDEX_EDGE_PADDING + progress * (viewportHeight - SECTION_INDEX_EDGE_PADDING * 2)) + 'px'; - fragment.appendChild(button); - }); - this.indexRail.appendChild(fragment); -@@ -1357,7 +2176,9 @@ export class NativeListWebEngine { - updateVisibleSelection() { - const update = (element, row) => { - if (!row) return; -- const selected = this.selectedKeys.has(row.key); -+ // OneKey patch: selector adapters mark active rows independently of checkbox selection. -+ // const selected = this.selectedKeys.has(row.key); -+ const selected = row.selected === true || this.selectedKeys.has(row.key); - setData(element, 'nativeListSelected', selected); - element.setAttribute('aria-selected', String(selected)); - element.querySelectorAll('.ok-native-list-checkbox').forEach(checkbox => { -@@ -1396,7 +2217,9 @@ export class NativeListWebEngine { - } - this.checkEndReached(last?.index ?? -1); - this.updateStickyHeader(first?.index ?? -1); -- this.updateSectionIndex(first?.index ?? -1); -+ // OneKey patch: index highlighting follows header positions at scroll boundaries. -+ // this.updateSectionIndex(first?.index ?? -1); -+ this.updateSectionIndex(); - } - updateStickyHeader(firstVisibleIndex) { - if (!this.snapshot.layout.stickyHeaders || this.layout.horizontal || firstVisibleIndex < 0) { -@@ -1407,7 +2230,7 @@ export class NativeListWebEngine { - let index = -1; - for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { - const row = this.rows[cursor]; -- if (row?.type === 'sectionHeader' && row.variant !== 'summary') { -+ if (row?.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary') { - index = cursor; - break; - } -@@ -1433,7 +2256,7 @@ export class NativeListWebEngine { - let nextIndex = -1; - for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { - const candidate = this.rows[cursor]; -- if (candidate?.type === 'sectionHeader' && candidate.variant !== 'summary') { -+ if (candidate?.type === 'sectionHeader' && candidate.sticky !== false && candidate.variant !== 'summary') { - nextIndex = cursor; - break; - } -@@ -1443,10 +2266,15 @@ export class NativeListWebEngine { - this.sticky.style.transform = 'translate3d(0,' + String(translate) + 'px,0)'; - this.updateVisibleSelection(); - } -- updateSectionIndex(firstVisibleIndex) { -+ -+ // private updateSectionIndex(firstVisibleIndex: number) { -+ updateSectionIndex() { - let activeKey; - this.snapshot.rows.forEach((row, index) => { -- if (index <= firstVisibleIndex && row.type === 'sectionHeader' && row.indexTitle) activeKey = row.key; -+ if ( -+ // OneKey patch: a spacer ending exactly at the viewport is not the active section. -+ // index <= firstVisibleIndex && -+ itemStart(this.layout.items[index], this.layout.horizontal) <= this.currentOffset() && row.type === 'sectionHeader' && row.sticky !== false && row.indexTitle) activeKey = row.key; - }); - this.indexRail.querySelectorAll('[data-section-key]').forEach(button => setData(button, 'active', button.dataset.sectionKey === activeKey)); - } -@@ -1539,7 +2367,14 @@ export class NativeListWebEngine { - const bindingEpoch = rowElement.dataset.nativeListBindingEpoch; - if (!source || !bindingEpoch || !rowElement.contains(actionElement)) return undefined; - this.invalidateActionAnchor('rebind'); -- const rect = actionElement.getBoundingClientRect(); -+ const actualRect = actionElement.getBoundingClientRect(); -+ const inset = Number(actionElement.dataset.nativeListAnchorInset ?? 0); -+ const rect = { -+ left: actualRect.left + inset, -+ top: actualRect.top + inset, -+ width: actualRect.width - inset * 2, -+ height: actualRect.height - inset * 2 -+ }; - const token = [this.actionAnchorInstanceId, this.snapshot.generation, ++this.actionAnchorCounter, bindingEpoch].join(':'); - const slotValue = actionElement.dataset.nativeListAnchorSlot; - const direction = this.document.defaultView?.getComputedStyle(actionElement).direction === 'rtl' || actionElement.closest('[dir="rtl"]') ? 'rtl' : 'ltr'; -@@ -1577,7 +2412,9 @@ export class NativeListWebEngine { - }); - } - handleRowPress(row, rowElement, sourceElement = rowElement) { -- if (row.disabled) return; -+ // OneKey patch: missing-address rows keep their create-address accessory interactive. -+ // if (row.disabled) return; -+ if (row.disabled || row.pressDisabled) return; - if (this.snapshot.selection?.rowPressToggles && this.snapshot.selection.mode !== 'none' && isSelectableRow(row)) { - this.activateSelection({ - scope: 'row' -@@ -1597,6 +2434,27 @@ export class NativeListWebEngine { - this.emitRowAction(row, actionKey); - } - } -+ -+ // OneKey patch: hover opens the same anchored help action as native taps. -+ handleTitlePointerOver = event => { -+ const target = event.target; -+ if (!(target instanceof Element)) return; -+ const action = target.closest('[data-native-list-hover-action]'); -+ if (!action || event.relatedTarget instanceof Node && action.contains(event.relatedTarget)) return; -+ const rowElement = action.closest('[data-native-list-row-key]'); -+ const row = this.rowAtElement(rowElement); -+ const memberKey = action.closest('[data-native-list-group-member-key]')?.dataset.nativeListGroupMemberKey; -+ const sourceRow = row?.type === 'walletGroup' ? [row.parent, ...row.children].find(member => member.key === memberKey) ?? row : row; -+ const actionKey = action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; -+ if (sourceRow && !sourceRow.disabled && actionKey) this.emitRowAction(sourceRow, actionKey, action, rowElement ?? undefined); -+ }; -+ handleTitlePointerOut = event => { -+ const target = event.target; -+ if (!(target instanceof Element)) return; -+ const action = target.closest('[data-native-list-hover-action]'); -+ if (!action || event.relatedTarget instanceof Node && action.contains(event.relatedTarget)) return; -+ if (this.actionAnchor?.actionElement === action) this.invalidateActionAnchor('pointerLeave'); -+ }; - handleClick = event => { - if (Date.now() < this.suppressClickUntil) { - event.preventDefault(); -@@ -1627,6 +2485,12 @@ export class NativeListWebEngine { - this.handleRowPress(sourceRow, rowElement ?? undefined, memberElement ?? rowElement ?? undefined); - }; - handleKeyDown = event => { -+ // OneKey patch: non-button title help supports keyboard activation. -+ if ((event.key === 'Enter' || event.key === ' ') && event.target instanceof HTMLElement && event.target.matches('[role="button"][data-native-list-action]')) { -+ event.preventDefault(); -+ event.target.click(); -+ return; -+ } - if (event.key === 'Escape') { - if (this.pointerReorder?.active) { - event.preventDefault(); -@@ -1729,7 +2593,10 @@ export class NativeListWebEngine { - if (this.frameHandle !== undefined || this.destroyed) return; - this.frameHandle = this.requestFrame(() => { - this.frameHandle = undefined; -- if (this.virtualizationEnabled) this.renderWindow();else this.updateVisibleState(); -+ if (this.virtualizationEnabled) this.renderWindow();else { -+ this.updateAvatarWindow(); -+ this.updateVisibleState(); -+ } - }); - } - requestFrame(callback) { -@@ -1740,13 +2607,20 @@ export class NativeListWebEngine { - const view = this.document.defaultView; - if (view?.cancelAnimationFrame) view.cancelAnimationFrame(handle);else view?.clearTimeout(handle); - } -- selectIndexPosition(position, title) { -+ selectIndexPosition(position, title, previewClientY) { - this.scrollToIndex(position, { - animated: false, - alignment: 'start', - viewPosition: 0, - viewOffset: 0 - }); -+ const frame = this.viewportFrame.getBoundingClientRect(); -+ if (previewClientY !== undefined && frame.height > 0) { -+ const previewY = Math.min(frame.height - 24, Math.max(24, previewClientY - frame.top)); -+ this.indexPreview.style.top = String(previewY) + 'px'; -+ } else { -+ this.indexPreview.style.top = '50%'; -+ } - this.indexPreview.textContent = title; - setData(this.indexPreview, 'visible', true); - if (this.previewTimer !== undefined) this.document.defaultView?.clearTimeout(this.previewTimer); -@@ -1754,24 +2628,40 @@ export class NativeListWebEngine { - setData(this.indexPreview, 'visible', false); - }, 180); - } -- indexButtonAtEvent(event) { -- const direct = event.target?.closest('[data-section-position]'); -- if (direct) return direct; -- return this.document.elementFromPoint(event.clientX, event.clientY)?.closest('[data-section-position]') ?? undefined; -+ sectionIndexEntryAtEvent(event) { -+ const rail = this.indexRail.getBoundingClientRect(); -+ if (this.sectionIndexEntries.length === 0 || rail.height <= 0) { -+ return undefined; -+ } -+ const availableHeight = Math.max(1, rail.height - SECTION_INDEX_EDGE_PADDING * 2); -+ const progress = Math.min(1, Math.max(0, (event.clientY - rail.top - SECTION_INDEX_EDGE_PADDING) / availableHeight)); -+ const entryIndex = Math.round(progress * (this.sectionIndexEntries.length - 1)); -+ const entry = this.sectionIndexEntries[entryIndex]; -+ return entry ? { -+ entry, -+ previewClientY: event.clientY -+ } : undefined; - } - handleIndexPointer = event => { - if (event.type === 'pointermove' && event.buttons === 0) return; -- const button = this.indexButtonAtEvent(event); -- if (!button) return; -+ const selection = this.sectionIndexEntryAtEvent(event); -+ if (!selection) return; - event.preventDefault(); -- this.selectIndexPosition(Number(button.dataset.sectionPosition), button.textContent ?? ''); -+ if (event.type === 'pointerdown') { -+ this.indexRail.setPointerCapture?.(event.pointerId); -+ } -+ this.selectIndexPosition(selection.entry.position, selection.entry.title, selection.previewClientY); - }; - handleIndexClick = event => { -+ if ('detail' in event && event.detail > 0) return; - const target = event.target; - if (!(target instanceof Element)) return; -- const button = target.closest('[data-section-position]'); -+ const button = target.closest('[data-section-entry-index]'); - if (!button) return; -- this.selectIndexPosition(Number(button.dataset.sectionPosition), button.textContent ?? ''); -+ const entry = this.sectionIndexEntries[Number(button.dataset.sectionEntryIndex)]; -+ if (!entry) return; -+ const rect = button.getBoundingClientRect(); -+ this.selectIndexPosition(entry.position, entry.title, rect.top + rect.height / 2); - }; - handlePullStart = event => { - if (this.pointerReorder?.pointerId === event.pointerId || !this.snapshot.capabilities?.pullToRefresh || this.viewport.scrollTop > 0 || event.pointerType !== 'touch' && event.pointerType !== 'pen') return; -@@ -1816,7 +2706,14 @@ export class NativeListWebEngine { - const index = Number(rowElement?.dataset.nativeListRowIndex); - const row = this.rows[index]; - if (!row || !this.isReorderable(row)) return; -- if (row.type === 'walletGroup' && target.closest('[data-native-list-group-parent]')?.dataset.nativeListGroupParent !== 'true') return; -+ // OneKey patch: a child drag reorders its parent wallet group as one item. -+ // if ( -+ // row.type === 'walletGroup' && -+ // target.closest('[data-native-list-group-parent]')?.dataset -+ // .nativeListGroupParent !== 'true' -+ // ) -+ // return; -+ - const view = this.document.defaultView; - const state = { - pointerId: event.pointerId, -@@ -1832,9 +2729,8 @@ export class NativeListWebEngine { - active: false - }; - this.pointerReorder = state; -- if (state.pointerType === 'mouse') { -- this.captureReorderPointer(state); -- } else { -+ // OneKey patch: normal wallet taps retain their target until a drag is activated. -+ if (state.pointerType !== 'mouse') { - state.longPressTimer = view?.setTimeout(() => this.activatePointerReorder(state), REORDER_TOUCH_LONG_PRESS_MS); - } - }; -@@ -1935,6 +2831,20 @@ export class NativeListWebEngine { - state.previewOffsetX = state.startX - rect.left; - state.previewOffsetY = Math.min(previewHeight, Math.max(0, state.startY - rect.top)); - this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); -+ // Cloned previews need their own lease when a source row is recycled during dragging. -+ const originals = previewRow.querySelectorAll('img'); -+ this.reorderPreview.querySelectorAll('img').forEach((image, index) => { -+ const original = originals.item(index); -+ const avatar = original ? webAvatarSources.get(original) : undefined; -+ if (!avatar) return; -+ const paint = image.previousElementSibling; -+ if (paint?.classList.contains('ok-native-list-selector-image-background')) { -+ image.addEventListener('load', () => { -+ paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; -+ }); -+ } -+ configureWebAvatar(image, avatar.source, avatar.uri); -+ }); - const sourceRow = state.workingRows[state.currentIndex]; - const badgeText = sourceRow ? webWalletGroupReorderBadge(sourceRow) : undefined; - if (badgeText) { -@@ -1970,6 +2880,7 @@ export class NativeListWebEngine { - } - clearReorderPreviewVisual() { - this.reorderPreview.hidden = true; -+ disposeWebImageRetries(this.reorderPreview); - this.reorderPreview.replaceChildren(); - this.reorderPreview.style.removeProperty('transform'); - this.reorderPreview.style.removeProperty('transition'); -@@ -2235,4 +3146,3 @@ export class NativeListWebEngine { - }); - } - } --//# sourceMappingURL=NativeListWebEngine.js.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts -index 2d4d817..4226865 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/NativeList.d.ts -@@ -15,4 +15,3 @@ export declare const NativeList: React.ForwardRefExoticComponent>>; --//# sourceMappingURL=NativeList.d.ts.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/avatarPrefetch.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/avatarPrefetch.d.ts -new file mode 100644 -index 0000000..261ab3c ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/avatarPrefetch.d.ts -@@ -0,0 +1,27 @@ -+import type { ImageSource, RowModel, RowPatch } from './models'; -+export type AvatarCandidate = Readonly<{ -+ source: ImageSource; -+ priority: 0 | 1 | 2; -+}>; -+export declare function avatarPrefetchWindow(rows: readonly RowModel[], first: number, last: number, direction: number, resolveRow?: (row: RowModel) => RowModel): AvatarCandidate[]; -+export declare class NativeAvatarPrefetchModel { -+ private rows; -+ private rowByKey; -+ private readonly imageRows; -+ constructor(rows: readonly RowModel[]); -+ replaceSnapshot(rows: readonly RowModel[]): void; -+ applyPatches(patches: readonly RowPatch[], dispatch?: () => void): boolean; -+ window(first: number, last: number, direction: number): AvatarCandidate[]; -+} -+export declare class NativeAvatarPrefetchQueue { -+ private readonly preload; -+ private pending; -+ private activeUri; -+ private timer; -+ private disposed; -+ private readonly recent; -+ constructor(preload: (source: ImageSource) => Promise); -+ update(candidates: readonly AvatarCandidate[]): void; -+ dispose(): void; -+ private schedule; -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts -index 60c6a5c..69829a5 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/models.d.ts -@@ -20,13 +20,43 @@ export type ImageSource = Readonly<{ - optimizeTos?: boolean; - overscan?: number; - loadingStrategy?: ImageLoadingStrategy; -+ fallbackUri?: string; -+ retryTimes?: number; - }>; - export type BadgeModel = Readonly<{ - key: string; - text: string; - tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; - }>; -+export type SelectorTextSegment = Readonly<{ -+ text: string; -+ textSegments?: readonly ValueTextSegment[]; -+ tone?: TextTone | 'disabled' | 'caution'; -+ separatorBefore?: boolean; -+}>; -+export type VisualOverlay = Readonly<{ -+ position: 'topLeft' | 'bottomRight'; -+ size?: number; -+ width?: number; -+ height?: number; -+ padding?: number; -+ offset?: number; -+ offsetX?: number; -+ offsetY?: number; -+ image?: ImageSource; -+ name?: string; -+ text?: string; -+ tintColor?: string; -+ backgroundColor?: string; -+}>; - type VisualWithImage = Readonly<{ -+ fallbackIcon?: Readonly<{ -+ name: string; -+ tintColor?: string; -+ }>; -+ overlays?: readonly VisualOverlay[]; -+ borderStyle?: 'dashed'; -+ borderColor?: string; - image?: ImageSource; - fallbackText?: string; - backgroundColor?: string; -@@ -67,10 +97,15 @@ export type SelectionTarget = Readonly<{ - }> | Readonly<{ - scope: 'list'; - }>; -+export type ValueTextSegment = Readonly<{ -+ text: string; -+ style?: 'subscript'; -+}>; - export type TrailingAccessory = Readonly<{ - kind: 'value'; - text: string; - secondary?: boolean; -+ textSegments?: readonly ValueTextSegment[]; - }> | Readonly<{ - kind: 'valuePair'; - primary: string; -@@ -109,6 +144,9 @@ export type TrailingAccessory = Readonly<{ - tintColor?: string; - disabled?: boolean; - actionKey?: string; -+ testID?: string; -+ hoverActionKey?: string; -+ accessibilityLabel?: string; - }> | Readonly<{ - kind: 'spinner'; - }> | Readonly<{ -@@ -122,6 +160,13 @@ export type FooterAction = Readonly<{ - disabled?: boolean; - }>; - export type RowBase = Readonly<{ -+ height?: number; -+ heightRounding?: 'floor' | 'nearest'; -+ testID?: string; -+ opacity?: number; -+ backgroundColor?: string; -+ backgroundFullWidth?: boolean; -+ pressDisabled?: boolean; - key: string; - revision?: number; - sectionKey?: string; -@@ -136,6 +181,13 @@ export type RowBase = Readonly<{ - }>; - export type IdentityRow = RowBase & Readonly<{ - type: 'identity'; -+ titleMatch?: readonly Readonly<{ -+ start: number; -+ end: number; -+ }>[]; -+ titleActionKey?: string; -+ titleActionOnHover?: boolean; -+ subtitleSegments?: readonly SelectorTextSegment[]; - presentation?: 'walletSidebar' | 'accountSelector' | 'networkSelector'; - leading: LeadingVisual; - leadingAction?: Extract; - export type SectionHeaderRow = RowBase & Readonly<{ - type: 'sectionHeader'; -+ sticky?: boolean; -+ valueActionTestID?: string; -+ valueSegments?: readonly ValueTextSegment[]; -+ titleActionKey?: string; -+ titleActionOnHover?: boolean; - sectionKey: string; - presentation?: 'networkSelector'; - indexTitle?: string; -@@ -284,6 +341,12 @@ export type SystemRow = RowBase & (Readonly<{ - variant: 'retry'; - message: string; - actionKey: string; -+}> | Readonly<{ -+ type: 'system'; -+ variant: 'warning'; -+ title: string; -+ message: string; -+ borderColor?: string; - }> | Readonly<{ - type: 'system'; - variant: 'noMatch'; -@@ -299,6 +362,10 @@ export type SystemRow = RowBase & (Readonly<{ - }>); - export type RowModel = IdentityRow | WalletGroupRow | RailRow | ActivityRow | MessageRow | DataRow | MediaTileRow | MetricCardRow | SectionHeaderRow | ActionRow | SystemRow; - export type NativeListTheme = Readonly<{ -+ checkboxBackground?: string; -+ checkboxBorder?: string; -+ checkboxIcon?: string; -+ cautionBackground?: string; - background: string; - rowBackground: string; - rowSelectedBackground: string; -@@ -318,6 +385,7 @@ export type NativeListTheme = Readonly<{ - inverseBackground?: string; - inverseText?: string; - info?: string; -+ caution?: string; - }>; - export type SectionIndexConfig = Readonly<{ - enabled: boolean; -@@ -355,11 +423,11 @@ export type NativeListSnapshot = Readonly<{ - fixedFooter?: ActionRow | SystemRow; - theme?: NativeListTheme; - }>; --type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator'; -+type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled' | 'accessibilityLabel'; - export type RowPatch = Readonly<{ - type: 'identity'; - key: string; -- changes: Partial>; -+ changes: Partial>; - }> | Readonly<{ - type: 'rail'; - key: string; -@@ -387,7 +455,7 @@ export type RowPatch = Readonly<{ - }> | Readonly<{ - type: 'sectionHeader'; - key: string; -- changes: Partial>; -+ changes: Partial>; - }> | Readonly<{ - type: 'action'; - key: string; -@@ -424,7 +492,7 @@ export type RowActionEvent = Readonly<{ - sectionKey?: string; - anchor?: NativeListActionAnchor; - }>; --export type ActionAnchorInvalidationReason = 'scroll' | 'rebind' | 'snapshot' | 'layout' | 'destroy'; -+export type ActionAnchorInvalidationReason = 'pointerLeave' | 'scroll' | 'rebind' | 'snapshot' | 'layout' | 'destroy'; - export type ActionAnchorInvalidatedEvent = Readonly<{ - token: string; - reason: ActionAnchorInvalidationReason; -@@ -453,4 +521,3 @@ export type VisibleRangeChangedEvent = Readonly<{ - lastIndex: number; - }>; - export {}; --//# sourceMappingURL=models.d.ts.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts -new file mode 100644 -index 0000000..6c2ee1d ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebAvatarCache.d.ts -@@ -0,0 +1,5 @@ -+export type AvatarLease = (() => void) & { -+ setPriority: (priority: number) => void; -+}; -+export declare function canonicalNativeListAvatarUri(uri: string): string | undefined; -+export declare function acquireNativeListAvatar(document: Document, uri: string, resolve: (url: string) => void, reject: () => void, priority?: number): AvatarLease; -diff --git a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts -index 646b4b8..85d1bf0 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts -+++ b/node_modules/@onekeyfe/react-native-native-list/lib/typescript/src/web/NativeListWebEngine.d.ts -@@ -77,6 +77,9 @@ export declare class NativeListWebEngine { - private resizeObserver; - private pendingScroll; - private lastVisibleSignature; -+ private readonly avatarLeases; -+ private avatarOffset; -+ private avatarDirection; - private reachedGeneration; - private stickyKey; - private previewTimer; -@@ -100,6 +103,7 @@ export declare class NativeListWebEngine { - private lastViewportWidth; - private lastViewportHeight; - private destroyed; -+ private measuredWarningHeights; - constructor(host: HTMLElement, snapshot: NativeListSnapshot, callbacks: NativeListWebCallbacks, virtualizationEnabled?: boolean); - updateCallbacks(callbacks: NativeListWebCallbacks): void; - setVirtualizationEnabled(enabled: boolean): void; -@@ -117,6 +121,7 @@ export declare class NativeListWebEngine { - private setSnapshot; - private applyTheme; - private recomputeLayout; -+ private updateAvatarWindow; - private renderWindow; - private positionElement; - private renderElement; -@@ -145,6 +150,8 @@ export declare class NativeListWebEngine { - private invalidateActionAnchor; - private emitActionAnchorInvalidated; - private handleRowPress; -+ private handleTitlePointerOver; -+ private handleTitlePointerOut; - private handleClick; - private handleKeyDown; - private startKeyboardReorder; -@@ -200,4 +207,3 @@ export declare class NativeListWebEngine { - private remapMountedRows; - private updateReorderVisualState; - } --//# sourceMappingURL=NativeListWebEngine.d.ts.map -\ No newline at end of file -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx -index 8dc8672..7a67a3e 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx -+++ b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.tsx -@@ -1,4 +1,6 @@ --import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; -+import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'; -+import { OneKeyImageCache, OneKeyImageCachePolicy } from '@onekeyfe/react-native-image'; -+import { NativeAvatarPrefetchModel, NativeAvatarPrefetchQueue } from './avatarPrefetch'; - import { callback, getHostComponent } from 'react-native-nitro-modules'; - import type { - NativeListMethods, -@@ -93,6 +95,40 @@ export const NativeList = forwardRef( - }; - const snapshotRef = useRef(snapshot); - snapshotRef.current = snapshot; -+ // OneKey patch: range events already cross the bridge only when row indices -+ // change. Prefetch is optional work; scrolling and visible loads stay native. -+ const avatarQueueRef = useRef(null); -+ const avatarRangeRef = useRef<{ first: number; last: number; direction: number } | undefined>(undefined); -+ // OneKey patch: a changed prop is a complete snapshot; unrelated renders must -+ // not erase image patches already dispatched through the imperative handle. -+ // const avatarPrefetchPaused = useRef(false); -+ const avatarModelRef = useRef(null); -+ const avatarPropSnapshotRef = useRef(snapshot); -+ const avatarLifecycle = useRef<'pending' | 'mounted' | 'unmounted'>('pending'); -+ if (!avatarModelRef.current) avatarModelRef.current = new NativeAvatarPrefetchModel(snapshot.rows); -+ else if (avatarPropSnapshotRef.current !== snapshot) avatarModelRef.current.replaceSnapshot(snapshot.rows); -+ avatarPropSnapshotRef.current = snapshot; -+ const updateAvatarPrefetch = () => { -+ const range = avatarRangeRef.current; -+ if (!range) return; -+ avatarQueueRef.current?.update(avatarModelRef.current?.window(range.first, range.last, range.direction) ?? []); -+ }; -+ const updateAvatarPrefetchRef = useRef(updateAvatarPrefetch); -+ updateAvatarPrefetchRef.current = updateAvatarPrefetch; -+ useEffect(() => { -+ avatarLifecycle.current = 'mounted'; -+ const queue = new NativeAvatarPrefetchQueue((source) => OneKeyImageCache.preload([{ -+ uri: source.uri, headers: source.headers, resizeWidth: source.width, -+ resizeHeight: source.height, optimizeTos: false, -+ cachePolicy: source.cachePolicy === 'memory' ? OneKeyImageCachePolicy.MEMORY : source.cachePolicy === 'disk' ? OneKeyImageCachePolicy.DISK : OneKeyImageCachePolicy.MEMORY_DISK, -+ }])); -+ avatarQueueRef.current = queue; -+ updateAvatarPrefetchRef.current(); -+ return () => { queue.dispose(); avatarQueueRef.current = null; avatarLifecycle.current = 'unmounted'; }; -+ }, []); -+ useEffect(() => { -+ updateAvatarPrefetchRef.current(); -+ }, [snapshot]); - const initialScrollRef = useRef< - | Readonly<{ - index?: number; -@@ -180,11 +216,23 @@ export const NativeList = forwardRef( - useImperativeHandle(forwardedRef, () => ({ - applySnapshot(nextSnapshot) { - snapshotRef.current = nextSnapshot; -- nativeRef.current?.applySnapshot(serializeSnapshot(nextSnapshot)); -+ const native = nativeRef.current; -+ if (native) { -+ native.applySnapshot(serializeSnapshot(nextSnapshot)); -+ if (avatarLifecycle.current !== 'unmounted') { -+ avatarModelRef.current?.replaceSnapshot(nextSnapshot.rows); -+ updateAvatarPrefetchRef.current(); -+ } -+ } - }, - applyPatches(patches) { -- if (patches.length > 0) -- nativeRef.current?.applyPatches(serializePatches(patches)); -+ // OneKey patch: image updates replace pending prefetch instead of disabling it. -+ // if (imageFieldsChanged) { avatarPrefetchPaused.current = true; avatarQueueRef.current?.update([]); } -+ if (!patches.length) return; -+ const native = nativeRef.current; -+ const dispatch = native ? () => native.applyPatches(serializePatches(patches)) : undefined; -+ if (avatarLifecycle.current === 'unmounted') { dispatch?.(); return; } -+ if (avatarModelRef.current?.applyPatches(patches, dispatch)) updateAvatarPrefetchRef.current(); - }, - reconcileSelection(selectedKeys) { - nativeRef.current?.reconcileSelection(JSON.stringify(selectedKeys)); -@@ -279,9 +327,14 @@ export const NativeList = forwardRef( - ); - }), - onVisibleRangeChanged: callback((payloadJson: string) => { -- callbacksRef.current.onVisibleRangeChanged?.( -- parsePayload(payloadJson) -- ); -+ const payload = parsePayload(payloadJson); -+ const previous = avatarRangeRef.current; -+ avatarRangeRef.current = { -+ first: payload.firstIndex, last: payload.lastIndex, -+ direction: previous && payload.firstIndex !== previous.first ? Math.sign(payload.firstIndex - previous.first) : previous?.direction ?? 1, -+ }; -+ updateAvatarPrefetchRef.current(); -+ callbacksRef.current.onVisibleRangeChanged?.(payload); - }), - }), - [] -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx -index e4cca82..e15f896 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx -+++ b/node_modules/@onekeyfe/react-native-native-list/src/NativeList.web.tsx -@@ -9,7 +9,7 @@ import React, { - } from 'react'; - import { View } from 'react-native'; - import type { NativeListProps, NativeListRef } from './NativeList.types'; --import type { NativeListSnapshot } from './models'; -+import type { NativeListSnapshot, RowPatch } from './models'; - import { - normalizeIndexScroll, - normalizeKeyScroll, -@@ -69,7 +69,20 @@ export const NativeList = forwardRef( - [snapshot] - ); - const snapshotRef = useRef(validatedSnapshot); -- snapshotRef.current = validatedSnapshot; -+ const snapshotPropRef = useRef(validatedSnapshot); -+ // An imperative snapshot survives host mounting until the snapshot prop changes. -+ if (snapshotPropRef.current !== validatedSnapshot) { -+ snapshotPropRef.current = validatedSnapshot; -+ snapshotRef.current = validatedSnapshot; -+ } -+ // OneKey patch: React refs can be ready before the DOM engine is mounted. -+ const pendingPatchesRef = useRef< -+ | { -+ snapshot: NativeListSnapshot; -+ batches: Array; -+ } -+ | undefined -+ >(undefined); - const appliedSnapshotRef = useRef( - undefined - ); -@@ -125,6 +138,10 @@ export const NativeList = forwardRef( - ); - engineRef.current = engine; - appliedSnapshotRef.current = snapshotRef.current; -+ const pending = pendingPatchesRef.current; -+ pendingPatchesRef.current = undefined; -+ if (pending?.snapshot === snapshotRef.current) -+ pending.batches.forEach((patches) => engine.applyPatches(patches)); - const initial = initialScrollRef.current; - if (initial && !didApplyInitialScroll.current) { - didApplyInitialScroll.current = true; -@@ -166,6 +183,7 @@ export const NativeList = forwardRef( - useImperativeHandle(forwardedRef, () => ({ - applySnapshot(nextSnapshot) { - const next = validateSnapshot(nextSnapshot); -+ pendingPatchesRef.current = undefined; - snapshotRef.current = next; - appliedSnapshotRef.current = next; - engineRef.current?.applySnapshot(next); -@@ -173,7 +191,16 @@ export const NativeList = forwardRef( - applyPatches(patches) { - if (patches.length > 0) { - serializePatches(patches); -- engineRef.current?.applyPatches(patches); -+ // engineRef.current?.applyPatches(patches); -+ if (engineRef.current) engineRef.current.applyPatches(patches); -+ else { -+ if (pendingPatchesRef.current?.snapshot !== snapshotRef.current) -+ pendingPatchesRef.current = { -+ snapshot: snapshotRef.current, -+ batches: [], -+ }; -+ pendingPatchesRef.current.batches.push(patches); -+ } - } - }, - reconcileSelection(selectedKeys) { -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/avatar-scheduling.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/avatar-scheduling.cjs -new file mode 100644 -index 0000000..3510bc4 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/avatar-scheduling.cjs -@@ -0,0 +1,205 @@ -+// OneKey patch: exercise actual worker/broker code with controlled I/O completion. -+const assert = require('node:assert/strict'); -+const fs = require('node:fs'); -+const path = require('node:path'); -+const vm = require('node:vm'); -+const { test } = require('node:test'); -+const ts = require('typescript'); -+const { IDBFactory } = require('fake-indexeddb'); -+const prefix = 'onekey-avatar://blockie/v1/'; -+const sourceRoot = path.resolve(__dirname, '..'); -+const tick = () => new Promise((resolve) => setTimeout(resolve, 1)); -+async function until(predicate) { for (let i = 0; i < 3000; i++) { if (predicate()) return; await tick(); } throw new Error('Timed out'); } -+function worker({ db = new IDBFactory(), size = 100 } = {}) { -+ const messages = [], generated = [], urls = new Map(), revoked = []; -+ const context = { -+ indexedDB: db, Blob, setTimeout, clearTimeout, -+ createImageBitmap: async () => ({ width: 128, height: 128, close() {} }), -+ URL: { createObjectURL(blob) { const url = `blob:${messages.length}:${urls.size}`; urls.set(url, blob); return url; }, revokeObjectURL(url) { revoked.push(url); urls.delete(url); } }, -+ postMessage(message) { messages.push(message); }, onmessage: undefined, -+ generate: async (seed) => { generated.push(seed); return new Blob([new Uint8Array(size)], { type: 'image/png' }); }, -+ }; -+ vm.createContext(context); -+ vm.runInContext(fs.readFileSync(path.join(sourceRoot, 'web/NativeListAvatarWorker.js'), 'utf8'), context); -+ vm.runInContext('generateBlob = generate', context); -+ const read = (expression) => vm.runInContext(expression, context); -+ const send = (type, id, seed = String(id), priority = 2) => context.onmessage({ data: { type, id, uri: prefix + seed, priority } }); -+ return { context, messages, generated, urls, revoked, read, send, db }; -+} -+function loadTypeScript(name, globals = {}) { -+ const source = fs.readFileSync(path.join(sourceRoot, name), 'utf8').replaceAll('import.meta.url', "'https://unit.test/module.js'"); -+ const result = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }); -+ const exports = {}; -+ const context = vm.createContext({ exports, setTimeout, clearTimeout, ...globals }); -+ vm.runInContext(result.outputText, context); -+ return exports; -+} -+test('display and subsequent load slots do not await disk; pending bytes deduplicate reacquire', async () => { -+ const w = worker(); -+ let finishWrite; -+ w.context.blockedWrite = () => new Promise((resolve) => { finishWrite = resolve; }); -+ w.read('readDisk = async () => undefined; writeDisk = blockedWrite'); -+ w.send('acquire', 1, 'a'); -+ await until(() => w.messages.length === 1); -+ assert.equal(w.read('activeLoads'), 0); -+ assert.equal(w.read('pendingBlobs.size'), 1); -+ w.send('release', 1, 'a'); -+ w.send('acquire', 2, 'a'); -+ await until(() => w.messages.length === 2); -+ assert.deepEqual(w.generated, ['a']); -+ w.send('acquire', 3, 'b'); -+ await until(() => w.messages.length === 3); -+ assert.deepEqual(w.generated, ['a', 'b']); -+ // OneKey patch: background persistence starts after the shared quiet window. -+ await until(() => finishWrite); -+ // Quota/write failure remains best-effort, never a late display error. -+ w.read('writeDisk = async () => { throw new Error("quota"); }'); -+ finishWrite(); -+ await until(() => w.read('pendingBlobs.size') === 0); -+ assert(w.messages.every((message) => message.type === 'resolved')); -+}); -+test('background persistence is bounded without blocking visible results', async () => { -+ const w = worker({ size: 200_000 }); -+ let release; -+ w.context.blockedWrite = () => new Promise((resolve) => { release = resolve; }); -+ w.read('readDisk = async () => undefined; writeDisk = blockedWrite'); -+ for (let index = 0; index < 80; index++) { -+ w.send('acquire', index); -+ await until(() => w.messages.length === index + 1); -+ w.send('release', index); -+ } -+ assert(w.read('pendingBlobs.size') <= 32); -+ assert(w.read('pendingBytes') <= 4 * 1024 * 1024); -+ assert.equal(w.read('activeLoads'), 0); -+ assert.equal(w.urls.size, 0); -+ await until(() => release); -+ w.read('writeDisk = async () => {}'); -+ release(); -+ await until(() => w.read('pendingBytes') === 0); -+}); -+test('queued visible promotion wins over FIFO prefetch without duplicate generation', async () => { -+ const w = worker(); -+ const reads = []; -+ w.context.blockedRead = () => new Promise((resolve) => reads.push(resolve)); -+ w.read('readDisk = blockedRead; writeDisk = async () => {}'); -+ w.send('acquire', 1, 'active-a'); w.send('acquire', 2, 'active-b'); -+ w.send('acquire', 3, 'earlier-prefetch'); w.send('acquire', 4, 'now-visible'); -+ w.send('priority', 4, 'now-visible', 0); -+ reads[0](); -+ await until(() => reads.length === 3); -+ assert.equal(w.read('jobs.get("' + prefix + 'now-visible").active'), true); -+ assert.equal(w.read('jobs.get("' + prefix + 'earlier-prefetch").active'), false); -+ w.send('release', 3); // Drop stale opposite-direction work before it starts. -+ reads[1](); reads[2](); -+ await until(() => w.read('activeLoads') === 0); -+ assert(!w.generated.includes('earlier-prefetch')); -+ assert.equal(w.generated.filter((seed) => seed === 'now-visible').length, 1); -+}); -+test('last-lease cancellation during generation never publishes or leaks a Blob URL', async () => { -+ const w = worker(); let finish; -+ w.context.slowGenerate = () => new Promise((resolve) => { finish = resolve; }); -+ w.read('readDisk = async () => undefined; generateBlob = slowGenerate; writeDisk = async () => {}'); -+ w.send('acquire', 1); await until(() => finish); -+ w.send('release', 1); finish(new Blob(['png'], { type: 'image/png' })); -+ await until(() => w.read('activeLoads') === 0); -+ assert.equal(w.messages.length, 0); assert.equal(w.urls.size, 0); -+}); -+test('disk reopen avoids generation; readonly hits defer touch; corrupt cache regenerates', async () => { -+ const w = worker(); w.send('acquire', 1, 'cached'); -+ await until(() => w.messages.length === 1 && w.read('pendingBlobs.size') === 0); -+ const reopened = worker({ db: w.db }); reopened.send('acquire', 1, 'cached'); -+ await until(() => reopened.messages.length === 1); -+ assert.equal(reopened.generated.length, 0); -+ assert.equal(reopened.read('touches.size'), 1); -+ const corrupt = worker({ db: w.db }); -+ corrupt.context.createImageBitmap = async () => { throw new Error('corrupt'); }; -+ corrupt.send('acquire', 1, 'cached'); -+ await until(() => corrupt.messages.length === 1); -+ assert.deepEqual(corrupt.generated, ['cached']); -+ const denied = worker({ db: { open() { throw new Error('denied'); } } }); -+ denied.send('acquire', 1); await until(() => denied.messages.length === 1); -+ assert.equal(denied.generated.length, 1); -+}); -+test('broker promotes a shared queued URI and release restores remaining priority', () => { -+ let instance; -+ class FakeWorker { -+ constructor() { instance = this; this.messages = []; } -+ addEventListener() {} -+ postMessage(message) { this.messages.push(message); } -+ } -+ const api = loadTypeScript('web/NativeListWebAvatarCache.ts', { Worker: FakeWorker, URL, queueMicrotask }); -+ const document = {}; -+ const prefetch = api.acquireNativeListAvatar(document, prefix + 'a', () => {}, () => {}, 2); -+ const visible = api.acquireNativeListAvatar(document, prefix + 'a', () => {}, () => {}, 0); -+ assert.equal(instance.messages.filter((m) => m.type === 'acquire').length, 1); -+ assert.equal(instance.messages.at(-1).priority, 0); -+ visible(); assert.equal(instance.messages.at(-1).priority, 2); -+ prefetch.setPriority(1); assert.equal(instance.messages.at(-1).priority, 1); -+ prefetch(); assert(instance.messages.some((m) => m.type === 'release')); -+}); -+test('window prioritizes visibility, reverses ahead work and bounds large groups', () => { -+ const { avatarPrefetchWindow } = loadTypeScript('avatarPrefetch.ts'); -+ const row = (i) => ({ type: 'identity', key: String(i), title: String(i), leading: { kind: 'account', image: { uri: prefix + i, width: 32, height: 32 } } }); -+ const rows = Array.from({ length: 1000 }, (_, i) => row(i)); -+ const forward = avatarPrefetchWindow(rows, 30, 39, 1); -+ assert.equal(forward[0].source.uri, prefix + '30'); -+ assert.equal(forward.find((item) => item.priority === 1).source.uri, prefix + '40'); -+ const reverse = avatarPrefetchWindow(rows, 30, 39, -1); -+ assert.equal(reverse.find((item) => item.priority === 1).source.uri, prefix + '29'); -+ assert(forward.length <= 64); assert(reverse.length <= 64); -+ const large = avatarPrefetchWindow([{ type: 'walletGroup', key: '0', parent: row(0), children: rows.slice(1) }], 0, 0, 1); -+ assert(large.length <= 64); -+ assert.equal(avatarPrefetchWindow(rows, -1, -1, 1).length, 0); -+}); -+test('native short batches replace stale pending direction, deduplicate and stop on unmount', async () => { -+ const { NativeAvatarPrefetchQueue } = loadTypeScript('avatarPrefetch.ts'); -+ const started = [], finish = []; -+ const queue = new NativeAvatarPrefetchQueue((source) => { started.push(source.uri); return new Promise((resolve) => finish.push(resolve)); }); -+ const candidates = (...values) => values.map((value) => ({ source: { uri: prefix + value, width: 32, height: 32 }, priority: 1 })); -+ queue.update(candidates('a', 'b', 'c')); await until(() => started.length === 1); -+ queue.update(candidates('x', 'y')); finish[0](true); -+ await until(() => started.length === 2); -+ assert.deepEqual(started, [prefix + 'a', prefix + 'x']); -+ queue.update(candidates('a', 'x', 'z')); queue.dispose(); finish[1](true); -+ await new Promise((resolve) => setTimeout(resolve, 25)); -+ assert.equal(started.length, 2); -+}); -+test('over-budget actual IDB write rolls back and unblocks a subsequent display miss', async () => { -+ const w = worker(); -+ w.send('acquire', 1, 'prime'); -+ await until(() => w.messages.length === 1 && w.read('pendingBlobs.size') === 0); -+ const database = await w.read('openDatabase()'); -+ const originalTransaction = database.transaction.bind(database); -+ let blocked = false, aborted = false; -+ database.transaction = (...args) => { -+ const transaction = originalTransaction(...args); -+ if (args[1] === 'readwrite' && Array.isArray(args[0]) && !blocked) { -+ blocked = true; -+ // Keep the real readwrite transaction alive until the production deadline -+ // aborts it. A later readonly transaction really shares this store lock. -+ let alive = true; -+ transaction.addEventListener('abort', () => { alive = false; aborted = true; }); -+ const store = transaction.objectStore('images'); -+ const keepAlive = () => { -+ if (!alive) return; -+ try { const request = store.get('lock'); request.onsuccess = keepAlive; } catch {} -+ }; -+ keepAlive(); -+ } -+ return transaction; -+ }; -+ w.send('acquire', 2, 'blocked-write'); -+ await until(() => blocked && w.messages.length === 2); -+ w.send('acquire', 3, 'subsequent-miss'); -+ await until(() => w.messages.length === 3 && aborted); -+ assert.equal(w.messages[2].type, 'resolved'); -+ assert(w.generated.includes('subsequent-miss')); -+ await until(() => w.read('pendingBlobs.size') === 0); -+ // The aborted transaction must not leave a half-written row/metadata counter. -+ const check = originalTransaction(['images', 'metadata'], 'readonly'); -+ const failed = check.objectStore('images').get(prefix + 'blocked-write'); -+ const counter = check.objectStore('metadata').get('size'); -+ await new Promise((resolve) => { check.oncomplete = resolve; }); -+ assert.equal(failed.result, undefined); -+ assert.equal(counter.result.count, 2); -+}); -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs -new file mode 100644 -index 0000000..820bffd ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/src/__tests__/selector-parity.cjs -@@ -0,0 +1,560 @@ -+// OneKey patch: focused regression checks for the serialized selector adapter contract. -+const assert = require('node:assert/strict'); -+const fs = require('node:fs'); -+const path = require('node:path'); -+const { test } = require('node:test'); -+const ts = require('typescript'); -+const { JSDOM } = require('jsdom'); -+const packageRoot = path.resolve(__dirname, '../..'); -+const originalLoader = require.extensions['.ts']; -+require.extensions['.ts'] = (module, filename) => { -+ if (!filename.startsWith(packageRoot)) return originalLoader?.(module, filename); -+ const result = ts.transpileModule(fs.readFileSync(filename, 'utf8').replaceAll('import.meta.url', "'https://unit.test/NativeList.js'"), { -+ compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }, -+ }); -+ module._compile(result.outputText, filename); -+}; -+const { validateSnapshot, serializePatches } = require('../validation.ts'); -+const { NativeListWebEngine, computeWebListLayout, estimateWebRowHeight } = require('../web/NativeListWebEngine.ts'); -+const identity = (key, fields = {}) => ({ type: 'identity', key, title: key, leading: { kind: 'network' }, ...fields }); -+const snapshot = (rows, fields = {}) => ({ schemaVersion: 1, generation: 1, layout: { kind: 'sectioned' }, rows, ...fields }); -+function mount(rows, props = {}) { -+ const dom = new JSDOM('
', { pretendToBeVisual: true }); -+ const view = dom.window; -+ global.Element = view.Element; -+ global.HTMLElement = view.HTMLElement; -+ global.Node = view.Node; -+ view.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 20, y: 30, left: 20, top: 30, right: 100, bottom: 54, width: 80, height: 24 }); -+ view.HTMLElement.prototype.scrollTo = function ({ top = 0, left = 0 }) { this.scrollTop = top; this.scrollLeft = left; }; -+ const actions = [], invalidated = [], selections = []; -+ const engine = new NativeListWebEngine(view.document.getElementById('host'), snapshot(rows, props), { -+ onRowAction: (event) => actions.push(event), -+ onActionAnchorInvalidated: (event) => invalidated.push(event), -+ onSelectionDelta: (event) => selections.push(event), -+ }, false); -+ return { view, engine, document: view.document, actions, invalidated, selections, close() { engine.destroy(); view.close(); } }; -+} -+test('index jumps highlight the section reached after an exact spacer boundary', async () => { -+ const page = mount([ -+ { type: 'sectionHeader', key: 'A', sectionKey: 'A', title: 'A', indexTitle: 'A', height: 36 }, -+ identity('a', { sectionKey: 'A', height: 48 }), -+ { type: 'system', variant: 'spacer', key: 'gap', height: 20 }, -+ { type: 'sectionHeader', key: 'B', sectionKey: 'B', title: 'B', indexTitle: 'B', height: 36 }, -+ identity('b', { sectionKey: 'B', height: 48 }), -+ { type: 'system', variant: 'spacer', key: 'tail-1', height: 500 }, -+ { type: 'system', variant: 'spacer', key: 'tail-2', height: 500 }, -+ ], { capabilities: { sectionIndex: { enabled: true } } }); -+ try { -+ const viewport = page.document.querySelector('.ok-native-list-viewport'); -+ Object.defineProperty(viewport, 'clientHeight', { value: 400 }); -+ Object.defineProperty(viewport, 'clientWidth', { value: 320 }); -+ const index = page.document.querySelector('[aria-label="Jump to B"]'); -+ index.click(); -+ await new Promise(resolve => page.view.requestAnimationFrame(() => page.view.requestAnimationFrame(resolve))); -+ assert.equal(page.document.querySelector('.ok-native-list-viewport').scrollTop, 104); -+ assert.equal(index.dataset.active, 'true'); -+ assert.equal(page.document.querySelector('[aria-label="Jump to A"]').dataset.active, 'false'); -+ } finally { -+ page.close(); -+ } -+}); -+test('compact web index keeps every section reachable without overflowing short viewports', () => { -+ const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); -+ const rows = [ -+ ...letters.map((letter) => ({ -+ type: 'sectionHeader', -+ key: letter, -+ sectionKey: letter, -+ title: letter, -+ indexTitle: letter, -+ height: 36, -+ })), -+ { type: 'system', variant: 'spacer', key: 'tail', height: 500 }, -+ ]; -+ const page = mount(rows, { -+ capabilities: { sectionIndex: { enabled: true } }, -+ }); -+ try { -+ const viewport = page.document.querySelector('.ok-native-list-viewport'); -+ const frame = page.document.querySelector('.ok-native-list-viewport-frame'); -+ const rail = page.document.querySelector('.ok-native-list-index-rail'); -+ Object.defineProperty(viewport, 'clientHeight', { -+ configurable: true, -+ value: 160, -+ }); -+ Object.defineProperty(viewport, 'clientWidth', { -+ configurable: true, -+ value: 320, -+ }); -+ rail.getBoundingClientRect = () => ({ -+ x: 288, -+ y: 20, -+ left: 288, -+ top: 20, -+ right: 320, -+ bottom: 180, -+ width: 32, -+ height: 160, -+ }); -+ frame.getBoundingClientRect = () => ({ -+ x: 0, -+ y: 20, -+ left: 0, -+ top: 20, -+ right: 320, -+ bottom: 180, -+ width: 320, -+ height: 160, -+ }); -+ page.engine.recomputeLayout(); -+ -+ const buttons = [...rail.querySelectorAll('[data-section-entry-index]')]; -+ assert.equal(rail.dataset.compact, 'true'); -+ assert(buttons.length < letters.length); -+ assert.equal(page.document.querySelector('[aria-label="Jump to H"]'), null); -+ assert.equal(page.engine.layout.items[0].width, 304); -+ -+ const targetIndex = 7; -+ const targetY = 20 + 8 + (targetIndex / (letters.length - 1)) * 144; -+ const overlappingVisibleButton = rail.querySelector( -+ '[data-section-entry-index="8"]', -+ ); -+ assert(overlappingVisibleButton); -+ overlappingVisibleButton.dispatchEvent( -+ new page.view.MouseEvent('pointerdown', { -+ bubbles: true, -+ buttons: 1, -+ clientX: 304, -+ clientY: targetY, -+ }), -+ ); -+ overlappingVisibleButton.dispatchEvent( -+ new page.view.MouseEvent('click', { -+ bubbles: true, -+ detail: 1, -+ }), -+ ); -+ assert.equal( -+ page.document.querySelector('.ok-native-list-index-preview').textContent, -+ 'H', -+ ); -+ assert.equal(viewport.scrollTop, targetIndex * 36); -+ -+ Object.defineProperty(viewport, 'clientHeight', { -+ configurable: true, -+ value: 100, -+ }); -+ page.engine.recomputeLayout(); -+ assert.equal(rail.hidden, true); -+ assert.equal(page.engine.layout.items[0].width, 320); -+ } finally { -+ page.close(); -+ } -+}); -+test('selector explicit dimensions override presets without changing existing defaults', () => { -+ const normal = identity('n', { presentation: 'networkSelector' }); -+ assert.equal(estimateWebRowHeight(normal, snapshot([normal]), 400), 47); -+ assert.equal(estimateWebRowHeight({ ...normal, height: 48 }, snapshot([normal]), 400), 48); -+ const account = identity('a', { presentation: 'accountSelector', height: 60 }); -+ assert.equal(computeWebListLayout(snapshot([normal, account]), 400, 800).items[1].height, 60); -+}); -+test('explicit native height rounding survives serialization and rejects incomplete policies', () => { -+ const row = { type: 'sectionHeader', key: 'a', sectionKey: 'a', title: 'A', presentation: 'networkSelector', height: 36, heightRounding: 'nearest' }; -+ const accepted = validateSnapshot(JSON.parse(JSON.stringify(snapshot([row])))); -+ assert.equal(accepted.rows[0].heightRounding, 'nearest'); -+ assert.equal(estimateWebRowHeight(row, accepted, 400), 36); -+ assert.throws(() => validateSnapshot(snapshot([{ ...row, height: undefined }])), /heightRounding/); -+ assert.throws(() => validateSnapshot(snapshot([{ ...row, heightRounding: 'ceil' }])), /heightRounding/); -+ assert.doesNotThrow(() => serializePatches([{ type: 'sectionHeader', key: 'a', changes: { heightRounding: 'nearest' } }])); -+ assert.throws(() => serializePatches([{ type: 'sectionHeader', key: 'a', changes: { heightRounding: 'ceil' } }]), /heightRounding/); -+}); -+test('wallet badge heights propagate through grouped layout', () => { -+ const group = { type: 'walletGroup', key: 'g', parent: identity('g', { presentation: 'walletSidebar' }), children: [identity('c', { presentation: 'walletSidebar', badges: [{ key: 'b', text: 'Bot' }] })] }; -+ assert.equal(estimateWebRowHeight(group, snapshot([group]), 96), 172); -+}); -+test('invalid title ranges and overlays are rejected before native serialization', () => { -+ assert.throws(() => validateSnapshot(snapshot([identity('x', { title: 'abc', titleMatch: [{ start: 1, end: 4 }] })])), /titleMatch/); -+ assert.throws(() => validateSnapshot(snapshot([identity('x', { leading: { kind: 'wallet', overlays: [{ position: 'topLeft', size: 100 }] } })])), /size/); -+ assert.throws(() => validateSnapshot(snapshot([identity('x', { opacity: 2 })])), /opacity/); -+}); -+test('search matches use info color and retain unhighlighted title text', () => { -+ const page = mount([identity('x', { title: 'Ethereum', titleMatch: [{ start: 2, end: 5 }], height: 48, presentation: 'networkSelector' })]); -+ assert.equal(page.document.querySelector('.ok-native-list-title').textContent, 'Ethereum'); -+ assert.equal(page.document.querySelector('.ok-native-list-title .ok-native-list-info').textContent, 'her'); -+ assert.equal(page.document.querySelector('.ok-native-list-title').style.fontSize, '16px'); -+ page.close(); -+}); -+test('subtitle supports a leading address separator and distinct caution tone', () => { -+ const page = mount([identity('x', { subtitleSegments: [{ text: 'Create address', tone: 'caution', separatorBefore: true }] })]); -+ assert.ok(page.document.querySelector('.ok-native-list-subtitle-segments').firstChild.classList.contains('ok-native-list-subtitle-dot')); -+ assert.equal(page.document.querySelector('.ok-native-list-subtitle-segments .ok-native-list-secondary').dataset.tone, 'caution'); -+ page.close(); -+}); -+test('pressDisabled gates row clicks while preserving plus accessory actions', () => { -+ const page = mount([identity('x', { presentation: 'accountSelector', height: 60, pressDisabled: true, trailing: [{ kind: 'icon', name: 'PlusSmallOutline', actionKey: 'create', testID: 'account-manager-plus-button-icon-btn' }] })]); -+ page.document.querySelector('.ok-native-list-title').click(); -+ assert.equal(page.actions.length, 0); -+ page.document.querySelector('[data-native-list-action="create"]').click(); -+ assert.equal(page.actions[0].actionKey, 'create'); -+ assert.ok(page.document.querySelector('[data-testid="account-manager-plus-button-icon-btn"]')); -+ page.close(); -+}); -+test('section help is independent of checkbox selection and anchored to the title', () => { -+ const rows = [{ type: 'sectionHeader', key: 'h', sectionKey: 'a', title: 'Assets', titleActionKey: 'help', titleActionOnHover: true, checkbox: { kind: 'checkbox', state: 'unchecked', target: { scope: 'section', sectionKey: 'a' } } }, identity('x', { sectionKey: 'a' })]; -+ const page = mount(rows, { selection: { mode: 'multiple', selectedKeys: [] } }); -+ const title = page.document.querySelector('[data-native-list-action="help"]'); -+ title.click(); -+ assert.equal(page.actions[0].actionKey, 'help'); -+ assert.equal(page.actions[0].anchor.source, 'leadingAction'); -+ assert.equal(page.selections.length, 0); -+ title.dispatchEvent(new page.view.MouseEvent('pointerover', { bubbles: true })); -+ const token = page.actions.at(-1).anchor.token; -+ page.engine.setActionAnchorState({ token, open: true }); -+ title.dispatchEvent(new page.view.MouseEvent('pointerout', { bubbles: true })); -+ assert.deepEqual(page.invalidated.at(-1), { token, reason: 'pointerLeave' }); -+ page.close(); -+}); -+test('failed network images render the official globe SVG fallback', () => { -+ const page = mount([identity('x', { leading: { kind: 'network', image: { uri: 'https://example.invalid/missing.png', width: 32, height: 32 }, fallbackIcon: { name: 'GlobusOutline' } } })]); -+ page.document.querySelector('.ok-native-list-visual-main').dispatchEvent(new page.view.Event('error')); -+ assert.equal(page.document.querySelector('.ok-native-list-visual-main'), null); -+ assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg path')); -+ page.close(); -+}); -+function fakeImageRetryClock(view) { -+ const timers = new Map(); -+ const cleared = []; -+ let serial = 0; -+ view.setTimeout = (callback, delay) => { -+ assert.ok([0, 1000, 2000].includes(delay)); -+ timers.set(++serial, callback); -+ return serial; -+ }; -+ view.clearTimeout = id => { cleared.push(id); timers.delete(id); }; -+ return { timers, cleared, flush() { const callbacks = [...timers.values()]; timers.clear(); callbacks.forEach(callback => callback()); } }; -+} -+test('optimized image failure falls back to raw, retries raw once, then shows the globe', () => { -+ const page = mount([identity('image', { leading: { kind: 'network', image: { uri: 'https://images.test/optimized.png', fallbackUri: 'https://images.test/raw.png', retryTimes: 1, width: 32, height: 32 }, fallbackIcon: { name: 'GlobusOutline' } } })]); -+ const clock = fakeImageRetryClock(page.view); -+ const image = page.document.querySelector('.ok-native-list-visual-main'); -+ image.dispatchEvent(new page.view.Event('error')); -+ assert.equal(image.src, 'https://images.test/raw.png'); -+ assert.equal(clock.timers.size, 0); -+ image.dispatchEvent(new page.view.Event('error')); -+ assert.equal(clock.timers.size, 1); -+ image.dispatchEvent(new page.view.Event('error')); -+ assert.equal(clock.timers.size, 1); -+ assert.equal(page.document.querySelector('.ok-native-list-visual-main'), image); -+ clock.flush(); -+ assert.equal(image.src, 'https://images.test/raw.png'); -+ image.dispatchEvent(new page.view.Event('error')); -+ assert.equal(page.document.querySelector('.ok-native-list-visual-main'), null); -+ assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg')); -+ page.close(); -+}); -+test('raw image retry works without an optimized source and successful loads stop pending retries', () => { -+ const page = mount([identity('image', { leading: { kind: 'network', image: { uri: 'https://images.test/raw.png', retryTimes: 1, width: 32, height: 32 } } })]); -+ const clock = fakeImageRetryClock(page.view); -+ const image = page.document.querySelector('.ok-native-list-visual-main'); -+ image.dispatchEvent(new page.view.Event('error')); -+ assert.equal(clock.timers.size, 1); -+ image.dispatchEvent(new page.view.Event('load')); -+ assert.equal(clock.timers.size, 0); -+ assert.equal(clock.cleared.length, 1); -+ page.close(); -+}); -+test('row rebinding and list destruction cancel image retries without reviving detached images', () => { -+ const row = uri => identity('image', { leading: { kind: 'network', image: { uri, retryTimes: 1, width: 32, height: 32 } } }); -+ const page = mount([row('https://images.test/old.png')]); -+ const clock = fakeImageRetryClock(page.view); -+ const old = page.document.querySelector('.ok-native-list-visual-main'); -+ old.dispatchEvent(new page.view.Event('error')); -+ const staleCallback = [...clock.timers.values()][0]; -+ page.engine.applySnapshot(snapshot([row('https://images.test/new.png')], { generation: 2 })); -+ assert.equal(clock.timers.size, 0); -+ staleCallback(); -+ assert.equal(old.src, 'https://images.test/old.png'); -+ const current = page.document.querySelector('.ok-native-list-visual-main'); -+ assert.equal(current.src, 'https://images.test/new.png'); -+ current.dispatchEvent(new page.view.Event('error')); -+ assert.equal(clock.timers.size, 1); -+ page.engine.destroy(); -+ assert.equal(clock.timers.size, 0); -+ page.view.close(); -+}); -+test('provider overlays preserve official colors and both corner positions', () => { -+ const page = mount([identity('x', { leading: { kind: 'wallet', overlays: [{ position: 'topLeft', name: 'GoogleIllus' }, { position: 'bottomRight', text: '3' }] } })]); -+ const corners = page.document.querySelectorAll('.ok-native-list-visual-overlay'); -+ assert.equal(corners.length, 2); -+ assert.equal(corners[0].querySelector('path').getAttribute('fill'), '#4285F4'); -+ assert.equal(corners[1].textContent, '3'); -+ page.close(); -+}); -+test('wallet child taps retain child identity and original automation IDs', () => { -+ const group = { type: 'walletGroup', key: 'g', parent: identity('g', { presentation: 'walletSidebar' }), children: [identity('c', { presentation: 'walletSidebar' })] }; -+ const page = mount([group, identity('x', { testID: 'original-network-id', backgroundColor: '#123456' })]); -+ page.document.querySelector('[data-native-list-group-member-key="c"] .ok-native-list-title').click(); -+ assert.equal(page.actions[0].rowKey, 'c'); -+ assert.ok(page.document.querySelector('[data-testid="original-network-id"]')); -+ page.close(); -+}); -+test('very small amounts preserve compact digits inside independent subtitle and value fragments', () => { -+ const runs = [{ text: '0.0' }, { text: '7', style: 'subscript' }, { text: '123' }]; -+ const page = mount([identity('x', { subtitleSegments: [{ text: '0.00000000123 BTC', textSegments: runs }], trailing: [{ kind: 'value', text: '0.00000000123', textSegments: runs }] })]); -+ const subtitle = page.document.querySelector('.ok-native-list-subtitle-segments .ok-native-list-secondary'); -+ const value = page.document.querySelector('.ok-native-list-accessory'); -+ assert.equal(subtitle.textContent, '0.07123'); -+ assert.equal(subtitle.children[1].style.fontSize, '9px'); -+ assert.equal(value.children[1].style.fontSize, '10px'); -+ page.close(); -+}); -+test('deprecated wallet warnings remain normal scroll content with source title and description', () => { -+ const page = mount([{ type: 'system', variant: 'warning', key: 'warning', title: 'Upgrade required', message: 'This wallet needs an upgrade before creating more accounts.', backgroundColor: '#ffcc00', backgroundFullWidth: true, borderColor: '#cc9900' }, identity('x')], { layout: { kind: 'linear', contentPaddingHorizontal: 8 } }); -+ const warning = page.document.querySelector('.ok-native-list-warning'); -+ assert.ok(warning.closest('.ok-native-list-content')); -+ assert.equal(warning.querySelector('.ok-native-list-warning-title').textContent, 'Upgrade required'); -+ assert.equal(warning.querySelector('.ok-native-list-warning-message').textContent, 'This wallet needs an upgrade before creating more accounts.'); -+ assert.equal(warning.parentElement.style.contain, 'layout style'); -+ page.close(); -+}); -+test('late image failures cannot replace the latest row after rapid snapshot rebinding', () => { -+ const makeRow = (index) => identity('x', { title: 'Account ' + index, revision: index, leading: { kind: 'network', image: { uri: 'https://example.invalid/' + index + '.png', width: 32, height: 32 }, fallbackIcon: { name: 'GlobusOutline' } } }); -+ const page = mount([makeRow(0)]); -+ for (let index = 1; index <= 50; index += 1) { -+ const oldImage = page.document.querySelector('.ok-native-list-visual-main'); -+ page.engine.applySnapshot(snapshot([makeRow(index)], { generation: index + 1 })); -+ oldImage.dispatchEvent(new page.view.Event('error')); -+ assert.equal(page.document.querySelector('.ok-native-list-title').textContent, 'Account ' + index); -+ assert.ok(page.document.querySelector('.ok-native-list-visual-main').src.endsWith('/' + index + '.png')); -+ } -+ page.close(); -+}); -+test('non-sticky asset help does not become a sticky title before alphabet sections', async () => { -+ const page = mount([{ type: 'sectionHeader', key: 'help', sectionKey: 'assets', title: 'Asset help', height: 47, sticky: false }, identity('asset', { height: 80 }), { type: 'sectionHeader', key: 'a', sectionKey: 'a', title: 'A', height: 36 }, identity('alphabet', { height: 800 })], { layout: { kind: 'sectioned', stickyHeaders: true } }); -+ const viewport = page.document.querySelector('.ok-native-list-viewport'); -+ viewport.scrollTop = 60; -+ viewport.dispatchEvent(new page.view.Event('scroll')); -+ await new Promise((resolve) => setTimeout(resolve, 25)); -+ assert.equal(page.document.querySelector('.ok-native-list-sticky').hidden, true); -+ viewport.scrollTop = 170; -+ viewport.dispatchEvent(new page.view.Event('scroll')); -+ await new Promise((resolve) => setTimeout(resolve, 25)); -+ assert.equal(page.document.querySelector('.ok-native-list-sticky').textContent, 'A'); -+ page.close(); -+}); -+test('explicit active account and wallet states update without checkbox selection', () => { -+ for (const presentation of ['accountSelector', 'walletSidebar']) { -+ const row = identity('selected', { presentation, selected: true, height: 60 }); -+ const page = mount([row]); -+ const item = page.document.querySelector('[data-native-list-row-key="selected"]'); -+ assert.equal(item.dataset.nativeListSelected, 'true'); -+ page.engine.applySnapshot(snapshot([{ ...row, selected: false }], { generation: 2 })); -+ assert.equal(item.dataset.nativeListSelected, 'false'); -+ page.engine.applySnapshot(snapshot([row], { generation: 3 })); -+ assert.equal(item.dataset.nativeListSelected, 'true'); -+ page.close(); -+ } -+}); -+test('account corner badge preserves its outer ring and inner image dimensions', () => { -+ const row = identity('account', { presentation: 'accountSelector', height: 60, leading: { kind: 'account', shape: 'rounded', overlays: [{ position: 'bottomRight', size: 20, padding: 2, offset: 4, name: 'AllNetworksSolid' }] } }); -+ const page = mount([row]); -+ const overlay = page.document.querySelector('.ok-native-list-visual-overlay'); -+ assert.equal(overlay.style.width, '20px'); -+ assert.equal(overlay.style.padding, '2px'); -+ assert.equal(overlay.style.right, '-4px'); -+ assert.equal(page.document.querySelector('.ok-native-list-account-row').style.borderRadius, '12px'); -+ assert.equal(page.document.querySelector('.ok-native-list-visual').style.borderRadius, '8px'); -+ assert.throws(() => validateSnapshot(snapshot([{ ...row, leading: { ...row.leading, overlays: [{ ...row.leading.overlays[0], padding: 10 }] } }])), /padding/); -+ page.close(); -+}); -+test('network header action anchor includes its own unclipped source SVG underline', () => { -+ const page = mount([{ type: 'sectionHeader', key: 'header', sectionKey: 'summary', presentation: 'networkSelector', height: 71, variant: 'summary', title: '15 networks selected', titleActionKey: 'help', value: 'Deselect all', valueActionKey: 'toggle' }]); -+ const title = page.document.querySelector('[data-native-list-action="help"]'); -+ assert.equal(title.querySelector('.ok-native-list-section-title-text').textContent, '15 networks selected'); -+ assert.equal(title.querySelector('svg line').getAttribute('stroke-dasharray'), '0,4'); -+ assert.equal(title.style.textDecoration, ''); -+ assert.equal(title.querySelector('svg').style.position, 'absolute'); -+ assert.equal(title.style.paddingBottom, '3px'); -+ assert.equal(page.document.querySelector('.ok-native-list-section').style.padding, '24px 12px 20px'); -+ assert.equal(page.document.querySelector('[data-native-list-action="toggle"]').style.fontSize, '16px'); -+ page.close(); -+}); -+test('selector text enables tabular digits while generic rows retain their existing font features', () => { -+ const generic = identity('generic'); -+ const account = identity('account', { presentation: 'accountSelector', title: 'Account 12', subtitleSegments: [{ text: '$5.70' }] }); -+ const network = identity('network', { presentation: 'networkSelector', trailing: [{ kind: 'value', text: '$5.70' }] }); -+ const wallet = identity('wallet', { presentation: 'walletSidebar', title: 'Wallet 12' }); -+ const group = { type: 'walletGroup', key: 'group', parent: { ...wallet, key: 'group' }, children: [{ ...wallet, key: 'child' }] }; -+ const header = { type: 'sectionHeader', key: 'header', sectionKey: 'summary', presentation: 'networkSelector', variant: 'summary', title: '12 networks selected', value: 'Deselect all', valueActionKey: 'toggle' }; -+ const page = mount([generic, account, network, group, header]); -+ const genericBody = page.document.querySelector('[data-native-list-row-key="generic"]>.ok-native-list-row'); -+ assert.equal(genericBody.style.fontVariantNumeric, ''); -+ for (const body of page.document.querySelectorAll('.ok-native-list-account-row,.ok-native-list-network-row,.ok-native-list-wallet-row,.ok-native-list-section')) { -+ assert.equal(body.style.fontVariantNumeric, 'tabular-nums'); -+ for (const text of body.querySelectorAll('span,button')) assert.equal(text.style.fontVariantNumeric, 'tabular-nums'); -+ } -+ page.close(); -+}); -+test('touch-active rows keep the pressed background rule', () => { -+ const page = mount([identity('network', { presentation: 'networkSelector' })]); -+ const css = page.document.querySelector('style').textContent; -+ assert.match(css, /native-list-disabled="true"\]\):active>\.ok-native-list-row\{background:var\(--nl-pressed\)\}/); -+ page.close(); -+}); -+test('wallet hover anchors the complete child row without consuming normal selection taps', () => { -+ const child = identity('child', { presentation: 'walletSidebar', height: 68, titleActionKey: 'wallet.help', titleActionOnHover: true }); -+ const group = { type: 'walletGroup', key: 'group', parent: identity('group', { presentation: 'walletSidebar', height: 68 }), children: [child] }; -+ const page = mount([group]); -+ assert.equal(estimateWebRowHeight(group, snapshot([group]), 96), 150); -+ const body = page.document.querySelector('[data-native-list-group-member-key="child"] .ok-native-list-wallet-row'); -+ body.querySelector('.ok-native-list-visual').dispatchEvent(new page.view.MouseEvent('pointerover', { bubbles: true })); -+ assert.equal(page.actions.at(-1).rowKey, 'child'); -+ assert.equal(page.actions.at(-1).actionKey, 'wallet.help'); -+ assert.deepEqual(page.actions.at(-1).anchor.windowRect, { x: 20, y: 30, width: 80, height: 24 }); -+ const token = page.actions.at(-1).anchor.token; -+ page.engine.setActionAnchorState({ token, open: true }); -+ body.dispatchEvent(new page.view.MouseEvent('pointerout', { bubbles: true })); -+ assert.deepEqual(page.invalidated.at(-1), { token, reason: 'pointerLeave' }); -+ body.click(); -+ assert.equal(page.actions.at(-1).rowKey, 'child'); -+ assert.equal(page.actions.at(-1).actionKey, 'press'); -+ page.close(); -+}); -+test('account menu preserves its automation ID and returns the 24-point layout slot', () => { -+ const page = mount([identity('account', { presentation: 'accountSelector', height: 60, trailing: [{ kind: 'icon', name: 'DotHorOutline', actionKey: 'menu', testID: 'account-edit' }] })]); -+ const button = page.document.querySelector('[data-testid="account-edit"]'); -+ button.getBoundingClientRect = () => ({ x: 909, y: 312, left: 909, top: 312, right: 947, bottom: 350, width: 38, height: 38 }); -+ button.click(); -+ assert.deepEqual(page.actions.at(-1).anchor.windowRect, { x: 916, y: 319, width: 24, height: 24 }); -+ page.close(); -+}); -+test('network checkbox state changes retain the official checked and indeterminate glyphs', () => { -+ const row = identity('network', { presentation: 'networkSelector', height: 48, trailing: [{ kind: 'checkbox', state: 'unchecked' }] }); -+ const page = mount([row], { theme: { checkboxBackground: '#fdfdfd', checkboxBorder: '#abcdef', checkboxIcon: '#151515' } }); -+ const checkbox = page.document.querySelector('.ok-native-list-checkbox'); -+ assert.equal(checkbox.dataset.selector, 'networkSelector'); -+ assert.equal(checkbox.querySelectorAll('svg').length, 2); -+ assert.equal(checkbox.querySelector('svg[data-state="checked"]').getAttribute('viewBox'), '0 0 16 16'); -+ assert.equal(checkbox.dataset.state, 'unchecked'); -+ page.engine.applySnapshot(snapshot([row], { generation: 2, selection: { mode: 'multiple', selectedKeys: ['network'] } })); -+ assert.equal(page.document.querySelector('.ok-native-list-checkbox').dataset.state, 'checked'); -+ page.close(); -+}); -+test('reorderable wallet taps do not capture the pointer until a drag crosses the threshold', () => { -+ const row = identity('wallet', { presentation: 'walletSidebar', height: 68, draggable: true }); -+ const page = mount([row], { capabilities: { reorderable: true } }); -+ const viewport = page.document.querySelector('.ok-native-list-viewport'); -+ const body = page.document.querySelector('.ok-native-list-wallet-row'); -+ const captures = []; -+ viewport.setPointerCapture = id => captures.push(id); -+ const pointer = (type, x, y) => { -+ const event = new page.view.MouseEvent(type, { bubbles: true, clientX: x, clientY: y }); -+ Object.defineProperties(event, { pointerId: { value: 1 }, pointerType: { value: 'mouse' }, isPrimary: { value: true } }); -+ body.dispatchEvent(event); -+ }; -+ pointer('pointerdown', 30, 40); -+ assert.deepEqual(captures, []); -+ pointer('pointerup', 30, 40); -+ body.click(); -+ assert.equal(page.actions.at(-1).actionKey, 'press'); -+ pointer('pointerdown', 30, 40); -+ pointer('pointermove', 50, 60); -+ assert.deepEqual(captures, [1]); -+ pointer('pointercancel', 50, 60); -+ page.close(); -+}); -+ -+test('accessory help uses a separate hover action without replacing the edit click', () => { -+ const page = mount([identity('custom', { presentation: 'networkSelector', height: 48, trailing: [{ kind: 'icon', name: 'PencilOutline', actionKey: 'edit', hoverActionKey: 'edit.help', accessibilityLabel: 'Edit' }] })]); -+ const button = page.document.querySelector('[data-native-list-action="edit"]'); -+ assert.equal(button.getAttribute('aria-label'), 'Edit'); -+ button.dispatchEvent(new page.view.MouseEvent('pointerover', { bubbles: true })); -+ assert.equal(page.actions.at(-1).actionKey, 'edit.help'); -+ assert.equal(page.actions.at(-1).anchor.source, 'trailingAccessory'); -+ button.click(); -+ assert.equal(page.actions.at(-1).actionKey, 'edit'); -+ page.close(); -+}); -+test('wallet overlays preserve rectangular firmware badges and naturally sized numeric labels', () => { -+ const wallet = identity('wallet', { presentation: 'walletSidebar', height: 68, leading: { kind: 'wallet', overlays: [{ position: 'topLeft', width: 18, height: 16, offsetX: 0, offsetY: 4, padding: 1, image: { uri: 'https://images.test/btc.png', width: 14, height: 14, contentFit: 'contain' } }, { position: 'bottomRight', text: '12', height: 16, offsetX: 1, offsetY: 2 }] } }); -+ const page = mount([wallet]); -+ const [firmware, number] = page.document.querySelectorAll('.ok-native-list-visual-overlay'); -+ assert.equal(firmware.style.width, '18px'); -+ assert.equal(firmware.style.height, '16px'); -+ assert.equal(firmware.style.left, '0px'); -+ assert.equal(firmware.style.top, '-4px'); -+ assert.equal(number.style.width, 'auto'); -+ assert.equal(number.style.padding, '0px 2px'); -+ assert.equal(number.style.fontSize, '12px'); -+ assert.equal(number.style.fontWeight, '400'); -+ assert.equal(number.style.right, '-1px'); -+ page.close(); -+}); -+test('hidden-wallet lock fills its avatar while the add-hidden plus keeps its original size', () => { -+ const lock = identity('group', { presentation: 'walletSidebar', height: 68, testID: 'wallet-group', leading: { kind: 'wallet', fallbackIcon: { name: 'LockSolid' } } }); -+ const plus = identity('plus', { presentation: 'walletSidebar', height: 68, leading: { kind: 'wallet', borderStyle: 'dashed', fallbackIcon: { name: 'PlusSmallOutline' } } }); -+ const page = mount([{ type: 'walletGroup', key: 'group', parent: lock, children: [plus] }]); -+ const parent = page.document.querySelector('[data-testid="wallet-group"]'); -+ assert.equal(parent.querySelector('svg').style.width, '40px'); -+ const child = page.document.querySelector('[data-native-list-group-member-key="plus"]'); -+ assert.equal(child.querySelector('svg').getAttribute('width'), '24'); -+ assert.equal(child.querySelector('.ok-native-list-visual').style.borderWidth, '1px'); -+ page.close(); -+}); -+test('account add actions retain ListItem medium text while empty-search text remains regular', () => { -+ const page = mount([{ type: 'action', key: 'add', actionKey: 'add', title: 'Add account', height: 48, presentation: 'accountSelector', icon: { kind: 'icon', name: 'PlusSmallOutline' } }, { type: 'action', key: 'empty', actionKey: 'empty', title: 'No account', height: 60, presentation: 'accountSelector', tone: 'primary' }]); -+ assert.equal(page.document.querySelector('[data-native-list-row-key="add"] .ok-native-list-action-title').style.fontWeight, '500'); -+ assert.equal(page.document.querySelector('[data-native-list-row-key="empty"] .ok-native-list-action-title').style.fontWeight, ''); -+ page.close(); -+}); -+test('selector background pixels follow successful image sources and disappear after terminal failure or rebinding', () => { -+ const row = uri => identity('image', { height: 48, presentation: 'networkSelector', leading: { kind: 'network', image: { uri, fallbackUri: 'https://images.test/raw.png', width: 32, height: 32, contentFit: 'cover' }, fallbackIcon: { name: 'GlobusOutline' } } }); -+ const page = mount([row('https://images.test/optimized.png')]); -+ const image = page.document.querySelector('img'); -+ const paint = page.document.querySelector('.ok-native-list-selector-image-background'); -+ assert.equal(paint.style.backgroundImage, ''); -+ image.dispatchEvent(new page.view.Event('error')); -+ assert.equal(image.src, 'https://images.test/raw.png'); -+ image.dispatchEvent(new page.view.Event('load')); -+ assert.ok(paint.style.backgroundImage.includes('https://images.test/raw.png')); -+ page.engine.applySnapshot(snapshot([row('https://images.test/new.png')], { generation: 2 })); -+ assert.equal(paint.isConnected, false); -+ assert.equal(page.document.querySelector('.ok-native-list-selector-image-background').style.backgroundImage, ''); -+ const nextImage = page.document.querySelector('img'); -+ nextImage.dispatchEvent(new page.view.Event('error')); -+ nextImage.dispatchEvent(new page.view.Event('error')); -+ assert.equal(page.document.querySelector('img'), null); -+ assert.equal(page.document.querySelector('.ok-native-list-selector-image-background').style.backgroundImage, 'none'); -+ assert.ok(page.document.querySelector('.ok-native-list-visual-fallback svg')); -+ page.close(); -+}); -+test('URI prefetch extends beyond mounted DOM and releases leases when the engine is destroyed', () => { -+ const previousWorker = global.Worker; -+ const workers = []; -+ global.Worker = class { -+ constructor() { this.messages = []; workers.push(this); } -+ addEventListener() {} -+ postMessage(message) { this.messages.push(message); } -+ }; -+ const dom = new JSDOM('
', { pretendToBeVisual: true }); -+ const view = dom.window; -+ global.Element = view.Element; global.HTMLElement = view.HTMLElement; global.Node = view.Node; -+ view.HTMLElement.prototype.scrollTo = function ({ top = 0, left = 0 }) { this.scrollTop = top; this.scrollLeft = left; }; -+ const rows = Array.from({ length: 1000 }, (_, index) => identity(String(index), { -+ presentation: 'accountSelector', height: 60, -+ leading: { kind: 'account', image: { uri: 'onekey-avatar://blockie/v1/' + index, width: 32, height: 32 } }, -+ })); -+ const engine = new NativeListWebEngine(view.document.getElementById('host'), snapshot(rows), {}, true); -+ try { -+ const mountedKeys = [...view.document.querySelectorAll('[data-native-list-row-key]')].map((element) => Number(element.dataset.nativeListRowKey)); -+ const messages = workers.flatMap((worker) => worker.messages); -+ const acquire = messages.filter((message) => message.type === 'acquire'); -+ assert(acquire.length < 80); -+ assert(mountedKeys.length < 40); -+ assert(acquire.some((message) => Number(message.uri.split('/').at(-1)) > Math.max(...mountedKeys))); -+ assert.equal(acquire[0].priority, 0); -+ engine.destroy(); -+ const released = new Set(workers.flatMap((worker) => worker.messages).filter((message) => message.type === 'release').map((message) => message.id)); -+ assert(acquire.every((message) => released.has(message.id))); -+ } finally { engine.destroy(); view.close(); global.Worker = previousWorker; } -+}); -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/avatarPrefetch.ts b/node_modules/@onekeyfe/react-native-native-list/src/avatarPrefetch.ts -new file mode 100644 -index 0000000..1379de9 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/src/avatarPrefetch.ts -@@ -0,0 +1,172 @@ -+// OneKey patch: share a bounded URI-only window across renderers. No bitmap or -+// account-specific data enters list snapshots or the UI runtime. -+import type { ImageSource, LeadingVisual, RowModel, RowPatch } from './models'; -+ -+export type AvatarCandidate = Readonly<{ source: ImageSource; priority: 0 | 1 | 2 }>; -+const PREFIX = 'onekey-avatar://blockie/v1/'; -+const MAX_CANDIDATES = 64; -+const IMAGE_PATCH_FIELDS = ['leading', 'secondaryLeading', 'image', 'networkImage', 'thumbnail', 'visual'] as const; -+ -+export function avatarPrefetchWindow( -+ rows: readonly RowModel[], first: number, last: number, direction: number, -+ resolveRow?: (row: RowModel) => RowModel -+): AvatarCandidate[] { -+ if (first < 0 || last < first || first >= rows.length) return []; -+ last = Math.min(last, rows.length - 1); -+ const result: AvatarCandidate[] = []; -+ const seen = new Set(); -+ let limit = MAX_CANDIDATES; -+ const add = (source: ImageSource | undefined, priority: 0 | 1 | 2) => { -+ if (!source?.uri.startsWith(PREFIX) || source.cachePolicy === 'none' || seen.has(source.uri) || result.length >= limit) return; -+ seen.add(source.uri); -+ result.push({ source, priority }); -+ }; -+ const visual = (value: LeadingVisual | undefined, priority: 0 | 1 | 2) => { -+ if (!value) return; -+ if ('image' in value) add(value.image, priority); -+ if ('networkImage' in value) add(value.networkImage, priority); -+ if ('images' in value) value.images.forEach((image) => add(image, priority)); -+ if ('overlays' in value) value.overlays?.forEach((overlay) => add(overlay.image, priority)); -+ }; -+ const row = (value: RowModel, priority: 0 | 1 | 2) => { -+ value = resolveRow?.(value) ?? value; -+ if ('leading' in value) visual(value.leading, priority); -+ if ('secondaryLeading' in value) visual(value.secondaryLeading, priority); -+ if ('image' in value) add(value.image, priority); -+ if ('networkImage' in value) add(value.networkImage, priority); -+ if ('thumbnail' in value) add(value.thumbnail, priority); -+ if (value.type === 'walletGroup') { -+ visual(value.parent.leading, priority); -+ for (const child of value.children.slice(0, MAX_CANDIDATES)) { -+ if (result.length >= limit) break; -+ visual(child.leading, priority); -+ } -+ } -+ }; -+ // Bound row examination too: a giant non-avatar group must not scan all data. -+ for (let index = first; index <= last && index < first + 64; index += 1) row(rows[index], 0); -+ const span = Math.min(24, (last - first + 1) * 2); -+ const step = direction < 0 ? -1 : 1; -+ const aheadStart = step > 0 ? last + 1 : first - 1; -+ const aheadLimit = Math.min(MAX_CANDIDATES, result.length + 24); -+ limit = aheadLimit; -+ for (let distance = 0; distance < span && result.length < aheadLimit; distance += 1) { -+ const index = aheadStart + distance * step; -+ if (index < 0 || index >= rows.length) break; -+ row(rows[index], 1); -+ } -+ const behindStart = step > 0 ? first - 1 : last + 1; -+ const behindLimit = Math.min(MAX_CANDIDATES, result.length + 8); -+ limit = behindLimit; -+ for (let distance = 0; distance < Math.min(8, span) && result.length < behindLimit; distance += 1) { -+ const index = behindStart - distance * step; -+ if (index < 0 || index >= rows.length) break; -+ row(rows[index], 2); -+ } -+ return result; -+} -+ -+// OneKey patch: imperative image patches update a sparse prefetch overlay, not -+// the readonly prop or a full cloned snapshot. The key index is rebuilt only for -+// a complete snapshot; ordinary balance patches retain no extra row copies. -+export class NativeAvatarPrefetchModel { -+ private rows: readonly RowModel[]; -+ private rowByKey: Map; -+ private readonly imageRows = new Map(); -+ -+ constructor(rows: readonly RowModel[]) { -+ this.rows = rows; -+ this.rowByKey = new Map(rows.map((row) => [row.key, row])); -+ } -+ -+ replaceSnapshot(rows: readonly RowModel[]) { -+ this.rows = rows; -+ this.rowByKey = new Map(rows.map((row) => [row.key, row])); -+ this.imageRows.clear(); -+ } -+ -+ applyPatches(patches: readonly RowPatch[], dispatch?: () => void): boolean { -+ // Native methods have no acknowledgement. Mirror only dispatched batches; -+ // a missing host or a synchronous serialization/dispatch failure changes nothing. -+ if (!dispatch) return false; -+ dispatch(); -+ const seen = new Set(); -+ for (const patch of patches) { -+ const row = this.rowByKey.get(patch.key); -+ // Both native renderers reject the whole batch for an unknown key/type. -+ if (!row || row.type !== patch.type || seen.has(patch.key)) return false; -+ seen.add(patch.key); -+ } -+ let changed = false; -+ for (const patch of patches) { -+ const changes: Readonly> = patch.changes; -+ let imageChanges: Record | undefined; -+ for (const key of IMAGE_PATCH_FIELDS) { -+ if (changes[key] !== undefined) { -+ imageChanges ??= {}; -+ imageChanges[key] = changes[key]; -+ } -+ } -+ // JSON.stringify omits undefined top-level fields. Native therefore keeps -+ // them unchanged; an explicit replacement visual without image clears it. -+ if (!imageChanges) continue; -+ const row = this.imageRows.get(patch.key) ?? this.rowByKey.get(patch.key)!; -+ // Preserve the validated row discriminant, matching applyRowPatches' shallow merge. -+ this.imageRows.set(patch.key, { ...row, ...imageChanges, key: row.key, type: row.type } as RowModel); -+ changed = true; -+ } -+ return changed; -+ } -+ -+ window(first: number, last: number, direction: number): AvatarCandidate[] { -+ return avatarPrefetchWindow(this.rows, first, last, direction, (row) => this.imageRows.get(row.key) ?? row); -+ } -+} -+ -+// Native preload has no cancellation token. One in-flight source is the bound; -+// direction changes replace all not-yet-started work rather than appending it. -+export class NativeAvatarPrefetchQueue { -+ private pending: ImageSource[] = []; -+ private activeUri: string | undefined; -+ private timer: ReturnType | undefined; -+ private disposed = false; -+ private readonly recent = new Map(); -+ -+ constructor(private readonly preload: (source: ImageSource) => Promise) {} -+ -+ update(candidates: readonly AvatarCandidate[]) { -+ if (this.disposed) return; -+ const now = Date.now(); -+ this.pending = candidates.filter(({ source, priority }) => priority !== 0 && source.uri !== this.activeUri && now - (this.recent.get(source.uri) ?? 0) > 30_000).map(({ source }) => source); -+ this.schedule(); -+ } -+ -+ dispose() { -+ this.disposed = true; -+ this.pending = []; -+ this.recent.clear(); -+ if (this.timer !== undefined) clearTimeout(this.timer); -+ this.timer = undefined; -+ } -+ -+ private schedule() { -+ if (this.disposed || this.activeUri || this.timer !== undefined || !this.pending.length) return; -+ // Yield between short batches without imposing a frame-per-image throughput cap. -+ this.timer = setTimeout(() => { -+ this.timer = undefined; -+ const source = this.pending.shift(); -+ if (!source || this.disposed) return; -+ this.activeUri = source.uri; -+ Promise.resolve().then(() => this.preload(source)).then((success) => { -+ if (success && !this.disposed) { -+ this.recent.delete(source.uri); -+ this.recent.set(source.uri, Date.now()); -+ if (this.recent.size > 128) this.recent.delete(this.recent.keys().next().value as string); -+ } -+ }).catch(() => { /* Visible loading retains its own failure/retry behavior. */ }).finally(() => { -+ this.activeUri = undefined; -+ this.schedule(); -+ }); -+ }, 0); -+ } -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/models.ts b/node_modules/@onekeyfe/react-native-native-list/src/models.ts -index 82ca2dd..5fed01c 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/src/models.ts -+++ b/node_modules/@onekeyfe/react-native-native-list/src/models.ts -@@ -22,6 +22,9 @@ export type ImageSource = Readonly<{ - optimizeTos?: boolean; - overscan?: number; - loadingStrategy?: ImageLoadingStrategy; -+ // OneKey patch: fallbackUri is Web-only; retryTimes opts all platforms into terminal retries. -+ fallbackUri?: string; -+ retryTimes?: number; - }>; - - export type BadgeModel = Readonly<{ -@@ -30,7 +33,34 @@ export type BadgeModel = Readonly<{ - tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; - }>; - -+// OneKey patch: keep selector decoration and text data serializable. -+export type SelectorTextSegment = Readonly<{ -+ text: string; -+ textSegments?: readonly ValueTextSegment[]; -+ tone?: TextTone | 'disabled' | 'caution'; -+ separatorBefore?: boolean; -+}>; -+export type VisualOverlay = Readonly<{ -+ position: 'topLeft' | 'bottomRight'; -+ size?: number; -+ width?: number; -+ height?: number; -+ padding?: number; -+ offset?: number; -+ offsetX?: number; -+ offsetY?: number; -+ image?: ImageSource; -+ name?: string; -+ text?: string; -+ tintColor?: string; -+ backgroundColor?: string; -+}>; -+ - type VisualWithImage = Readonly<{ -+ fallbackIcon?: Readonly<{ name: string; tintColor?: string }>; -+ overlays?: readonly VisualOverlay[]; -+ borderStyle?: 'dashed'; -+ borderColor?: string; - image?: ImageSource; - fallbackText?: string; - backgroundColor?: string; -@@ -70,8 +100,11 @@ export type SelectionTarget = - | Readonly<{ scope: 'section'; sectionKey: string }> - | Readonly<{ scope: 'list' }>; - -+// OneKey patch: preserve compact very-small-balance digit runs. -+export type ValueTextSegment = Readonly<{ text: string; style?: 'subscript' }>; -+ - export type TrailingAccessory = -- | Readonly<{ kind: 'value'; text: string; secondary?: boolean }> -+ | Readonly<{ kind: 'value'; text: string; secondary?: boolean; textSegments?: readonly ValueTextSegment[] }> - | Readonly<{ - kind: 'valuePair'; - primary: string; -@@ -108,6 +141,9 @@ export type TrailingAccessory = - tintColor?: string; - disabled?: boolean; - actionKey?: string; -+ testID?: string; -+ hoverActionKey?: string; -+ accessibilityLabel?: string; - }> - | Readonly<{ kind: 'spinner' }> - | Readonly<{ kind: 'progress'; value: number }>; -@@ -120,6 +156,15 @@ export type FooterAction = Readonly<{ - }>; - - export type RowBase = Readonly<{ -+ // OneKey patch: preserve measured selector geometry without changing defaults. -+ height?: number; -+ // OneKey patch: retain the source Android list's measured fractional-DP rounding. -+ heightRounding?: 'floor' | 'nearest'; -+ testID?: string; -+ opacity?: number; -+ backgroundColor?: string; -+ backgroundFullWidth?: boolean; -+ pressDisabled?: boolean; - key: string; - revision?: number; - sectionKey?: string; -@@ -136,6 +181,11 @@ export type RowBase = Readonly<{ - export type IdentityRow = RowBase & - Readonly<{ - type: 'identity'; -+ // OneKey patch: UTF-16 ranges match Fuse indices and native attributed strings. -+ titleMatch?: readonly Readonly<{ start: number; end: number }>[]; -+ titleActionKey?: string; -+ titleActionOnHover?: boolean; -+ subtitleSegments?: readonly SelectorTextSegment[]; - presentation?: 'walletSidebar' | 'accountSelector' | 'networkSelector'; - leading: LeadingVisual; - leadingAction?: Extract; -@@ -257,6 +307,12 @@ export type MetricCardRow = RowBase & - export type SectionHeaderRow = RowBase & - Readonly<{ - type: 'sectionHeader'; -+ // OneKey patch: title help stays independent of section selection. -+ sticky?: boolean; -+ valueActionTestID?: string; -+ valueSegments?: readonly ValueTextSegment[]; -+ titleActionKey?: string; -+ titleActionOnHover?: boolean; - sectionKey: string; - presentation?: 'networkSelector'; - indexTitle?: string; -@@ -291,6 +347,7 @@ export type SystemRow = RowBase & - message: string; - actionKey: string; - }> -+ | Readonly<{ type: 'system'; variant: 'warning'; title: string; message: string; borderColor?: string }> - | Readonly<{ type: 'system'; variant: 'noMatch'; message: string }> - | Readonly<{ type: 'system'; variant: 'end'; message?: string }> - | Readonly<{ type: 'system'; variant: 'spacer'; height: number }> -@@ -310,6 +367,11 @@ export type RowModel = - | SystemRow; - - export type NativeListTheme = Readonly<{ -+ // OneKey patch: explicit selector controls use the original semantic theme tokens. -+ checkboxBackground?: string; -+ checkboxBorder?: string; -+ checkboxIcon?: string; -+ cautionBackground?: string; - background: string; - rowBackground: string; - rowSelectedBackground: string; -@@ -329,6 +391,8 @@ export type NativeListTheme = Readonly<{ - inverseBackground?: string; - inverseText?: string; - info?: string; -+ // OneKey patch: match the existing account warning address tone. -+ caution?: string; - }>; - - export type SectionIndexConfig = Readonly<{ -@@ -369,7 +433,11 @@ export type NativeListSnapshot = Readonly<{ - theme?: NativeListTheme; - }>; - --type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator'; -+// OneKey patch: include selector-only mutable presentation fields. -+// type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator'; -+// OneKey patch: balance patches also refresh the existing row's spoken content. -+// type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled'; -+type CommonPatchFields = 'revision' | 'disabled' | 'selected' | 'separator' | 'height' | 'heightRounding' | 'opacity' | 'pressDisabled' | 'accessibilityLabel'; - - export type RowPatch = - | Readonly<{ -@@ -387,6 +455,10 @@ export type RowPatch = - | 'trailing' - | 'leading' - | 'leadingAction' -+ | 'titleMatch' -+ | 'titleActionKey' -+ | 'titleActionOnHover' -+ | 'subtitleSegments' - > - >; - }> -@@ -501,6 +573,8 @@ export type RowPatch = - | 'subtitle' - | 'value' - | 'valueActionKey' -+ | 'titleActionKey' -+ | 'titleActionOnHover' - | 'titleIcon' - | 'valueIcon' - | 'checkbox' -@@ -565,6 +639,8 @@ export type RowActionEvent = Readonly<{ - }>; - - export type ActionAnchorInvalidationReason = -+ // OneKey patch: close web title tooltips when their native list target is left. -+ | 'pointerLeave' - | 'scroll' - | 'rebind' - | 'snapshot' -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/validation.ts b/node_modules/@onekeyfe/react-native-native-list/src/validation.ts -index a64a0ec..10e9c48 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/src/validation.ts -+++ b/node_modules/@onekeyfe/react-native-native-list/src/validation.ts -@@ -214,6 +214,26 @@ function assertLeadingVisual( - assertImage(visual.image, `${path}.image`); - assertVisualShape(visual.shape, `${path}.shape`); - assertText(visual.cornerIcon?.name, `${path}.cornerIcon.name`); -+ // OneKey patch: validate optional selector overlays at the JSON boundary. -+ assertText(visual.fallbackIcon?.name, `${path}.fallbackIcon.name`); -+ if ((visual.overlays?.length ?? 0) > 2) fail(`${path}.overlays`, 'supports at most two overlays'); -+ visual.overlays?.forEach((overlay, index) => { -+ if (!['topLeft', 'bottomRight'].includes(overlay.position)) fail(`${path}.overlays[${index}].position`, 'must be topLeft or bottomRight'); -+ if (overlay.size !== undefined && (overlay.size <= 0 || overlay.size > 40)) fail(`${path}.overlays[${index}].size`, 'must be within 1...40'); -+ if (overlay.padding !== undefined && (!Number.isFinite(overlay.padding) || overlay.padding < 0 || overlay.padding * 2 >= (overlay.size ?? 20))) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); -+ if (overlay.offset !== undefined && (!Number.isFinite(overlay.offset) || overlay.offset < 0 || overlay.offset > 20)) fail(`${path}.overlays[${index}].offset`, 'must be within 0...20'); -+ for (const field of ['width', 'height'] as const) { -+ const value = overlay[field]; -+ if (value !== undefined && (!Number.isFinite(value) || value <= 0 || value > 40)) fail(`${path}.overlays[${index}].${field}`, 'must be within 1...40'); -+ } -+ for (const field of ['offsetX', 'offsetY'] as const) { -+ const value = overlay[field]; -+ if (value !== undefined && (!Number.isFinite(value) || value < 0 || value > 20)) fail(`${path}.overlays[${index}].${field}`, 'must be within 0...20'); -+ } -+ if (overlay.padding !== undefined && overlay.padding * 2 >= Math.min(overlay.width ?? overlay.size ?? 20, overlay.height ?? overlay.size ?? 20)) fail(`${path}.overlays[${index}].padding`, 'must fit inside the overlay'); -+ assertImage(overlay.image, `${path}.overlays[${index}].image`); -+ assertText(overlay.text, `${path}.overlays[${index}].text`); -+ }); - if (visual.kind === 'token') { - assertImage(visual.networkImage, `${path}.networkImage`); - } -@@ -288,6 +308,10 @@ function assertRow( - path = `rows[${index}]` - ): void { - assertKey(row.key, `${path}.key`); -+ // OneKey patch: explicit dimensions and opacity cannot corrupt list layout. -+ if (row.height !== undefined && (row.height < 0 || row.height > 4096)) fail(`${path}.height`, 'must be within 0...4096'); -+ if (row.heightRounding !== undefined && (row.height === undefined || !['floor', 'nearest'].includes(row.heightRounding))) fail(`${path}.heightRounding`, 'requires an explicit height and must be floor or nearest'); -+ if (row.opacity !== undefined && (row.opacity < 0 || row.opacity > 1)) fail(`${path}.opacity`, 'must be within 0...1'); - if (row.groupId && !row.groupPosition) { - fail(`${path}.groupPosition`, 'is required when groupId is present'); - } -@@ -314,6 +338,16 @@ function assertRow( - } - assertText(row.title, `${path}.title`); - assertText(row.subtitle, `${path}.subtitle`); -+ // OneKey patch: selector text segments truncate independently. -+ row.subtitleSegments?.forEach((segment, segmentIndex) => { -+ assertText(segment.text, `${path}.subtitleSegments[${segmentIndex}].text`); -+ if (segment.tone !== undefined && !['primary', 'secondary', 'disabled', 'caution', 'positive', 'negative'].includes(segment.tone)) fail(`${path}.subtitleSegments[${segmentIndex}].tone`, 'invalid selector text tone'); -+ }); -+ let previousMatchEnd = 0; -+ row.titleMatch?.forEach((match) => { -+ if (!Number.isInteger(match.start) || !Number.isInteger(match.end) || match.start < previousMatchEnd || match.end <= match.start || match.end > row.title.length) fail(`${path}.titleMatch`, 'must contain ordered, non-overlapping UTF-16 ranges inside title'); -+ previousMatchEnd = match.end; -+ }); - assertText(row.tertiary, `${path}.tertiary`); - if ( - row.tertiaryTone !== undefined && -@@ -470,13 +504,15 @@ function assertRow( - break; - case 'system': - if ( -- !['loading', 'retry', 'noMatch', 'end', 'spacer'].includes(row.variant) -+ // OneKey patch: deprecated-wallet warnings retain the original scrolling semantics. -+ !['loading', 'retry', 'noMatch', 'end', 'spacer', 'warning'].includes(row.variant) - ) { - fail( - `${path}.variant`, -- 'must be loading, retry, noMatch, end, or spacer' -+ 'must be loading, retry, noMatch, end, spacer, or warning' - ); - } -+ if (row.variant === 'warning') assertText(row.title, `${path}.title`); - if (row.variant !== 'spacer') { - assertText(row.message, `${path}.message`); - } -@@ -649,6 +685,12 @@ export function validateSnapshot( - - function assertPatchChanges(patch: RowPatch, index: number): void { - const path = `patches[${index}].changes`; -+ // OneKey patch: partial balance updates retain a valid, current accessibility label. -+ if ('accessibilityLabel' in patch.changes) { -+ assertText(patch.changes.accessibilityLabel, `${path}.accessibilityLabel`); -+ } -+ // OneKey patch: partial updates may refer to an existing height but still require a valid policy. -+ if ('heightRounding' in patch.changes && patch.changes.heightRounding !== undefined && !['floor', 'nearest'].includes(patch.changes.heightRounding)) fail(`${path}.heightRounding`, 'must be floor or nearest'); - if ( - patch.changes.revision !== undefined && - (!Number.isSafeInteger(patch.changes.revision) || -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js -new file mode 100644 -index 0000000..5c27e23 ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListAvatarWorker.js -@@ -0,0 +1,443 @@ -+// OneKey patch: avatar generation and PNG bytes stay in this external worker. -+// Algorithm ported from ethereum-blockies-base64 1.0.2 by MyCrypto (MIT): -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64 -+// Permission is hereby granted, free of charge, to any person obtaining a copy -+// of this software and associated documentation files (the "Software"), to deal -+// in the Software without restriction, including without limitation the rights -+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -+// copies of the Software, and to permit persons to whom the Software is -+// furnished to do so, subject to the following conditions: -+// The above copyright notice and this permission notice shall be included in -+// all copies or substantial portions of the Software. -+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -+// THE SOFTWARE. -+ -+const PREFIX = 'onekey-avatar://blockie/v1/'; -+const DATABASE = 'onekey-native-list-avatar-v1'; -+const MAX_DISK_BYTES = 32 * 1024 * 1024; -+const MAX_DISK_ENTRIES = 2048; -+const MAX_CONCURRENT_LOADS = 2; -+const requests = new Map(); -+const images = new Map(); -+const jobs = new Map(); -+const queue = []; -+let activeLoads = 0; -+let databasePromise; -+// OneKey patch: persistence never occupies either display-load slot. Pending bytes -+// bridge the last-lease/reacquire window until a bounded background write finishes. -+const MAX_PENDING_WRITES = 32; -+const MAX_PENDING_BYTES = 4 * 1024 * 1024; -+const WRITE_DEADLINE_MS = 32; -+const READ_BUDGET_MS = 8; -+const MAX_PENDING_READS = 2; -+const DISK_IDLE_MS = 100; -+const pendingBlobs = new Map(); -+const diskQueue = []; -+const touches = new Map(); -+const priorities = new Map(); -+let pendingBytes = 0; -+let writing = false; -+let diskTimer; -+let lastAcquire = 0; -+let pendingReads = 0; -+ -+function diskIsIdle() { -+ return !activeLoads && !queue.length && !pendingReads && Date.now() - lastAcquire >= DISK_IDLE_MS; -+} -+ -+function scheduleDiskWork() { -+ if (writing || diskTimer !== undefined || activeLoads || queue.length || pendingReads || (!diskQueue.length && !touches.size)) return; -+ const delay = Math.max(0, DISK_IDLE_MS - (Date.now() - lastAcquire)); -+ diskTimer = setTimeout(() => { diskTimer = undefined; void drainDiskQueue(); }, delay); -+} -+ -+function persistLater(uri, blob) { -+ if (pendingBlobs.has(uri) || blob.size > MAX_PENDING_BYTES) return; -+ // OneKey patch: a long scroll retains the most recent bounded write window, -+ // rather than filling it once and dropping every later result. Never evict I/O in flight. -+ // if (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) return; -+ while (pendingBlobs.size >= MAX_PENDING_WRITES || pendingBytes + blob.size > MAX_PENDING_BYTES) { -+ const oldest = diskQueue.shift(); -+ if (!oldest) return; -+ if (pendingBlobs.get(oldest.uri) === oldest.blob) pendingBlobs.delete(oldest.uri); -+ pendingBytes -= oldest.blob.size; -+ } -+ pendingBlobs.set(uri, blob); -+ pendingBytes += blob.size; -+ diskQueue.push({ uri, blob }); -+ scheduleDiskWork(); -+} -+ -+async function drainDiskQueue() { -+ // OneKey patch: a momentary empty load queue is not a scroll-idle window. -+ // if (writing || activeLoads || queue.length) return; -+ if (writing) return; -+ if (!diskIsIdle()) { scheduleDiskWork(); return; } -+ writing = true; -+ try { -+ if (diskQueue.length) { -+ const { uri, blob } = diskQueue.shift(); -+ let result; -+ try { result = await writeDisk(uri, blob); } catch { /* Persistence remains best-effort. */ } -+ if (result === 'deferred') { -+ diskQueue.unshift({ uri, blob }); -+ } else { -+ if (pendingBlobs.get(uri) === blob) pendingBlobs.delete(uri); -+ pendingBytes -= blob.size; -+ const image = images.get(uri); -+ if (result === true && image?.blob === blob) image.persisted = true; -+ } -+ } else if (touches.size) { -+ await flushTouches(); -+ } -+ } finally { -+ writing = false; -+ scheduleDiskWork(); -+ } -+} -+ -+// OneKey patch: abort is a request, not a bounded browser store-lock release. -+// Display reads have their own budget below, even if this transaction is committing. -+function cacheWriteDeadline(transaction, resolve) { -+ let timer; -+ const check = () => { -+ if (!activeLoads && !queue.length && Date.now() - lastAcquire >= DISK_IDLE_MS) { timer = setTimeout(check, WRITE_DEADLINE_MS); return; } -+ try { transaction.abort(); } catch { /* The browser already started committing. */ } -+ }; -+ timer = setTimeout(check, WRITE_DEADLINE_MS); -+ const finish = (success) => { clearTimeout(timer); resolve(success); }; -+ transaction.oncomplete = () => finish(true); -+ transaction.onabort = () => finish(false); -+} -+ -+function touchLater(uri) { -+ touches.delete(uri); -+ touches.set(uri, Date.now()); -+ if (touches.size > 128) touches.delete(touches.keys().next().value); -+ // OneKey patch: touches share the same idle gate and writer as persistence. -+ // if (touchTimer === undefined) touchTimer = setTimeout(flushTouches, 250); -+ scheduleDiskWork(); -+} -+ -+async function flushTouches() { -+ const database = await openDatabase(); -+ // Opening the database can yield across a new acquire; recheck before starting I/O. -+ if (!diskIsIdle()) return; -+ const batch = [...touches]; -+ touches.clear(); -+ if (database && batch.length) await new Promise((resolve) => { -+ try { -+ const transaction = database.transaction('images', 'readwrite'); -+ cacheWriteDeadline(transaction, resolve); -+ const store = transaction.objectStore('images'); -+ batch.forEach(([uri, accessed]) => { -+ const request = store.get(uri); -+ request.onsuccess = () => { -+ if (request.result) store.put({ ...request.result, accessed: Math.max(request.result.accessed || 0, accessed) }); -+ }; -+ }); -+ } catch { resolve(false); } -+ }); -+} -+ -+function jobPriority(job) { -+ let priority = 2; -+ job.ids.forEach((id) => { priority = Math.min(priority, priorities.get(id) ?? 2); }); -+ return priority; -+} -+ -+function openDatabase() { -+ if (!databasePromise) { -+ databasePromise = new Promise((resolve, reject) => { -+ const request = indexedDB.open(DATABASE, 1); -+ request.onupgradeneeded = () => { -+ const store = request.result.createObjectStore('images', { keyPath: 'uri' }); -+ store.createIndex('accessed', 'accessed'); -+ request.result.createObjectStore('metadata'); -+ }; -+ let blocked = false; -+ request.onsuccess = () => { -+ const database = request.result; -+ if (blocked) { database.close(); return; } -+ database.onversionchange = () => { database.close(); databasePromise = undefined; }; -+ resolve(database); -+ }; -+ request.onerror = () => reject(request.error); -+ request.onblocked = () => { -+ blocked = true; -+ reject(new Error('Avatar cache database is blocked')); -+ }; -+ }).catch(() => undefined); -+ } -+ return databasePromise; -+} -+ -+async function readDisk(uri) { -+ // OneKey patch: the cache is optional. A slow read must not retain a display -+ // load slot while abort/commit waits on browser I/O. Keep unfinished reads -+ // separately bounded, including an unresolved shared database open. -+ if (pendingReads >= MAX_PENDING_READS) return undefined; -+ pendingReads += 1; -+ return new Promise((resolve) => { -+ let transaction; -+ let blob; -+ let settled = false; -+ let finished = false; -+ let abortRequested = false; -+ const finish = (success) => { -+ if (finished) return; -+ finished = true; -+ pendingReads -= 1; -+ clearTimeout(timer); -+ if (!settled) { -+ settled = true; -+ if (success && blob) touchLater(uri); -+ resolve(success ? blob : undefined); -+ } -+ scheduleDiskWork(); -+ }; -+ const timeout = () => { -+ if (finished) return; -+ if (!settled) { settled = true; resolve(undefined); } -+ // Do not release pendingReads here: an aborted IDB transaction can still -+ // hold its store lock for hundreds of milliseconds before onabort arrives. -+ if (transaction && !abortRequested) { -+ abortRequested = true; -+ try { transaction.abort(); } catch { /* Already committing; ignore late callbacks. */ } -+ } -+ }; -+ const timer = setTimeout(timeout, READ_BUDGET_MS); -+ Promise.resolve().then(openDatabase).then((database) => { -+ if (settled || !database) { finish(false); return; } -+ try { -+ transaction = database.transaction('images', 'readonly'); -+ transaction.oncomplete = () => finish(true); -+ transaction.onabort = () => finish(false); -+ transaction.onerror = timeout; -+ const request = transaction.objectStore('images').get(uri); -+ request.onsuccess = () => { -+ if (settled) return; -+ const record = request.result; -+ if (record?.blob instanceof Blob && record.blob.type === 'image/png' && record.blob.size <= MAX_DISK_BYTES) blob = record.blob; -+ // A completed readonly get can serve display before the transaction ends. -+ // Its physical permit still belongs to oncomplete/onabort. -+ settled = true; -+ if (blob) touchLater(uri); -+ resolve(blob); -+ }; -+ } catch { if (transaction) timeout(); else finish(false); } -+ }).catch(() => finish(false)); -+ }); -+} -+ -+async function writeDisk(uri, blob) { -+ const database = await openDatabase(); -+ if (!database || blob.size > MAX_DISK_BYTES) return false; -+ if (!diskIsIdle()) return 'deferred'; -+ return new Promise((resolve) => { -+ try { -+ const transaction = database.transaction(['images', 'metadata'], 'readwrite'); -+ cacheWriteDeadline(transaction, resolve); -+ const store = transaction.objectStore('images'); -+ const metadata = transaction.objectStore('metadata'); -+ const previous = store.get(uri); -+ previous.onsuccess = () => { -+ const counter = metadata.get('size'); -+ counter.onsuccess = () => { -+ const size = counter.result || { bytes: 0, count: 0 }; -+ size.bytes += blob.size - (previous.result?.blob?.size || 0); -+ size.count += previous.result ? 0 : 1; -+ store.put({ uri, blob, accessed: Date.now() }); -+ const save = () => metadata.put(size, 'size'); -+ if (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES) { save(); return; } -+ const cursor = store.index('accessed').openCursor(); -+ cursor.onsuccess = () => { -+ const item = cursor.result; -+ if (!item || (size.bytes <= MAX_DISK_BYTES && size.count <= MAX_DISK_ENTRIES)) { save(); return; } -+ if (item.value.uri !== uri) { -+ size.bytes -= item.value.blob.size; -+ size.count -= 1; -+ item.delete(); -+ } -+ item.continue(); -+ }; -+ }; -+ }; -+ // OneKey patch: completion/abort clears the deadline before releasing pending bytes. -+ // transaction.oncomplete = transaction.onerror = transaction.onabort = () => resolve(); -+ } catch { resolve(); } -+ }); -+} -+ -+// PRNG and HSL conversion adapted from ethereum-blockies-base64 1.0.2 (MIT), MyCrypto. -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/main.js -+// https://github.com/MyCryptoHQ/ethereum-blockies-base64/blob/master/src/hsl2rgb.js -+// Preserve signed shifts, color order, and RGB rounding to match V1's decoded pixels. -+async function generateBlob(seed) { -+ const state = [0, 0, 0, 0]; -+ for (let i = 0; i < seed.length; i += 1) { -+ state[i % 4] = (state[i % 4] << 5) - state[i % 4] + seed.charCodeAt(i); -+ } -+ const rand = () => { -+ const t = state[0] ^ (state[0] << 11); -+ state[0] = state[1]; -+ state[1] = state[2]; -+ state[2] = state[3]; -+ state[3] = state[3] ^ (state[3] >> 19) ^ t ^ (t >> 8); -+ return (state[3] >>> 0) / ((1 << 31) >>> 0); -+ }; -+ const hue = (p, q, value) => { -+ let t = value; -+ if (t < 0) t += 1; -+ if (t > 1) t -= 1; -+ if (t < 1 / 6) return p + (q - p) * 6 * t; -+ if (t < 1 / 2) return q; -+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; -+ return p; -+ }; -+ const color = () => { -+ const h = Math.floor(rand() * 360) / 360; -+ const s = (rand() * 60 + 40) / 100; -+ const l = ((rand() + rand() + rand() + rand()) * 25) / 100; -+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s; -+ const p = 2 * l - q; -+ const rgb = s === 0 ? [l, l, l] : [hue(p, q, h + 1 / 3), hue(p, q, h), hue(p, q, h - 1 / 3)]; -+ return `rgb(${rgb.map((value) => Math.round(value * 255)).join(',')})`; -+ }; -+ const foreground = color(); -+ const background = color(); -+ const spot = color(); -+ const canvas = new OffscreenCanvas(128, 128); -+ const context = canvas.getContext('2d'); -+ if (!context) throw new Error('Avatar canvas is unavailable'); -+ context.fillStyle = background; -+ context.fillRect(0, 0, 128, 128); -+ for (let row = 0; row < 8; row += 1) { -+ for (let column = 0; column < 4; column += 1) { -+ const value = Math.floor(rand() * 2.3); -+ if (value === 0) continue; -+ context.fillStyle = value === 1 ? foreground : spot; -+ context.fillRect(column * 16, row * 16, 16, 16); -+ context.fillRect((7 - column) * 16, row * 16, 16, 16); -+ } -+ } -+ return canvas.convertToBlob({ type: 'image/png' }); -+} -+ -+function release(id) { -+ const uri = requests.get(id); -+ requests.delete(id); -+ priorities.delete(id); -+ const image = images.get(uri); -+ image?.references.delete(id); -+ if (image && image.references.size === 0) { -+ URL.revokeObjectURL(image.url); -+ images.delete(uri); -+ } -+ const job = jobs.get(uri); -+ job?.ids.delete(id); -+ if (job && job.ids.size === 0 && !job.active) { -+ jobs.delete(uri); -+ const index = queue.indexOf(job); -+ if (index !== -1) queue.splice(index, 1); -+ } -+} -+ -+async function runJob(job) { -+ // OneKey patch: reuse generated bytes even if the last UI lease was released -+ // while persistence is still in flight; these bytes already passed generation. -+ const pending = pendingBlobs.get(job.uri); -+ let blob = pending ?? await readDisk(job.uri); -+ let persisted = !!blob && !pending; -+ if (blob && !pending) { -+ try { -+ const bitmap = await createImageBitmap(blob); -+ const valid = bitmap.width === 128 && bitmap.height === 128; -+ bitmap.close(); -+ if (!valid) blob = undefined; -+ } catch { blob = undefined; } -+ } -+ if (!blob && job.ids.size) { -+ persisted = false; -+ blob = await generateBlob(job.seed); -+ // OneKey patch: notify clients and free the load slot without awaiting I/O. -+ // await writeDisk(job.uri, blob); -+ persistLater(job.uri, blob); -+ } -+ if (!blob || !job.ids.size) return; -+ const image = { url: URL.createObjectURL(blob), blob, persisted, references: new Set() }; -+ images.set(job.uri, image); -+ job.ids.forEach((id) => { -+ if (requests.get(id) !== job.uri) return; -+ image.references.add(id); -+ postMessage({ type: 'resolved', id, url: image.url }); -+ }); -+ if (!image.references.size) { URL.revokeObjectURL(image.url); images.delete(job.uri); } -+} -+ -+function pump() { -+ while (activeLoads < MAX_CONCURRENT_LOADS && queue.length) { -+ // OneKey patch: queued leases can be promoted without restarting their load. -+ // const job = queue.shift(); -+ let next = 0; -+ for (let index = 1; index < queue.length; index += 1) { -+ if (jobPriority(queue[index]) < jobPriority(queue[next])) next = index; -+ } -+ const [job] = queue.splice(next, 1); -+ if (jobs.get(job.uri) !== job || !job.ids.size) continue; -+ job.active = true; -+ activeLoads += 1; -+ runJob(job).catch(() => { -+ job.ids.forEach((id) => { -+ if (requests.get(id) === job.uri) postMessage({ type: 'error', id }); -+ }); -+ }).finally(() => { -+ if (jobs.get(job.uri) === job) jobs.delete(job.uri); -+ activeLoads -= 1; -+ pump(); -+ void drainDiskQueue(); -+ }); -+ } -+} -+ -+onmessage = ({ data }) => { -+ if (!data || !Number.isSafeInteger(data.id)) return; -+ if (data.type === 'release') { release(data.id); return; } -+ if (data.type === 'priority') { -+ if (requests.has(data.id)) priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); -+ return; -+ } -+ if (data.type !== 'acquire') return; -+ release(data.id); -+ lastAcquire = Date.now(); -+ scheduleDiskWork(); -+ try { -+ if (typeof data.uri !== 'string' || !data.uri.startsWith(PREFIX)) throw new Error('Invalid avatar URI'); -+ const seed = decodeURIComponent(data.uri.slice(PREFIX.length)).toLowerCase(); -+ if (!seed) throw new Error('Empty avatar seed'); -+ const uri = PREFIX + encodeURIComponent(seed); -+ requests.set(data.id, uri); -+ priorities.set(data.id, data.priority === 0 ? 0 : data.priority === 1 ? 1 : 2); -+ const image = images.get(uri); -+ if (image) { -+ // Reacquiring a live Blob can retry best-effort persistence after queue pressure. -+ if (!image.persisted) persistLater(uri, image.blob); -+ image.references.add(data.id); -+ postMessage({ type: 'resolved', id: data.id, url: image.url }); -+ return; -+ } -+ let job = jobs.get(uri); -+ if (!job) { -+ job = { uri, seed, ids: new Set(), active: false }; -+ jobs.set(uri, job); -+ queue.push(job); -+ } -+ job.ids.add(data.id); -+ pump(); -+ } catch { postMessage({ type: 'error', id: data.id }); } -+}; -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts -new file mode 100644 -index 0000000..d921a4c ---- /dev/null -+++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebAvatarCache.ts -@@ -0,0 +1,264 @@ -+// OneKey patch: avatar bytes stay in a worker and IndexedDB, outside list snapshots. -+const AVATAR_PREFIX = 'onekey-avatar://blockie/v1/'; -+const MAX_RETAINED_AVATARS = 128; -+export type AvatarLease = (() => void) & { setPriority: (priority: number) => void }; -+ -+type AvatarEntry = { -+ id: number; -+ uri: string; -+ url?: string; -+ references: number; -+ listeners: Set<{ resolve: (url: string) => void; reject: () => void; priority: number }>; -+}; -+ -+type AvatarResponse = -+ | { type: 'resolved'; id: number; url: string } -+ | { type: 'error'; id: number }; -+ -+export function canonicalNativeListAvatarUri(uri: string): string | undefined { -+ if (!uri.startsWith(AVATAR_PREFIX)) return undefined; -+ try { -+ const seed = decodeURIComponent(uri.slice(AVATAR_PREFIX.length)); -+ return seed ? AVATAR_PREFIX + encodeURIComponent(seed.toLowerCase()) : undefined; -+ } catch { -+ return undefined; -+ } -+} -+ -+class NativeListWebAvatarCache { -+ private readonly entries = new Map(); -+ private readonly requests = new Map(); -+ private nextId = 0; -+ private worker: Worker | undefined; -+ // OneKey patch: one cancellable startup serves every pending avatar lease. -+ private workerGeneration = 0; -+ private startingWorker: { generation: number; controller?: AbortController } | undefined; -+ -+ acquire(uri: string, resolve: (url: string) => void, reject: () => void, priority = 2): AvatarLease { -+ let entry = this.entries.get(uri); -+ const isNew = !entry; -+ if (!entry) { -+ entry = { id: ++this.nextId, uri, references: 0, listeners: new Set() }; -+ this.entries.set(uri, entry); -+ this.requests.set(entry.id, entry); -+ } -+ const current = entry; -+ this.entries.delete(uri); -+ this.entries.set(uri, current); -+ current.references += 1; -+ const listener = { resolve, reject, priority }; -+ if (current.url) resolve(current.url); -+ // OneKey patch: keep lease priority after resolution as well as while queued. -+ // else current.listeners.add(listener); -+ current.listeners.add(listener); -+ if (isNew) { -+ // OneKey patch: file workers need async asset loading; keep pending requests in this cache. -+ // try { -+ // if (!this.worker) { -+ // // Deliberately avoid *.worker.js and its inline Blob-worker loader. -+ // this.worker = new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { -+ // name: 'onekey-native-list-avatar', -+ // }); -+ // this.worker.addEventListener('message', this.handleMessage); -+ // this.worker.addEventListener('error', this.handleFailure); -+ // this.worker.addEventListener('messageerror', this.handleFailure); -+ // } -+ // this.worker.postMessage({ type: 'acquire', id: current.id, uri, priority }); -+ // } catch { -+ // queueMicrotask(this.handleFailure); -+ // } -+ if (this.worker) { -+ this.sendAcquire(current); -+ } else { -+ this.startWorker(); -+ } -+ } -+ const updatePriority = () => { -+ if (current.url) return; -+ const priorities = [...current.listeners].map((value) => value.priority); -+ this.worker?.postMessage({ type: 'priority', id: current.id, priority: Math.min(2, ...priorities) }); -+ }; -+ updatePriority(); -+ this.trim(); -+ let disposed = false; -+ const release = () => { -+ if (disposed) return; -+ disposed = true; -+ current.listeners.delete(listener); -+ current.references = Math.max(0, current.references - 1); -+ if (current.references === 0 && !current.url && this.entries.get(uri) === current) { -+ this.entries.delete(uri); -+ this.requests.delete(current.id); -+ this.worker?.postMessage({ type: 'release', id: current.id }); -+ if (this.requests.size === 0 && this.startingWorker) { -+ const starting = this.startingWorker; -+ this.startingWorker = undefined; -+ this.workerGeneration += 1; -+ starting.controller?.abort(); -+ } -+ } -+ updatePriority(); -+ this.trim(); -+ }; -+ return Object.assign(release, { -+ setPriority: (next: number) => { -+ if (disposed || listener.priority === next) return; -+ listener.priority = next; -+ updatePriority(); -+ }, -+ }); -+ } -+ -+ // OneKey patch: HTTP/extension keeps the bundler's original Worker entry. Electron's -+ // file interceptor can serve the same self-contained source as an asset for a Blob worker. -+ private startWorker() { -+ if (this.worker || this.startingWorker) return; -+ const starting: NonNullable = { -+ generation: ++this.workerGeneration, -+ }; -+ this.startingWorker = starting; -+ const assetURL = new URL('./NativeListAvatarWorker.js', import.meta.url); -+ if (assetURL.protocol === 'file:') { -+ starting.controller = new AbortController(); -+ void (async () => { -+ const response = await fetch(assetURL, { -+ signal: starting.controller?.signal, -+ }); -+ if (!response.ok) throw new Error('NativeList avatar worker asset failed to load'); -+ const source = await response.blob(); -+ if (this.startingWorker !== starting) return; -+ const sourceURL = URL.createObjectURL(source); -+ try { -+ this.activateWorker(new Worker(sourceURL, { name: 'onekey-native-list-avatar' }), starting); -+ } finally { -+ // Worker construction captures the script URL; the document must not retain its bytes. -+ URL.revokeObjectURL(sourceURL); -+ } -+ })().catch(() => { -+ if (this.startingWorker === starting || this.workerGeneration === starting.generation) { -+ this.handleFailure(); -+ } -+ }); -+ return; -+ } -+ try { -+ this.activateWorker(new Worker(new URL('./NativeListAvatarWorker.js', import.meta.url), { -+ name: 'onekey-native-list-avatar', -+ }), starting); -+ } catch { -+ queueMicrotask(() => { -+ if (this.workerGeneration === starting.generation) this.handleFailure(); -+ }); -+ } -+ } -+ -+ private activateWorker(worker: Worker, starting: NonNullable) { -+ if (this.startingWorker !== starting) { -+ worker.terminate(); -+ return; -+ } -+ this.startingWorker = undefined; -+ this.worker = worker; -+ // OneKey patch: queued events from a failed instance cannot affect its replacement. -+ worker.addEventListener('message', (event: MessageEvent) => { -+ if (this.worker === worker) this.handleMessage(event); -+ }); -+ const failed = () => { if (this.worker === worker) this.handleFailure(); }; -+ worker.addEventListener('error', failed); -+ worker.addEventListener('messageerror', failed); -+ this.requests.forEach((entry) => { -+ if (entry.references > 0 && !entry.url) this.sendAcquire(entry); -+ }); -+ } -+ -+ private sendAcquire(entry: AvatarEntry) { -+ const worker = this.worker; -+ try { -+ worker?.postMessage({ -+ type: 'acquire', id: entry.id, uri: entry.uri, -+ priority: Math.min(2, ...[...entry.listeners].map((listener) => listener.priority)), -+ }); -+ } catch { -+ queueMicrotask(() => { -+ if (this.worker === worker) this.handleFailure(); -+ }); -+ } -+ } -+ -+ private readonly handleMessage = (event: MessageEvent) => { -+ const response = event.data; -+ if (!response || !Number.isSafeInteger(response.id)) return; -+ const entry = this.requests.get(response.id); -+ if (!entry) { -+ this.worker?.postMessage({ type: 'release', id: response.id }); -+ return; -+ } -+ if (response.type === 'resolved' && typeof response.url === 'string' && response.url.startsWith('blob:')) { -+ entry.url = response.url; -+ entry.listeners.forEach((listener) => listener.resolve(response.url)); -+ } else { -+ this.entries.delete(entry.uri); -+ this.requests.delete(entry.id); -+ this.worker?.postMessage({ type: 'release', id: entry.id }); -+ entry.listeners.forEach((listener) => listener.reject()); -+ } -+ // OneKey patch: retain resolved lease priorities until their explicit release. -+ // entry.listeners.clear(); -+ if (!entry.url) entry.listeners.clear(); -+ this.trim(); -+ }; -+ -+ private readonly handleFailure = () => { -+ // OneKey patch: detach the failed generation before callbacks can acquire a replacement. -+ // this.worker?.terminate(); -+ // this.worker = undefined; -+ // this.requests.forEach((entry) => { -+ // if (!entry.url) entry.listeners.forEach((listener) => listener.reject()); -+ // entry.listeners.clear(); -+ // }); -+ // this.requests.clear(); -+ // this.entries.clear(); -+ const failedEntries = [...this.requests.values()]; -+ const starting = this.startingWorker; -+ this.startingWorker = undefined; -+ this.workerGeneration += 1; -+ starting?.controller?.abort(); -+ this.worker?.terminate(); -+ this.worker = undefined; -+ this.requests.clear(); -+ this.entries.clear(); -+ failedEntries.forEach((entry) => { -+ const listeners = [...entry.listeners]; -+ entry.listeners.clear(); -+ if (!entry.url) listeners.forEach((listener) => listener.reject()); -+ }); -+ }; -+ -+ private trim() { -+ for (const [uri, entry] of this.entries) { -+ if (this.entries.size <= MAX_RETAINED_AVATARS) break; -+ if (entry.references === 0) { -+ this.entries.delete(uri); -+ this.requests.delete(entry.id); -+ this.worker?.postMessage({ type: 'release', id: entry.id }); -+ } -+ } -+ } -+} -+ -+const documentCaches = new WeakMap(); -+ -+export function acquireNativeListAvatar( -+ document: Document, -+ uri: string, -+ resolve: (url: string) => void, -+ reject: () => void, -+ priority = 2 -+): AvatarLease { -+ let cache = documentCaches.get(document); -+ if (!cache) { -+ cache = new NativeListWebAvatarCache(); -+ documentCaches.set(document, cache); -+ } -+ return cache.acquire(uri, resolve, reject, priority); -+} -diff --git a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -index 11d57d1..75364d7 100644 ---- a/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -+++ b/node_modules/@onekeyfe/react-native-native-list/src/web/NativeListWebEngine.ts -@@ -37,8 +37,14 @@ import { - type NormalizedPositionScroll, - } from '../scrolling'; - import { applyRowPatches, validateSnapshot } from '../validation'; -- --const SECTION_INDEX_GUTTER = 44; -+import { avatarPrefetchWindow } from '../avatarPrefetch'; -+import { acquireNativeListAvatar, canonicalNativeListAvatarUri, type AvatarLease } from './NativeListWebAvatarCache'; -+ -+const SECTION_INDEX_CONTENT_INSET = 16; -+const SECTION_INDEX_RAIL_WIDTH = 32; -+const SECTION_INDEX_EDGE_PADDING = 8; -+const SECTION_INDEX_MIN_LABEL_SPACING = 14; -+const SECTION_INDEX_MIN_HEIGHT = 120; - const DEFAULT_VIEWPORT_WIDTH = 320; - const DEFAULT_VIEWPORT_HEIGHT = 640; - const OVERSCAN_VIEWPORTS = 1; -@@ -354,11 +360,23 @@ export function estimateWebRowHeight( - snapshot: NativeListSnapshot, - availableWidth: number - ): number { -- if (row.type === 'system' && row.variant === 'spacer') return row.height; -+ // OneKey patch: explicit selector height takes precedence over presets. -+ // if (row.type === 'system' && row.variant === 'spacer') return row.height; -+ if (row.height !== undefined) return row.height; -+ if (row.type === 'system' && row.variant === 'warning') { -+ const width = Math.max(1, availableWidth - 24); -+ const lines = (text: string) => Math.max(1, Math.ceil(Array.from(text).reduce((length, char) => length + (char.charCodeAt(0) > 255 ? 14 : 7), 0) / width)); -+ return 32 + 20 * (lines(row.title) + lines(row.message)); -+ } - if (row.type === 'walletGroup') -- return (row.children.length + 1) * 68 + row.children.length * 12; -+ // OneKey patch: wallet badges participate in the outer group height. -+ // return (row.children.length + 1) * 68 + row.children.length * 12; -+ return [row.parent, ...row.children].reduce((height, member) => height + estimateWebRowHeight(member, snapshot, availableWidth), 0) + row.children.length * 12 + (row.parent.height !== undefined ? 2 : 0); -+ // OneKey patch: reserve the source badge line below wallet names. -+ // if (row.type === 'identity' && row.presentation === 'walletSidebar') -+ // return 68; - if (row.type === 'identity' && row.presentation === 'walletSidebar') -- return 68; -+ return 68 + (row.badges?.length ? 24 : 0); - if (row.type === 'identity' && row.presentation === 'networkSelector') - return 47; - if (row.type === 'identity' && row.presentation === 'accountSelector') -@@ -471,9 +489,13 @@ export function computeWebListLayout( - const horizontal = snapshot.layout.orientation === 'horizontal'; - const spacing = snapshot.layout.itemSpacing ?? 0; - const padding = paddingValues(snapshot); -- const indexGutter = sectionIndexEnabled(snapshot) ? SECTION_INDEX_GUTTER : 0; - const width = Math.max(1, viewportWidth || DEFAULT_VIEWPORT_WIDTH); - const height = Math.max(1, viewportHeight || DEFAULT_VIEWPORT_HEIGHT); -+ // OneKey patch: the index overlays the content and only keeps a small accessory-safe inset. -+ const indexGutter = -+ sectionIndexEnabled(snapshot) && height >= SECTION_INDEX_MIN_HEIGHT -+ ? SECTION_INDEX_CONTENT_INSET -+ : 0; - const availableWidth = Math.max( - 1, - width - padding.horizontal * 2 - indexGutter -@@ -699,7 +721,9 @@ export function webRowRenderSignature(row: RowModel): string { - return JSON.stringify(rowWithoutSelectionState(row)); - } - -+// OneKey patch: selector names must shrink before the sidebar clips their contents. - export const WEB_LIST_CSS = ` -+[data-native-list-selector="walletSidebar"] .ok-native-list-title{max-width:100%;min-width:0} - .ok-native-list-root{--nl-bg:#f7f7f7;--nl-row:#fff;--nl-selected:#eaf2ff;--nl-pressed:#e8e8e8;--nl-subdued:#f9f9f9;--nl-strong:#0000000f;--nl-primary:#111;--nl-secondary:#6b7280;--nl-disabled:#8d8d8d;--nl-icon:#111;--nl-icon-subdued:#8d8d8d;--nl-separator:#e5e7eb;--nl-accent:#2f6bff;--nl-positive:#15803d;--nl-negative:#dc2626;--nl-critical:#feecec;--nl-inverse:#202020;--nl-inverse-text:#fcfcfc;--nl-info:#0d74ce;position:absolute;inset:0;display:flex;min-width:0;min-height:0;overflow:hidden;background:var(--nl-bg);color:var(--nl-primary);font-family:Roobert,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none} - .ok-native-list-viewport-frame{position:relative;flex:1;min-width:0;min-height:0;overflow:hidden} - .ok-native-list-viewport{position:absolute;inset:0;overflow:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;scrollbar-gutter:stable} -@@ -710,6 +734,7 @@ export const WEB_LIST_CSS = ` - .ok-native-list-item[data-native-list-selected="true"]>.ok-native-list-row{background:var(--nl-selected)} - .ok-native-list-item[data-native-list-disabled="true"]>.ok-native-list-row{opacity:.5;cursor:default} - .ok-native-list-item:not([data-native-list-disabled="true"]):hover>.ok-native-list-row{background:var(--nl-pressed)} -+.ok-native-list-item:not([data-native-list-disabled="true"]):active>.ok-native-list-row{background:var(--nl-pressed)} - .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):hover>.ok-native-list-wallet-row{background:var(--nl-strong)} - .ok-native-list-item:not([data-native-list-disabled="true"]):not([data-native-list-selected="true"]):active>.ok-native-list-wallet-row{background:var(--nl-pressed)} - .ok-native-list-item[data-native-list-selected="true"]:hover>.ok-native-list-wallet-row{background:var(--nl-selected)} -@@ -752,9 +777,29 @@ export const WEB_LIST_CSS = ` - .ok-native-list-media{display:block;padding:0 5px;background:transparent;border-radius:16px}.ok-native-list-media-image{display:block;width:100%;aspect-ratio:1;border-radius:10px;background:var(--nl-strong);object-fit:cover}.ok-native-list-media-image[data-state="empty"]{background:transparent}.ok-native-list-media-image[data-state="error"]{display:flex;align-items:center;justify-content:center;color:var(--nl-icon-subdued);font-size:24px}.ok-native-list-media-meta{padding-top:7px}.ok-native-list-media-subtitle-row{display:flex;align-items:center;gap:6px}.ok-native-list-media-subtitle{flex:1;min-width:0;font-size:12px;color:var(--nl-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-network{width:14px;height:14px;border-radius:50%}.ok-native-list-media-title{font-size:16px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.ok-native-list-media-close{position:absolute;right:9px;top:4px;border:0;background:color-mix(in srgb,var(--nl-inverse) 72%,transparent);color:var(--nl-inverse-text);width:24px;height:24px;border-radius:50%;font:18px/20px inherit;cursor:pointer} - .ok-native-list-metric{display:flex;flex-direction:column;align-items:flex-start;padding:12px;border-radius:12px;gap:5px;background:var(--nl-row)}.ok-native-list-metric-value{font-size:22px;line-height:28px;font-weight:700}.ok-native-list-composite{display:flex;flex-direction:column;align-items:stretch;padding:14px;border-radius:12px;gap:12px;background:var(--nl-subdued)}.ok-native-list-composite-heading{font-size:14px;letter-spacing:1px;color:var(--nl-secondary)}.ok-native-list-composite-row{display:flex;gap:12px}.ok-native-list-composite-cell{flex:1;min-width:0}.ok-native-list-composite-cell[data-shaded="true"]{padding:10px;border-radius:10px;background:color-mix(in srgb,var(--nl-primary) 5%,transparent)}.ok-native-list-composite-value{font-size:18px;font-weight:600}.ok-native-list-divider{height:1px;background:var(--nl-separator)}.ok-native-list-progress{height:4px;border-radius:2px;overflow:hidden;background:var(--nl-negative)}.ok-native-list-progress>span{display:block;height:100%;border-radius:2px;background:var(--nl-positive)} - .ok-native-list-data{padding:6px 12px}.ok-native-list-index{flex:0 0 28px;color:var(--nl-secondary);font-size:13px}.ok-native-list-favorite{flex:0 0 24px;color:var(--nl-icon-subdued);font-size:22px}.ok-native-list-favorite[data-active="true"]{color:var(--nl-accent)}.ok-native-list-data-cell{display:flex;flex-direction:column;min-width:0}.ok-native-list-data-cell[data-align="center"]{align-items:center}.ok-native-list-data-cell[data-align="end"]{align-items:flex-end}.ok-native-list-data-primary{display:flex;align-items:center;gap:5px;max-width:100%;font-size:16px;font-weight:500;white-space:nowrap}.ok-native-list-unread{width:7px;height:7px;flex:0 0 7px;border-radius:50%;background:var(--nl-accent)}.ok-native-list-thumbnail{width:64px;height:64px;border-radius:10px;object-fit:cover} --.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:44px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0;touch-action:none}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;border:0;background:transparent;display:flex;flex:1;max-height:22px;min-height:12px;width:100%;align-items:center;justify-content:center;padding:0;color:var(--nl-secondary);font:600 11px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{color:var(--nl-accent)}.ok-native-list-index-preview{position:absolute;z-index:8;left:50%;top:50%;display:flex;width:72px;height:72px;align-items:center;justify-content:center;transform:translate(-50%,-50%) scale(.92);border-radius:16px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:28px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translate(-50%,-50%) scale(1)} -+.ok-native-list-footer{flex:0 0 auto;min-height:0}.ok-native-list-sticky{position:absolute;z-index:4;left:0;right:0;top:0;pointer-events:auto;box-shadow:0 1px 0 var(--nl-separator)}.ok-native-list-index-rail{position:absolute;z-index:6;top:0;right:0;bottom:0;width:${SECTION_INDEX_RAIL_WIDTH}px;touch-action:none;cursor:pointer}.ok-native-list-index-rail[hidden]{display:none}.ok-native-list-index-button{appearance:none;position:absolute;left:6px;display:flex;width:20px;height:16px;align-items:center;justify-content:center;padding:0;transform:translateY(-50%);border:0;border-radius:8px;background:transparent;color:var(--nl-secondary);font:600 10px/1 inherit;cursor:pointer}.ok-native-list-index-button[data-active="true"]{background:var(--nl-accent);color:var(--nl-inverse-text)}.ok-native-list-index-button:focus-visible{outline:2px solid var(--nl-accent);outline-offset:1px}.ok-native-list-index-preview{position:absolute;z-index:8;right:40px;top:50%;display:flex;width:48px;height:48px;align-items:center;justify-content:center;transform:translateY(-50%) scale(.92);border-radius:14px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:22px;font-weight:600;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}.ok-native-list-index-preview[data-visible="true"]{opacity:1;transform:translateY(-50%) scale(1)} - .ok-native-list-refresh{position:absolute;z-index:7;left:50%;top:8px;display:flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;background:var(--nl-inverse);color:var(--nl-inverse-text);font-size:12px;opacity:0;transform:translate(-50%,-16px);transition:opacity .15s ease,transform .15s ease;pointer-events:none}.ok-native-list-refresh[data-visible="true"]{opacity:1;transform:translate(-50%,0)} -+.ok-native-list-warning{height:auto;display:flex;flex-direction:column;align-items:stretch;gap:4px;padding:14px 12px;border-top:1px solid;border-bottom:1px solid;box-sizing:border-box;cursor:default}.ok-native-list-warning-title,.ok-native-list-warning-message{font-size:14px;line-height:20px;white-space:normal;overflow-wrap:anywhere}.ok-native-list-warning-title{font-weight:500;color:var(--nl-primary)}.ok-native-list-warning-message{font-weight:400;color:var(--nl-secondary)} -+.ok-native-list-subtitle-segments{display:flex;align-items:center;min-width:0;max-width:100%;height:20px}.ok-native-list-subtitle-segments>.ok-native-list-secondary{flex:0 1 auto;min-width:0}.ok-native-list-subtitle-dot{flex:0 0 4px;width:4px;height:4px;margin:0 6px;border-radius:50%;background:var(--nl-disabled)}.ok-native-list-wallet-row>.ok-native-list-flex{flex:0 1 auto;width:100%;align-items:center}.ok-native-list-wallet-badges{display:flex;gap:4px;justify-content:center;margin-top:4px;height:20px;max-width:100%}.ok-native-list-wallet-badges>.ok-native-list-badge{background:var(--nl-strong);color:var(--nl-secondary);font-size:12px;line-height:16px;height:20px;box-sizing:border-box;padding:2px 4px}.ok-native-list-visual-overlay{position:absolute;display:flex;align-items:center;justify-content:center;box-sizing:border-box;border-radius:50%;overflow:hidden;line-height:1;font-size:10px}.ok-native-list-visual-overlay img,.ok-native-list-visual-overlay svg{width:100%;height:100%;object-fit:contain} - @media (prefers-reduced-motion:reduce){.ok-native-list-index-preview,.ok-native-list-refresh{transition:none}.ok-native-list-spinner{animation:none}} -+/* OneKey patch: selector controls follow their original semantic colors and geometry. */ -+.ok-native-list-checkbox[data-selector="networkSelector"]{padding:0;border-radius:4px;border-color:var(--nl-checkbox-border,var(--nl-separator));background:var(--nl-checkbox-icon,var(--nl-inverse-text))} -+.ok-native-list-checkbox[data-selector="networkSelector"]::after{display:none} -+.ok-native-list-checkbox[data-selector="networkSelector"]>svg{display:none;width:16px;height:16px;color:var(--nl-checkbox-icon,var(--nl-inverse-text));flex-shrink:0} -+.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]{border-color:transparent;background:var(--nl-checkbox-background,var(--nl-primary))} -+.ok-native-list-checkbox[data-selector="networkSelector"][data-state="checked"]>svg[data-state="checked"],.ok-native-list-checkbox[data-selector="networkSelector"][data-state="indeterminate"]>svg[data-state="indeterminate"]{display:block} -+/* OneKey patch: source WalletListItem uses four-point padding on every side. */ -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"]{border-radius:12px;padding:4px} -+/* OneKey patch: selected group members use the same primary title color as standalone wallets. */ -+.ok-native-list-wallet-member[data-native-list-selected="true"]>.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-title{color:var(--nl-primary)} -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges{height:18px} -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge{font-size:11px;line-height:14px;font-weight:400;height:18px;padding:2px 6px;border-radius:4px;background:var(--nl-subdued);color:var(--nl-secondary)} -+.ok-native-list-wallet-row[data-native-list-selector="walletSidebar"] .ok-native-list-wallet-badges>.ok-native-list-badge[data-tone="warning"]{background:var(--nl-caution-background);color:var(--nl-caution)} -+.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>.ok-native-list-icon-button{box-sizing:border-box;flex:0 0 38px;width:38px;height:38px;margin:-7px;padding:7px} -+/* OneKey patch: AccountSelectorAccountListItem fixes the borderless Plus slot at top18/right20. */ -+.ok-native-list-account-row[data-native-list-selector="accountSelector"]>.ok-native-list-accessories[data-native-list-account-control="createAddress"]{position:absolute;top:18px;right:12px} -+.ok-native-list-account-row[data-native-list-selector="accountSelector"] .ok-native-list-accessories>[data-native-list-account-control="createAddress"]{flex-basis:36px;width:36px;height:36px;padding:6px;border-radius:8px} -+.ok-native-list-account-action-row{padding-left:12px;padding-right:12px}.ok-native-list-account-action-row .ok-native-list-action-title{font-size:16px;line-height:24px;font-weight:400}.ok-native-list-account-action-row .ok-native-list-action-title[data-tone="primary"]{color:var(--nl-primary)} - `; - - function createElement( -@@ -788,16 +833,114 @@ function safeImageUri(uri: string): string | undefined { - return undefined; - } - -+// OneKey patch: retries belong to an image binding and must not outlive recycled rows. -+const webImageRetryCleanup = new WeakMap void>(); -+const webAvatarCleanup = new WeakMap void>(); -+const webAvatarSources = new WeakMap(); -+function disposeWebImageRetries(element: HTMLElement): boolean { -+ let disposed = false; -+ const images = element.matches('img') ? [element as HTMLImageElement] : element.querySelectorAll('img'); -+ images.forEach((image) => { -+ const avatarCleanup = webAvatarCleanup.get(image); -+ const cleanup = webImageRetryCleanup.get(image); -+ if (!cleanup && !avatarCleanup) return; -+ avatarCleanup?.(); -+ webAvatarCleanup.delete(image); -+ webAvatarSources.delete(image); -+ cleanup?.(); -+ webImageRetryCleanup.delete(image); -+ disposed = true; -+ }); -+ return disposed; -+} -+ -+function configureWebImageRetry( -+ image: HTMLImageElement, -+ source: ImageSource, -+ initialUri: string -+) { -+ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; -+ const retryLimit = Number.isFinite(source.retryTimes) -+ ? Math.max(0, Math.floor(source.retryTimes ?? 0)) -+ : 0; -+ if (!fallbackUri && retryLimit === 0) return; -+ const view = image.ownerDocument.defaultView; -+ let currentUri = initialUri; -+ let usedFallback = false; -+ let retryCount = 0; -+ let retryTimer: number | undefined; -+ let disposed = false; -+ const clearRetry = () => { -+ if (retryTimer !== undefined) view?.clearTimeout(retryTimer); -+ retryTimer = undefined; -+ }; -+ const handleError = (event: Event) => { -+ if (disposed || !image.isConnected || retryTimer !== undefined) { -+ event.stopImmediatePropagation(); -+ return; -+ } -+ if (fallbackUri && !usedFallback && fallbackUri !== currentUri) { -+ event.stopImmediatePropagation(); -+ usedFallback = true; -+ currentUri = fallbackUri; -+ image.src = currentUri; -+ return; -+ } -+ if (retryCount >= retryLimit || !view) return; -+ event.stopImmediatePropagation(); -+ retryCount += 1; -+ retryTimer = view.setTimeout(() => { -+ retryTimer = undefined; -+ if (disposed || !image.isConnected) return; -+ image.removeAttribute('src'); -+ image.src = currentUri; -+ }, Math.floor(Math.random() * 3) * 1000); -+ }; -+ image.addEventListener('error', handleError); -+ image.addEventListener('load', clearRetry); -+ webImageRetryCleanup.set(image, () => { -+ disposed = true; -+ clearRetry(); -+ image.removeEventListener('error', handleError); -+ image.removeEventListener('load', clearRetry); -+ }); -+} -+ -+function configureWebAvatar(image: HTMLImageElement, source: ImageSource, uri: string) { -+ const dispose = acquireNativeListAvatar(image.ownerDocument, uri, (resolvedUri) => { -+ configureWebImageRetry(image, source, resolvedUri); -+ image.src = resolvedUri; -+ }, () => { -+ const fallbackUri = source.fallbackUri ? safeImageUri(source.fallbackUri) : undefined; -+ if (fallbackUri) { -+ configureWebImageRetry(image, source, fallbackUri); -+ image.src = fallbackUri; -+ return; -+ } -+ const ImageEvent = image.ownerDocument.defaultView?.Event; -+ if (ImageEvent) image.dispatchEvent(new ImageEvent('error')); -+ }); -+ webAvatarSources.set(image, { uri, source }); -+ webAvatarCleanup.set(image, dispose); -+} -+ - function createImage( - context: RenderContext, - source: ImageSource, - className?: string - ): HTMLImageElement | undefined { -- const uri = safeImageUri(source.uri); -+ const avatarUri = canonicalNativeListAvatarUri(source.uri); -+ const uri = avatarUri ?? safeImageUri(source.uri); - if (!uri) return undefined; - const image = context.document.createElement('img'); - if (className) image.className = className; -- image.src = uri; -+ // OneKey patch: consume recoverable errors before the visual's final fallback listener. -+ if (avatarUri) { -+ configureWebAvatar(image, source, avatarUri); -+ } else { -+ configureWebImageRetry(image, source, uri); -+ image.src = uri; -+ } - image.alt = ''; - image.draggable = false; - image.loading = 'lazy'; -@@ -807,6 +950,43 @@ function createImage( - return image; - } - -+// OneKey patch: React Native Web paints selector images as centered CSS backgrounds. -+// The image keeps its loading/error lifecycle; only its replaced-element pixels are hidden. -+function paintSelectorImageBackground(image: HTMLImageElement, frame: HTMLElement, inset = 0) { -+ const paint = createElement(image.ownerDocument, 'span', 'ok-native-list-selector-image-background'); -+ paint.style.cssText = 'position:absolute;pointer-events:none;border-radius:inherit;background-position:center;background-repeat:no-repeat'; -+ paint.style.inset = String(inset) + 'px'; -+ paint.style.backgroundSize = image.style.objectFit === 'fill' ? '100% 100%' : image.style.objectFit === 'center' ? 'auto' : image.style.objectFit; -+ image.style.opacity = '0'; -+ const update = () => { paint.style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; }; -+ image.addEventListener('load', update); -+ image.addEventListener('error', () => { paint.style.backgroundImage = 'none'; }); -+ frame.insertBefore(paint, image); -+ if (image.complete && image.naturalWidth > 0) update(); -+} -+ -+// OneKey patch: use source SVG paths for selector actions and wallet provider marks. -+const selectorIcons: Readonly[] }>>> = {"GlobusOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M12 2c5.185 0 9.448 3.947 9.95 9H22v2h-.05c-.502 5.053-4.765 9-9.95 9s-9.448-3.947-9.95-9H2v-2h.05C2.552 5.947 6.815 2 12 2M9.523 13c.09 1.982.438 3.726.934 5.002.29.746.612 1.282.917 1.614.304.331.517.384.626.384s.322-.053.626-.384c.305-.332.627-.868.917-1.614.496-1.276.845-3.02.934-5.002zm-5.459 0a8 8 0 0 0 4.8 6.36 10 10 0 0 1-.271-.633C7.994 17.187 7.61 15.189 7.52 13zm12.416 0c-.09 2.189-.474 4.187-1.073 5.727a10 10 0 0 1-.271.633 8 8 0 0 0 4.8-6.36zM8.863 4.639A8 8 0 0 0 4.064 11h3.457c.09-2.189.473-4.187 1.072-5.727q.127-.327.27-.634M12 4c-.109 0-.322.053-.626.384-.305.332-.627.868-.917 1.614-.496 1.276-.844 3.02-.934 5.002h4.954c-.09-1.982-.438-3.726-.934-5.002-.29-.746-.612-1.282-.917-1.614C12.322 4.053 12.109 4 12 4m3.136.639q.144.307.271.634c.599 1.54.982 3.538 1.073 5.727h3.456a8 8 0 0 0-4.8-6.361","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"LockSolid":{"viewBox":"0 0 24 24","paths":[{"d":"M12 2a5 5 0 0 1 5 5v2h3v13H4V9h3V7a5 5 0 0 1 5-5m-1 11v5h2v-5zm1-9a3 3 0 0 0-3 3v2h6V7a3 3 0 0 0-3-3","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"GoogleIllus":{"viewBox":"0 0 24 24","paths":[{"d":"M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09","fill":"#4285F4","fillRule":"nonzero","opacity":1.0},{"d":"M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23","fill":"#34A853","fillRule":"nonzero","opacity":1.0},{"d":"M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22z","fill":"#FBBC05","fillRule":"nonzero","opacity":1.0},{"d":"M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53","fill":"#EA4335","fillRule":"nonzero","opacity":1.0}]},"AppleBrand":{"viewBox":"0 0 16 20","paths":[{"d":"M11.67.834c.117 1.074-.315 2.153-.955 2.928-.64.773-1.692 1.378-2.718 1.298-.14-1.054.38-2.151.971-2.836C9.63 1.45 10.746.872 11.67.834M14.994 7.093c-.176.108-1.992 1.224-1.972 3.482.025 2.769 2.428 3.693 2.46 3.705l-.004.015a10.1 10.1 0 0 1-1.264 2.593c-.764 1.116-1.556 2.229-2.806 2.254-.598.011-1-.162-1.416-.343-.437-.19-.891-.386-1.609-.386-.751 0-1.226.203-1.683.398-.397.169-.78.333-1.32.354-1.208.047-2.124-1.207-2.895-2.32C.909 14.57-.294 10.414 1.322 7.612c.803-1.395 2.237-2.275 3.794-2.298.671-.014 1.32.244 1.89.47.434.172.821.326 1.135.326.282 0 .659-.149 1.099-.323.692-.273 1.539-.607 2.41-.518.599.026 2.276.24 3.354 1.818z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"BotIllus":{"viewBox":"0 0 24 24","paths":[{"d":"M11 2a1 1 0 1 1 2 0v1.8l1.6 1.6a1 1 0 1 1-1.4 1.4L12 5.6l-1.2 1.2a1 1 0 0 1-1.4-1.4L11 3.8z","fill":"#8897A5","fillRule":"nonzero","opacity":1.0},{"d":"M8.0 6.0h8.0a5.0 5.0 0 0 1 5.0 5.0v4.0a5.0 5.0 0 0 1 -5.0 5.0h-8.0a5.0 5.0 0 0 1 -5.0 -5.0v-4.0a5.0 5.0 0 0 1 5.0 -5.0z","fill":"#3FA9F5","fillRule":"nonzero","opacity":1.0},{"d":"M3.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z","fill":"#8897A5","fillRule":"nonzero","opacity":1.0},{"d":"M21.0 10.0h0.0a1.5 1.5 0 0 1 1.5 1.5v3.0a1.5 1.5 0 0 1 -1.5 1.5h0.0a1.5 1.5 0 0 1 -1.5 -1.5v-3.0a1.5 1.5 0 0 1 1.5 -1.5z","fill":"#8897A5","fillRule":"nonzero","opacity":1.0},{"d":"M7.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0","fill":"#10243E","fillRule":"nonzero","opacity":1.0},{"d":"M13.5 12.0a1.5 1.5 0 1 0 3.0 0a1.5 1.5 0 1 0 -3.0 0","fill":"#10243E","fillRule":"nonzero","opacity":1.0},{"d":"M8.5 15.4c.9.8 2.08 1.2 3.5 1.2s2.6-.4 3.5-1.2c.24-.2.6-.18.8.06.2.23.17.6-.06.8-1.14.98-2.58 1.46-4.24 1.46s-3.1-.48-4.24-1.46a.58.58 0 0 1-.06-.8c.2-.24.56-.26.8-.06","fill":"#10243E","fillRule":"nonzero","opacity":1.0}]},"AllNetworksSolid":{"viewBox":"0 0 24 24","paths":[{"d":"M15.333 13.998a1.335 1.335 0 1 1 0 2.67 1.335 1.335 0 0 1 0-2.67","fill":"currentColor","fillRule":"nonzero","opacity":1.0},{"d":"M12 0c6.627 0 12 5.373 12 12s-5.373 12-12 12S0 18.627 0 12 5.373 0 12 0M8 12.668A2 2 0 0 0 6 14.666V16c0 1.103.895 1.997 1.998 1.998h1.334A2 2 0 0 0 11.33 16v-1.334a2 2 0 0 0-1.998-1.998zm7.333 0a2.665 2.665 0 1 0 0 5.33 2.665 2.665 0 0 0 0-5.33M7.999 6.001A2 2 0 0 0 6.001 8v1.334c0 1.103.895 1.998 1.998 1.998h1.334a2 2 0 0 0 1.998-1.998V7.999a2 2 0 0 0-1.998-1.998zm6.667 0A2 2 0 0 0 12.668 8v1.334c0 1.103.895 1.998 1.998 1.998H16a2 2 0 0 0 1.998-1.998V7.999A2 2 0 0 0 16 6.001z","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"CrossedSmallSolid":{"viewBox":"0 0 24 24","paths":[{"d":"M17.87 8.25 14.12 12l3.75 3.75-2.12 2.121-3.75-3.75-3.75 3.75-2.121-2.121L9.879 12l-3.75-3.75 2.12-2.121L12 9.879l3.75-3.75 2.122 2.121Z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"AccountErrorCustom":{"viewBox":"0 0 18 18","paths":[{"d":"M12.5 12.75a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5","fill":"#000","fillRule":"nonzero","opacity":0.447},{"d":"M0 3.5A3.5 3.5 0 0 1 3.5 0h8.088A2.41 2.41 0 0 1 14 2.412V5h1a3 3 0 0 1 3 3v7a3 3 0 0 1-3 3H4a4 4 0 0 1-4-4zm2 3.163V14a2 2 0 0 0 2 2h11a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1H3.5c-.537 0-1.045-.12-1.5-.337M2 3.5A1.5 1.5 0 0 0 3.5 5H12V2.412A.41.41 0 0 0 11.588 2H3.5A1.5 1.5 0 0 0 2 3.5","fill":"#000","fillRule":"evenodd","opacity":0.447}]},"PlusSmallOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M13 11h5v2h-5v5h-2v-5H6v-2h5V6h2z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"DotHorOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M6 14H2v-4h4zm8 0h-4v-4h4zm8 0h-4v-4h4z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"ChevronRightSmallOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M15.414 12 10 17.414 8.586 16l4-4-4-4L10 6.586z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"DragOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M11 21H7v-4h4zm6 0h-4v-4h4zm-6-7H7v-4h4zm6 0h-4v-4h4zm-6-7H7V3h4zm6 0h-4V3h4z","fill":"currentColor","fillRule":"nonzero","opacity":1.0}]},"PencilOutline":{"viewBox":"0 0 24 24","paths":[{"d":"M22.414 7.5 7.914 22H2v-5.914l14.5-14.5zM4 16.914V20h3.086l9.5-9.5L13.5 7.414zM14.914 6 18 9.086 19.586 7.5 16.5 4.414z","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"CheckboxCheckedCustom":{"viewBox":"0 0 16 16","paths":[{"d":"M12.204 5.043a1 1 0 0 1 0 1.414l-4.5 4.5a1 1 0 0 1-1.414 0l-2-2a1 1 0 1 1 1.414-1.414l1.293 1.293 3.793-3.793a1 1 0 0 1 1.414 0","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"CheckboxIndeterminateCustom":{"viewBox":"0 0 16 16","paths":[{"d":"M4 8a1 1 0 0 1 1-1h6a1 1 0 0 1 0 2H5a1 1 0 0 1-1-1","fill":"currentColor","fillRule":"evenodd","opacity":1.0}]},"Circle":{"viewBox":"0 0 24 24","paths":[{"d":"M0 12a12 12 0 1 0 24 0a12 12 0 1 0 -24 0","fill":"currentColor","fillRule":"nonzero","opacity":1}]}}; -+function applySelectorIcon(element: HTMLElement, name: string) { -+ const icon = selectorIcons[name]; -+ if (!icon) return; -+ element.textContent = ''; -+ const svg = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); -+ svg.setAttribute('viewBox', icon.viewBox); -+ svg.setAttribute('width', '24'); -+ svg.setAttribute('height', '24'); -+ svg.setAttribute('aria-hidden', 'true'); -+ icon.paths.forEach((path) => { -+ const child = element.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); -+ child.setAttribute('d', path.d); -+ child.setAttribute('fill', path.fill); -+ child.setAttribute('fill-rule', path.fillRule); -+ child.setAttribute('fill-opacity', String(path.opacity)); -+ svg.appendChild(child); -+ }); -+ element.appendChild(svg); -+} -+ - function iconGlyph(name: string): string { - const normalized = name.toLocaleLowerCase(); - if (normalized.includes('chevron')) { -@@ -841,7 +1021,8 @@ function visualFromRow(row: RowModel): LeadingVisual | undefined { - - function createVisual( - context: RenderContext, -- visual: LeadingVisual | undefined -+ visual: LeadingVisual | undefined, -+ selectorPresentation?: string - ): HTMLElement | undefined { - if (!visual) return undefined; - if (visual.kind === 'stackedImages') { -@@ -873,6 +1054,7 @@ function createVisual( - 'ok-native-list-visual-fallback', - iconGlyph(visual.name) - ); -+ applySelectorIcon(fallback, visual.name); - if (visual.tintColor) fallback.style.color = visual.tintColor; - frame.appendChild(fallback); - return frame; -@@ -885,6 +1067,7 @@ function createVisual( - if (image) { - image.className = 'ok-native-list-visual-main'; - frame.appendChild(image); -+ if (selectorPresentation) paintSelectorImageBackground(image, frame); - } else { - frame.appendChild( - createElement( -@@ -913,8 +1096,51 @@ function createVisual( - corner.style.color = visual.cornerIcon.tintColor; - if (visual.cornerIcon.backgroundColor) - corner.style.background = visual.cornerIcon.backgroundColor; -+ applySelectorIcon(corner, visual.cornerIcon.name); - frame.appendChild(corner); - } -+ // OneKey patch: image failure uses the same source-derived fallback as v1. -+ if ('fallbackIcon' in visual && visual.fallbackIcon) { -+ const icon = visual.fallbackIcon; -+ const showFallback = () => { -+ if (image) { disposeWebImageRetries(image); image.remove(); } -+ frame.querySelector('.ok-native-list-visual-fallback:not(.ok-native-list-visual-corner)')?.remove(); -+ const fallback = createElement(context.document, 'span', 'ok-native-list-visual-fallback'); -+ applySelectorIcon(fallback, icon.name); -+ if (icon.tintColor) fallback.style.color = icon.tintColor; -+ frame.prepend(fallback); -+ }; -+ if (image) image.addEventListener('error', showFallback, { once: true }); -+ else showFallback(); -+ } -+ if ('borderStyle' in visual && visual.borderStyle === 'dashed') { -+ frame.style.border = '2px dashed ' + (visual.borderColor ?? 'var(--nl-disabled)'); -+ frame.style.boxSizing = 'border-box'; -+ } -+ if ('overlays' in visual) visual.overlays?.forEach((overlay) => { -+ const corner = createElement(context.document, 'span', 'ok-native-list-visual-overlay', overlay.text); -+ const size = overlay.size ?? 20; -+ const isWalletText = selectorPresentation === 'walletSidebar' && !!overlay.text && !overlay.image && !overlay.name; -+ corner.style.width = isWalletText && overlay.width === undefined ? 'auto' : String(overlay.width ?? size) + 'px'; -+ corner.style.height = String(overlay.height ?? (isWalletText ? 16 : size)) + 'px'; -+ corner.style.padding = isWalletText ? '0 2px' : String(overlay.padding ?? 0) + 'px'; -+ if (isWalletText) { corner.style.fontSize = '12px'; corner.style.lineHeight = '16px'; corner.style.fontWeight = '400'; } -+ if (isWalletText || overlay.width !== undefined || overlay.height !== undefined) corner.style.borderRadius = '9999px'; -+ const offsetX = String(-(overlay.offsetX ?? overlay.offset ?? 2)) + 'px'; -+ const offsetY = String(-(overlay.offsetY ?? overlay.offset ?? 2)) + 'px'; -+ corner.style.background = overlay.backgroundColor ?? 'transparent'; -+ corner.style.color = overlay.tintColor ?? 'var(--nl-secondary)'; -+ if (overlay.position === 'topLeft') { corner.style.left = offsetX; corner.style.top = offsetY; } -+ else { corner.style.right = offsetX; corner.style.bottom = offsetY; } -+ if (overlay.image) { -+ const overlayImage = createImage(context, overlay.image); -+ if (overlayImage) { -+ corner.appendChild(overlayImage); -+ if (selectorPresentation) paintSelectorImageBackground(overlayImage, corner, overlay.padding ?? 0); -+ } -+ } else if (overlay.name) applySelectorIcon(corner, overlay.name); -+ frame.appendChild(corner); -+ }); - return frame; - } - -@@ -1022,6 +1248,16 @@ function createCheckbox( - setData(element, 'checkboxFallback', accessory.state); - setData(element, 'nativeListAction', accessory.actionKey ?? 'selection'); - setData(element, 'selectionScope', accessory.target?.scope ?? 'row'); -+ const row = context.snapshot.rows[context.itemIndex]; -+ if (row && 'presentation' in row && row.presentation === 'networkSelector') { -+ setData(element, 'selector', 'networkSelector'); -+ for (const [state, name] of [['checked', 'CheckboxCheckedCustom'], ['indeterminate', 'CheckboxIndeterminateCustom']]) { -+ const holder = createElement(context.document, 'span'); -+ applySelectorIcon(holder, name); -+ const svg = holder.firstElementChild; -+ if (svg) { svg.setAttribute('data-state', state); element.appendChild(svg); } -+ } -+ } - if (accessory.target?.scope === 'section') - setData(element, 'selectionKey', accessory.target.sectionKey); - else if (accessory.target?.scope === 'row') -@@ -1046,6 +1282,7 @@ function createIconAction( - element.setAttribute('type', 'button'); - element.toggleAttribute('disabled', Boolean(disabled)); - } -+ applySelectorIcon(element, name); - if (actionKey) setData(element, 'nativeListAction', actionKey); - if (tintColor) element.style.color = tintColor; - return element; -@@ -1060,6 +1297,20 @@ function markActionAnchorSource( - if (slot !== undefined) setData(element, 'nativeListAnchorSlot', slot); - } - -+// OneKey patch: the compact zero-count digits share the amount baseline. -+function applyValueSegments(element: HTMLElement, segments: readonly Readonly<{ text: string; style?: 'subscript' }>[] | undefined, fontSize = 16, lineHeight = 24, weight = 500) { -+ if (!segments?.length) return; -+ element.textContent = ''; -+ element.style.fontSize = String(fontSize) + 'px'; -+ element.style.lineHeight = String(lineHeight) + 'px'; -+ element.style.fontWeight = String(weight); -+ segments.forEach((segment) => { -+ const span = createElement(element.ownerDocument, 'span', undefined, segment.text); -+ if (segment.style === 'subscript') { span.style.fontSize = String(Math.ceil(fontSize * 0.6)) + 'px'; span.style.lineHeight = String(fontSize) + 'px'; } -+ element.appendChild(span); -+ }); -+} -+ - function createAccessory( - context: RenderContext, - rowKey: string, -@@ -1080,6 +1331,18 @@ function createAccessory( - accessory.tintColor - ); - markActionAnchorSource(element, 'trailingAccessory', slot); -+ setData(element, 'testid', accessory.testID); -+ if (accessory.hoverActionKey) setData(element, 'nativeListHoverAction', accessory.hoverActionKey); -+ if (accessory.accessibilityLabel) element.setAttribute('aria-label', accessory.accessibilityLabel); -+ const row = context.snapshot.rows[context.itemIndex]; -+ if (row && 'presentation' in row && row.presentation === 'accountSelector') { -+ setData(element, 'nativeListAnchorInset', 7); -+ // OneKey patch: the create-address button omits IconButton's one-point border. -+ if (row.height !== undefined && accessory.name === 'PlusSmallOutline') { -+ setData(element, 'nativeListAccountControl', 'createAddress'); -+ if (!accessory.tintColor) element.style.color = 'var(--nl-icon-subdued)'; -+ } -+ } - return element; - } - if (accessory.kind === 'spinner') { -@@ -1099,6 +1362,7 @@ function createAccessory( - switch (accessory.kind) { - case 'value': - element.textContent = accessory.text; -+ applyValueSegments(element, accessory.textSegments); - if (accessory.secondary) - element.classList.add('ok-native-list-accessory-secondary'); - break; -@@ -1157,6 +1421,13 @@ function appendAccessories( - 'span', - 'ok-native-list-accessories' - ); -+ const row = context.snapshot.rows[context.itemIndex]; -+ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'networkSelector') { -+ container.style.gap = accessories.some(accessory => accessory.kind === 'checkbox') ? '12px' : '20px'; -+ } -+ if (row && row.height !== undefined && 'presentation' in row && row.presentation === 'accountSelector' && accessories.length === 1 && accessories[0]?.kind === 'icon' && accessories[0].name === 'PlusSmallOutline') { -+ setData(container, 'nativeListAccountControl', 'createAddress'); -+ } - accessories.forEach((accessory, slot) => - container.appendChild(createAccessory(context, rowKey, accessory, slot)) - ); -@@ -1234,7 +1505,59 @@ function createSectionHeader( - markActionAnchorSource(titleIcon, 'leadingAction'); - body.appendChild(titleIcon); - } -- body.appendChild(createTextColumn(context, row.title, row.subtitle)); -+ // OneKey patch: section title help has its own measurable action target. -+ // body.appendChild(createTextColumn(context, row.title, row.subtitle)); -+ const column = createTextColumn(context, row.title, row.subtitle); -+ const title = column.firstElementChild as HTMLElement; -+ title.classList.add('ok-native-list-section-title'); -+ if (row.titleActionKey) { -+ setData(title, 'nativeListAction', row.titleActionKey); -+ markActionAnchorSource(title, 'leadingAction'); -+ title.setAttribute('role', 'button'); -+ title.tabIndex = 0; -+ title.style.alignSelf = 'flex-start'; -+ title.style.maxWidth = '100%'; -+ // OneKey patch: explicit network headers reserve a separate 3-point underline area. -+ if (row.presentation !== 'networkSelector' || row.height === undefined) { -+ title.style.textDecoration = 'underline dotted'; -+ title.style.textUnderlineOffset = '6px'; -+ } -+ if (row.titleActionOnHover) setData(title, 'nativeListHoverAction', true); -+ } -+ if (row.presentation === 'networkSelector' && row.height !== undefined) { -+ body.style.padding = row.variant === 'summary' ? '24px 12px 20px' : '0 12px'; -+ body.style.backgroundColor = 'var(--nl-bg)'; -+ body.style.gap = row.checkbox ? '12px' : '8px'; -+ title.style.fontSize = row.variant === 'summary' ? '16px' : '14px'; -+ title.style.lineHeight = row.variant === 'summary' ? '24px' : '20px'; -+ title.style.fontWeight = row.variant === 'summary' || (row.titleActionKey && !row.checkbox) ? '500' : '600'; -+ if (row.titleActionKey) { -+ const text = createElement(context.document, 'span', 'ok-native-list-section-title-text', row.title); -+ text.style.overflow = 'hidden'; -+ text.style.textOverflow = 'ellipsis'; -+ text.style.maxWidth = '100%'; -+ const dotted = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); -+ dotted.setAttribute('height', '2'); -+ dotted.style.cssText = 'display:block;position:absolute;left:0;bottom:0;width:100%;height:2px;color:var(--nl-secondary)'; -+ const line = context.document.createElementNS('http://www.w3.org/2000/svg', 'line'); -+ for (const [key, value] of Object.entries({ x1: '1', y1: '1', x2: '100%', y2: '1', stroke: 'currentColor', 'stroke-width': '1.5', 'stroke-dasharray': '0,4', 'stroke-linecap': 'round' })) line.setAttribute(key, value); -+ // OneKey patch: keep both round caps inside the original full-width viewport. -+ const lineViewport = context.document.createElementNS('http://www.w3.org/2000/svg', 'svg'); -+ lineViewport.setAttribute('width', 'calc(100% - 1px)'); -+ lineViewport.setAttribute('height', '2'); -+ lineViewport.setAttribute('overflow', 'visible'); -+ lineViewport.appendChild(line); -+ dotted.appendChild(lineViewport); -+ // OneKey patch: SVG intrinsic width must not expand the title action beyond its text. -+ title.style.display = 'block'; -+ title.style.position = 'relative'; -+ title.style.width = 'fit-content'; -+ title.style.paddingBottom = '3px'; -+ text.style.display = 'block'; -+ title.replaceChildren(text, dotted); -+ } -+ } -+ body.appendChild(column); - if (row.value) { - const value = createElement( - context.document, -@@ -1244,6 +1567,15 @@ function createSectionHeader( - : 'ok-native-list-value ok-native-list-section-value', - row.value - ); -+ applyValueSegments(value, row.valueSegments); -+ if (row.presentation === 'networkSelector' && row.height !== undefined) { -+ value.style.fontFamily = 'inherit'; -+ value.style.fontSize = '16px'; -+ value.style.lineHeight = '24px'; -+ value.style.fontWeight = '500'; -+ if (row.valueActionKey) { value.style.color = 'var(--nl-secondary)'; value.style.padding = '0'; value.style.flexShrink = '0'; } -+ } -+ if (row.valueActionTestID) setData(value, 'testid', row.valueActionTestID); - if (row.valueActionKey) { - value.setAttribute('type', 'button'); - setData(value, 'nativeListAction', row.valueActionKey); -@@ -1292,6 +1624,7 @@ function createActionRow( - row.title - ); - setData(title, 'tone', row.tone); -+ if (row.presentation === 'accountSelector' && row.icon) title.style.fontWeight = '500'; - body.appendChild(title); - if (row.checkbox) - body.appendChild(createCheckbox(context, row.key, row.checkbox)); -@@ -1309,6 +1642,13 @@ function createSystemRow( - 'ok-native-list-row ok-native-list-system' - ); - setData(body, 'variant', row.variant); -+ if (row.variant === 'warning') { -+ body.classList.add('ok-native-list-warning'); -+ body.style.borderColor = row.borderColor ?? 'var(--nl-separator)'; -+ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-title', row.title)); -+ body.appendChild(createElement(context.document, 'span', 'ok-native-list-warning-message', row.message)); -+ return body; -+ } - if (row.variant === 'loading') - body.appendChild( - createElement(context.document, 'span', 'ok-native-list-spinner') -@@ -1703,6 +2043,11 @@ function createIdentityActivityOrMessageRow( - .filter(Boolean) - .join(' ') - ); -+ setData(body, 'nativeListSelector', row.height !== undefined ? presentation : undefined); -+ if (row.type === 'identity' && row.titleActionKey && row.titleActionOnHover) { -+ setData(body, 'nativeListHoverAction', row.titleActionKey); -+ markActionAnchorSource(body, 'leadingAction'); -+ } - if (row.type === 'identity' && row.leadingAction) { - const action = createIconAction( - context, -@@ -1714,7 +2059,15 @@ function createIdentityActivityOrMessageRow( - markActionAnchorSource(action, 'leadingAction'); - body.appendChild(action); - } -- const visual = createVisual(context, visualFromRow(row)); -+ const visual = createVisual(context, visualFromRow(row), row.height !== undefined ? presentation : undefined); -+ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'fallbackIcon' in row.leading && row.leading.fallbackIcon?.name === 'LockSolid') { -+ // OneKey patch: hidden-wallet locks use WalletAvatar's full 40-point icon. -+ const icon = visual.querySelector('.ok-native-list-visual-fallback svg'); -+ if (icon) { icon.style.width = '40px'; icon.style.height = '40px'; } -+ const fallback = visual.querySelector('.ok-native-list-visual-fallback'); -+ if (fallback) { fallback.style.borderRadius = '0'; fallback.style.overflow = 'visible'; } -+ } -+ if (visual && row.type === 'identity' && row.height !== undefined && row.presentation === 'walletSidebar' && 'borderStyle' in row.leading && row.leading.borderStyle === 'dashed') visual.style.borderWidth = '1px'; - if (visual) body.appendChild(visual); - if (row.type === 'activity' && row.secondaryLeading) { - const secondVisual = createVisual(context, row.secondaryLeading); -@@ -1737,8 +2090,44 @@ function createIdentityActivityOrMessageRow( - subtitle, - row.type === 'identity' ? row.tertiary : undefined, - row.type === 'identity' ? row.tertiaryTone : undefined, -- row.type === 'identity' ? row.badges : undefined -+ row.type === 'identity' && presentation !== 'walletSidebar' ? row.badges : undefined - ); -+ // OneKey patch: match existing search, subtitle fragments, and sidebar badges. -+ if (row.type === 'identity') { -+ const titleElement = column.firstElementChild as HTMLElement; -+ if (row.titleMatch?.length) { -+ const firstText = titleElement.firstChild; -+ if (firstText) firstText.remove(); -+ const fragment = context.document.createDocumentFragment(); -+ let offset = 0; -+ row.titleMatch.forEach(({ start, end }) => { -+ fragment.appendChild(context.document.createTextNode(row.title.slice(offset, start))); -+ const match = createElement(context.document, 'span', 'ok-native-list-info', row.title.slice(start, end)); -+ fragment.appendChild(match); -+ offset = end; -+ }); -+ fragment.appendChild(context.document.createTextNode(row.title.slice(offset))); -+ titleElement.prepend(fragment); -+ } -+ if (row.subtitleSegments?.length) { -+ column.querySelector('.ok-native-list-secondary')?.remove(); -+ const segments = createElement(context.document, 'span', 'ok-native-list-subtitle-segments'); -+ row.subtitleSegments.forEach((segment) => { -+ if (segment.separatorBefore) segments.appendChild(createElement(context.document, 'span', 'ok-native-list-subtitle-dot')); -+ const text = createElement(context.document, 'span', 'ok-native-list-secondary', segment.text); -+ applyValueSegments(text, segment.textSegments, 14, 20, 400); -+ setData(text, 'tone', segment.tone); -+ text.style.color = segment.tone === 'disabled' ? 'var(--nl-disabled)' : segment.tone === 'caution' ? 'var(--nl-caution)' : toneColor(segment.tone, 'secondary'); -+ segments.appendChild(text); -+ }); -+ column.insertBefore(segments, titleElement.nextSibling); -+ } -+ if (presentation === 'walletSidebar' && row.badges?.length) { -+ const badges = createElement(context.document, 'span', 'ok-native-list-wallet-badges'); -+ row.badges.forEach((badge) => badges.appendChild(createBadge(context, badge))); -+ column.appendChild(badges); -+ } -+ } - if (row.type === 'activity' && row.status) - column.appendChild( - createElement( -@@ -1814,6 +2203,13 @@ function createIdentityActivityOrMessageRow( - return body; - } - -+// OneKey patch: SizableText enables tabular digits without replacing its font family. -+function applySelectorTabularNumbers(body: HTMLElement, row: RowModel) { -+ if (!('presentation' in row) || !['accountSelector', 'networkSelector', 'walletSidebar'].includes(row.presentation ?? '')) return; -+ body.style.fontVariantNumeric = 'tabular-nums'; -+ body.querySelectorAll('span,button').forEach((text) => { text.style.fontVariantNumeric = 'tabular-nums'; }); -+} -+ - function createWalletGroupRow( - context: RenderContext, - row: Extract -@@ -1830,11 +2226,18 @@ function createWalletGroupRow( - 'ok-native-list-wallet-member' - ); - setData(memberElement, 'nativeListGroupMemberKey', member.key); -+ setData(memberElement, 'testid', member.testID); - setData(memberElement, 'nativeListGroupParent', memberIndex === 0); - setData(memberElement, 'nativeListSelected', member.selected); -- memberElement.appendChild( -- createIdentityActivityOrMessageRow(context, member) -- ); -+ // OneKey patch: grouped members have the same selector typography as standalone wallets. -+ // memberElement.appendChild(createIdentityActivityOrMessageRow(context, member)); -+ const memberBody = createIdentityActivityOrMessageRow(context, member); -+ applySelectorTabularNumbers(memberBody, member); -+ memberElement.appendChild(memberBody); -+ // OneKey patch: group children use their own measured badge height. -+ memberElement.style.flexBasis = String(member.height ?? (68 + (member.badges?.length ? 24 : 0))) + 'px'; -+ memberElement.style.height = memberElement.style.flexBasis; -+ memberElement.style.opacity = String(member.opacity ?? 1); - body.appendChild(memberElement); - }); - return body; -@@ -1894,9 +2297,18 @@ export class NativeListWebEngine { - private resizeObserver: ResizeObserver | undefined; - private pendingScroll: PendingScroll | undefined; - private lastVisibleSignature: string | undefined; -+ // OneKey patch: URI leases outlive DOM overscan only inside this bounded window. -+ private readonly avatarLeases = new Map(); -+ private avatarOffset = 0; -+ private avatarDirection = 1; - private reachedGeneration: number | undefined; - private stickyKey: string | undefined; - private previewTimer: number | undefined; -+ private sectionIndexEntries: readonly Readonly<{ -+ key: string; -+ title: string; -+ position: number; -+ }>[] = []; - private pointerReorder: PointerReorderState | undefined; - private keyboardReorder: KeyboardReorderState | undefined; - private reorderMoveFrame: number | undefined; -@@ -1919,6 +2331,8 @@ export class NativeListWebEngine { - private lastViewportWidth = -1; - private lastViewportHeight = -1; - private destroyed = false; -+ // OneKey patch: warning banners are measured after normal browser text wrapping. -+ private measuredWarningHeights = new Map(); - - constructor( - host: HTMLElement, -@@ -2004,6 +2418,9 @@ export class NativeListWebEngine { - passive: true, - }); - this.root.addEventListener('click', this.handleClick); -+ // OneKey patch: preserve web tooltip hover for section titles. -+ this.root.addEventListener('pointerover', this.handleTitlePointerOver); -+ this.root.addEventListener('pointerout', this.handleTitlePointerOut); - this.root.addEventListener('keydown', this.handleKeyDown); - this.viewport.addEventListener( - 'pointerdown', -@@ -2160,7 +2577,7 @@ export class NativeListWebEngine { - const index = resolveLocationIndex(this.snapshot.rows, params); - if (index === undefined) { - const sectionCount = this.snapshot.rows.filter( -- (row) => row.type === 'sectionHeader' && row.variant !== 'summary' -+ (row) => row.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary' - ).length; - this.emitScrollFailure( - params.itemIndex, -@@ -2219,6 +2636,8 @@ export class NativeListWebEngine { - ); - this.viewport.removeEventListener('scroll', this.handleScroll); - this.root.removeEventListener('click', this.handleClick); -+ this.root.removeEventListener('pointerover', this.handleTitlePointerOver); -+ this.root.removeEventListener('pointerout', this.handleTitlePointerOut); - this.root.removeEventListener('keydown', this.handleKeyDown); - this.cancelPointerReorder(true); - this.viewport.removeEventListener( -@@ -2251,25 +2670,29 @@ export class NativeListWebEngine { - this.viewport.removeEventListener('pointerup', this.handlePullEnd); - this.viewport.removeEventListener('pointercancel', this.handlePullEnd); - const host = this.root.parentElement; -+ disposeWebImageRetries(this.root); -+ this.pool.forEach(disposeWebImageRetries); - this.root.remove(); - this.hideReorderPreview(); - this.reorderPreview.remove(); - if (host) host.style.position = this.previousHostPosition; - this.mounted.clear(); - this.pool.length = 0; -+ this.avatarLeases.forEach((release) => release()); -+ this.avatarLeases.clear(); - } - - private setSnapshot( - snapshot: NativeListSnapshot, - selectedKeys?: ReadonlySet - ) { -+ this.measuredWarningHeights.clear(); - this.snapshot = snapshot; - this.rows = effectiveRows(snapshot); - this.selectedKeys = - selectedKeys ?? selectionStateFromSnapshot(snapshot).selectedKeys; - if (this.reachedGeneration !== snapshot.generation) - this.reachedGeneration = undefined; -- this.renderSectionIndex(); - this.applyTheme(); - this.recomputeLayout(); - this.renderFooter(); -@@ -2288,6 +2711,11 @@ export class NativeListWebEngine { - '--nl-primary': theme.primaryText, - '--nl-secondary': theme.secondaryText, - '--nl-disabled': theme.disabledText, -+ '--nl-caution': theme.caution ?? '#AB6400', -+ '--nl-caution-background': theme.cautionBackground, -+ '--nl-checkbox-background': theme.checkboxBackground, -+ '--nl-checkbox-border': theme.checkboxBorder, -+ '--nl-checkbox-icon': theme.checkboxIcon, - '--nl-icon': theme.icon, - '--nl-icon-subdued': theme.iconSubdued, - '--nl-separator': theme.separator, -@@ -2316,18 +2744,24 @@ export class NativeListWebEngine { - viewportHeight !== this.lastViewportHeight) - ) { - this.invalidateActionAnchor('layout'); -+ this.measuredWarningHeights.clear(); - } - this.lastViewportWidth = viewportWidth; - this.lastViewportHeight = viewportHeight; - const previousHorizontal = this.layout.horizontal; -+ const measuredSnapshot: NativeListSnapshot = { -+ ...this.snapshot, -+ rows: this.snapshot.rows.map((row) => row.type === 'system' && row.variant === 'warning' && row.height === undefined && this.measuredWarningHeights.has(row.key) ? { ...row, height: this.measuredWarningHeights.get(row.key) } : row), -+ }; - this.layout = computeWebListLayout( -- this.snapshot, -+ measuredSnapshot, - viewportWidth, - viewportHeight, - this.reorderCompactKey - ); - this.content.style.width = String(this.layout.contentWidth) + 'px'; - this.content.style.height = String(this.layout.contentHeight) + 'px'; -+ this.renderSectionIndex(viewportHeight || DEFAULT_VIEWPORT_HEIGHT); - if (previousHorizontal !== this.layout.horizontal) { - this.viewport.scrollLeft = 0; - this.viewport.scrollTop = 0; -@@ -2336,7 +2770,38 @@ export class NativeListWebEngine { - this.performPendingScroll(); - }; - -+ private updateAvatarWindow() { -+ const offset = this.currentOffset(); -+ if (offset !== this.avatarOffset) this.avatarDirection = Math.sign(offset - this.avatarOffset); -+ this.avatarOffset = offset; -+ const visible = visibleWebLayoutItems(this.layout, offset, this.viewportLength(), 0); -+ const first = visible[0]?.index ?? -1; -+ const last = visible[visible.length - 1]?.index ?? -1; -+ const candidates = avatarPrefetchWindow(this.rows, first, last, this.avatarDirection); -+ const desired = new Set(candidates.map(({ source }) => canonicalNativeListAvatarUri(source.uri)).filter((uri) => uri !== undefined)); -+ this.avatarLeases.forEach((release, uri) => { -+ if (!desired.has(uri)) { release(); this.avatarLeases.delete(uri); } -+ }); -+ candidates.forEach(({ source, priority }) => { -+ const uri = canonicalNativeListAvatarUri(source.uri); -+ if (!uri) return; -+ const existing = this.avatarLeases.get(uri); -+ if (existing) existing.setPriority(priority); -+ else { -+ let lease: AvatarLease | undefined; -+ lease = acquireNativeListAvatar(this.document, uri, () => {}, () => { -+ if (this.avatarLeases.get(uri) === lease) { -+ lease?.(); -+ this.avatarLeases.delete(uri); -+ } -+ }, priority); -+ this.avatarLeases.set(uri, lease); -+ } -+ }); -+ } -+ - private renderWindow() { -+ this.updateAvatarWindow(); - const viewportLength = this.viewportLength(); - const visible = webLayoutItemsForMount( - this.layout, -@@ -2350,6 +2815,7 @@ export class NativeListWebEngine { - if (!desired.has(index)) { - this.invalidateActionAnchorForElement(element); - this.mounted.delete(index); -+ if (disposeWebImageRetries(element)) element.removeAttribute('data-render-signature'); - element.remove(); - this.pool.push(element); - } -@@ -2377,6 +2843,17 @@ export class NativeListWebEngine { - element.dataset.renderSignature = signature; - } - }); -+ let measuredWarningChanged = false; -+ this.mounted.forEach((element, index) => { -+ const row = this.rows[index]; -+ if (row?.type !== 'system' || row.variant !== 'warning' || row.height !== undefined) return; -+ const height = element.querySelector('.ok-native-list-warning')?.offsetHeight ?? 0; -+ if (height > 0 && height !== this.measuredWarningHeights.get(row.key)) { -+ this.measuredWarningHeights.set(row.key, height); -+ measuredWarningChanged = true; -+ } -+ }); -+ if (measuredWarningChanged) { this.recomputeLayout(); return; } - this.updateVisibleSelection(); - this.updateVisibleState(); - } -@@ -2395,6 +2872,7 @@ export class NativeListWebEngine { - overlay = false - ) { - this.invalidateActionAnchorForElement(element); -+ disposeWebImageRetries(element); - const bindingEpoch = String(++this.bindingEpochCounter); - element.className = overlay - ? 'ok-native-list-item ok-native-list-sticky' -@@ -2403,6 +2881,9 @@ export class NativeListWebEngine { - setData(element, 'nativeListBindingEpoch', bindingEpoch); - setData(element, 'nativeListRowIndex', index); - setData(element, 'nativeListDisabled', Boolean(row.disabled)); -+ setData(element, 'testid', row.testID); -+ // OneKey patch: deprecation dims a row without disabling its actions. -+ element.style.opacity = String(row.opacity ?? 1); - setData(element, 'nativeListReorderable', this.isReorderable(row)); - setData( - element, -@@ -2435,12 +2916,48 @@ export class NativeListWebEngine { - selectedKeys: this.selectedKeys, - itemIndex: index, - }; -- element.replaceChildren(createRowBody(context, row)); -+ const body = createRowBody(context, row); -+ applySelectorTabularNumbers(body, row); -+ // OneKey patch: explicit selector fields preserve original page geometry. -+ element.style.contain = row.backgroundFullWidth ? 'layout style' : ''; -+ if (row.backgroundColor) body.style.backgroundColor = row.backgroundColor; -+ if (row.backgroundFullWidth && row.backgroundColor) { -+ const bleed = paddingValues(this.snapshot).horizontal; -+ body.style.position = 'relative'; -+ body.style.overflow = 'visible'; -+ body.style.boxShadow = String(-bleed) + 'px 0 ' + row.backgroundColor + ',' + String(bleed) + 'px 0 ' + row.backgroundColor; -+ } -+ if (row.type === 'identity' && row.height !== undefined) { -+ const title = body.querySelector('.ok-native-list-title'); -+ if (row.presentation === 'accountSelector') { -+ body.style.gap = '12px'; -+ body.style.borderRadius = '12px'; -+ if ('shape' in row.leading && row.leading.shape === 'rounded') { -+ const visual = body.querySelector('.ok-native-list-visual'); -+ if (visual) visual.style.borderRadius = '8px'; -+ } -+ if (title) title.style.lineHeight = '24px'; -+ } -+ if (row.presentation === 'networkSelector') { -+ body.style.borderRadius = '12px'; -+ const visual = body.querySelector('.ok-native-list-visual'); -+ if (visual) { visual.style.width = '32px'; visual.style.height = '32px'; visual.style.flexBasis = '32px'; } -+ if (row.leading.kind === 'network' && !row.leading.image && !row.leading.fallbackIcon && row.leading.fallbackText) { -+ const fallback = visual?.querySelector('.ok-native-list-visual-fallback'); -+ if (fallback) { fallback.style.fontSize = '19px'; fallback.style.lineHeight = '27px'; fallback.style.fontWeight = '600'; fallback.style.color = 'var(--nl-inverse-text)'; } -+ } -+ visual?.querySelectorAll('.ok-native-list-visual-main').forEach((image) => { image.style.width = '32px'; image.style.height = '32px'; }); -+ if (title) { title.style.fontSize = '16px'; title.style.lineHeight = '24px'; title.style.fontWeight = '500'; } -+ body.querySelectorAll('.ok-native-list-accessory').forEach((value) => { value.style.fontSize = '16px'; value.style.lineHeight = '24px'; value.style.fontWeight = '500'; }); -+ } -+ } -+ element.replaceChildren(body); - } - - private renderFooter() { - const row = this.snapshot.fixedFooter; - this.invalidateActionAnchorForElement(this.footer); -+ disposeWebImageRetries(this.footer); - this.footer.replaceChildren(); - if (!row) return; - const element = createElement(this.document, 'div', 'ok-native-list-item'); -@@ -2459,25 +2976,78 @@ export class NativeListWebEngine { - this.footer.appendChild(element); - } - -- private renderSectionIndex() { -+ private sectionIndexVisibleEntryIndices(viewportHeight: number): readonly number[] { -+ const entryCount = this.sectionIndexEntries.length; -+ if (entryCount <= 1) return entryCount ? [0] : []; -+ const availableHeight = Math.max( -+ 0, -+ viewportHeight - SECTION_INDEX_EDGE_PADDING * 2 -+ ); -+ const maxVisible = Math.max( -+ 2, -+ Math.floor(availableHeight / SECTION_INDEX_MIN_LABEL_SPACING) + 1 -+ ); -+ if (entryCount <= maxVisible) { -+ return Array.from({ length: entryCount }, (_, index) => index); -+ } -+ const result = new Set(); -+ for (let slot = 0; slot < maxVisible; slot += 1) { -+ result.add(Math.round((slot * (entryCount - 1)) / (maxVisible - 1))); -+ } -+ return [...result].sort((left, right) => left - right); -+ } -+ -+ private renderSectionIndex(viewportHeight: number) { - this.indexRail.replaceChildren(); - if (!sectionIndexEnabled(this.snapshot)) { -+ this.sectionIndexEntries = []; -+ this.indexRail.hidden = true; -+ return; -+ } -+ this.sectionIndexEntries = this.snapshot.rows.flatMap((row, position) => -+ row.type === 'sectionHeader' && row.indexTitle -+ ? [{ key: row.key, title: row.indexTitle, position }] -+ : [] -+ ); -+ if ( -+ this.sectionIndexEntries.length === 0 || -+ viewportHeight < SECTION_INDEX_MIN_HEIGHT -+ ) { - this.indexRail.hidden = true; - return; - } -+ const visibleEntryIndices = -+ this.sectionIndexVisibleEntryIndices(viewportHeight); -+ setData( -+ this.indexRail, -+ 'compact', -+ visibleEntryIndices.length < this.sectionIndexEntries.length -+ ); - const fragment = this.document.createDocumentFragment(); -- this.snapshot.rows.forEach((row, index) => { -- if (row.type !== 'sectionHeader' || !row.indexTitle) return; -+ visibleEntryIndices.forEach((entryIndex) => { -+ const entry = this.sectionIndexEntries[entryIndex]; -+ if (!entry) return; - const button = createElement( - this.document, - 'button', - 'ok-native-list-index-button', -- row.indexTitle -+ entry.title - ); - button.setAttribute('type', 'button'); -- button.setAttribute('aria-label', 'Jump to ' + row.indexTitle); -- setData(button, 'sectionPosition', index); -- setData(button, 'sectionKey', row.key); -+ button.setAttribute('aria-label', 'Jump to ' + entry.title); -+ setData(button, 'sectionEntryIndex', entryIndex); -+ setData(button, 'sectionPosition', entry.position); -+ setData(button, 'sectionKey', entry.key); -+ const progress = -+ this.sectionIndexEntries.length === 1 -+ ? 0.5 -+ : entryIndex / (this.sectionIndexEntries.length - 1); -+ button.style.top = -+ String( -+ SECTION_INDEX_EDGE_PADDING + -+ progress * -+ (viewportHeight - SECTION_INDEX_EDGE_PADDING * 2) -+ ) + 'px'; - fragment.appendChild(button); - }); - this.indexRail.appendChild(fragment); -@@ -2487,7 +3057,9 @@ export class NativeListWebEngine { - private updateVisibleSelection() { - const update = (element: HTMLElement, row: RowModel | undefined) => { - if (!row) return; -- const selected = this.selectedKeys.has(row.key); -+ // OneKey patch: selector adapters mark active rows independently of checkbox selection. -+ // const selected = this.selectedKeys.has(row.key); -+ const selected = row.selected === true || this.selectedKeys.has(row.key); - setData(element, 'nativeListSelected', selected); - element.setAttribute('aria-selected', String(selected)); - element -@@ -2548,7 +3120,9 @@ export class NativeListWebEngine { - } - this.checkEndReached(last?.index ?? -1); - this.updateStickyHeader(first?.index ?? -1); -- this.updateSectionIndex(first?.index ?? -1); -+ // OneKey patch: index highlighting follows header positions at scroll boundaries. -+ // this.updateSectionIndex(first?.index ?? -1); -+ this.updateSectionIndex(); - } - - private updateStickyHeader(firstVisibleIndex: number) { -@@ -2564,7 +3138,7 @@ export class NativeListWebEngine { - let index = -1; - for (let cursor = firstVisibleIndex; cursor >= 0; cursor -= 1) { - const row = this.rows[cursor]; -- if (row?.type === 'sectionHeader' && row.variant !== 'summary') { -+ if (row?.type === 'sectionHeader' && row.sticky !== false && row.variant !== 'summary') { - index = cursor; - break; - } -@@ -2594,7 +3168,7 @@ export class NativeListWebEngine { - for (let cursor = index + 1; cursor < this.rows.length; cursor += 1) { - const candidate = this.rows[cursor]; - if ( -- candidate?.type === 'sectionHeader' && -+ candidate?.type === 'sectionHeader' && candidate.sticky !== false && - candidate.variant !== 'summary' - ) { - nextIndex = cursor; -@@ -2610,12 +3184,15 @@ export class NativeListWebEngine { - this.updateVisibleSelection(); - } - -- private updateSectionIndex(firstVisibleIndex: number) { -+ // private updateSectionIndex(firstVisibleIndex: number) { -+ private updateSectionIndex() { - let activeKey: string | undefined; - this.snapshot.rows.forEach((row, index) => { - if ( -- index <= firstVisibleIndex && -- row.type === 'sectionHeader' && -+ // OneKey patch: a spacer ending exactly at the viewport is not the active section. -+ // index <= firstVisibleIndex && -+ itemStart(this.layout.items[index], this.layout.horizontal) <= this.currentOffset() && -+ row.type === 'sectionHeader' && row.sticky !== false && - row.indexTitle - ) - activeKey = row.key; -@@ -2793,7 +3370,9 @@ export class NativeListWebEngine { - if (!source || !bindingEpoch || !rowElement.contains(actionElement)) - return undefined; - this.invalidateActionAnchor('rebind'); -- const rect = actionElement.getBoundingClientRect(); -+ const actualRect = actionElement.getBoundingClientRect(); -+ const inset = Number(actionElement.dataset.nativeListAnchorInset ?? 0); -+ const rect = { left: actualRect.left + inset, top: actualRect.top + inset, width: actualRect.width - inset * 2, height: actualRect.height - inset * 2 }; - const token = [ - this.actionAnchorInstanceId, - this.snapshot.generation, -@@ -2866,7 +3445,9 @@ export class NativeListWebEngine { - rowElement?: HTMLElement, - sourceElement = rowElement - ) { -- if (row.disabled) return; -+ // OneKey patch: missing-address rows keep their create-address accessory interactive. -+ // if (row.disabled) return; -+ if (row.disabled || row.pressDisabled) return; - if ( - this.snapshot.selection?.rowPressToggles && - this.snapshot.selection.mode !== 'none' && -@@ -2894,6 +3475,28 @@ export class NativeListWebEngine { - } - } - -+ // OneKey patch: hover opens the same anchored help action as native taps. -+ private handleTitlePointerOver = (event: PointerEvent) => { -+ const target = event.target; -+ if (!(target instanceof Element)) return; -+ const action = target.closest('[data-native-list-hover-action]'); -+ if (!action || (event.relatedTarget instanceof Node && action.contains(event.relatedTarget))) return; -+ const rowElement = action.closest('[data-native-list-row-key]'); -+ const row = this.rowAtElement(rowElement); -+ const memberKey = action.closest('[data-native-list-group-member-key]')?.dataset.nativeListGroupMemberKey; -+ const sourceRow = row?.type === 'walletGroup' ? [row.parent, ...row.children].find(member => member.key === memberKey) ?? row : row; -+ const actionKey = action.dataset.nativeListHoverAction === 'true' ? action.dataset.nativeListAction : action.dataset.nativeListHoverAction; -+ if (sourceRow && !sourceRow.disabled && actionKey) this.emitRowAction(sourceRow, actionKey, action, rowElement ?? undefined); -+ }; -+ -+ private handleTitlePointerOut = (event: PointerEvent) => { -+ const target = event.target; -+ if (!(target instanceof Element)) return; -+ const action = target.closest('[data-native-list-hover-action]'); -+ if (!action || (event.relatedTarget instanceof Node && action.contains(event.relatedTarget))) return; -+ if (this.actionAnchor?.actionElement === action) this.invalidateActionAnchor('pointerLeave'); -+ }; -+ - private handleClick = (event: Event) => { - if (Date.now() < this.suppressClickUntil) { - event.preventDefault(); -@@ -2944,6 +3547,12 @@ export class NativeListWebEngine { - }; - - private handleKeyDown = (event: KeyboardEvent) => { -+ // OneKey patch: non-button title help supports keyboard activation. -+ if ((event.key === 'Enter' || event.key === ' ') && event.target instanceof HTMLElement && event.target.matches('[role="button"][data-native-list-action]')) { -+ event.preventDefault(); -+ event.target.click(); -+ return; -+ } - if (event.key === 'Escape') { - if (this.pointerReorder?.active) { - event.preventDefault(); -@@ -3073,7 +3682,7 @@ export class NativeListWebEngine { - this.frameHandle = this.requestFrame(() => { - this.frameHandle = undefined; - if (this.virtualizationEnabled) this.renderWindow(); -- else this.updateVisibleState(); -+ else { this.updateAvatarWindow(); this.updateVisibleState(); } - }); - } - -@@ -3090,13 +3699,27 @@ export class NativeListWebEngine { - else view?.clearTimeout(handle); - } - -- private selectIndexPosition(position: number, title: string) { -+ private selectIndexPosition( -+ position: number, -+ title: string, -+ previewClientY?: number -+ ) { - this.scrollToIndex(position, { - animated: false, - alignment: 'start', - viewPosition: 0, - viewOffset: 0, - }); -+ const frame = this.viewportFrame.getBoundingClientRect(); -+ if (previewClientY !== undefined && frame.height > 0) { -+ const previewY = Math.min( -+ frame.height - 24, -+ Math.max(24, previewClientY - frame.top) -+ ); -+ this.indexPreview.style.top = String(previewY) + 'px'; -+ } else { -+ this.indexPreview.style.top = '50%'; -+ } - this.indexPreview.textContent = title; - setData(this.indexPreview, 'visible', true); - if (this.previewTimer !== undefined) -@@ -3106,37 +3729,69 @@ export class NativeListWebEngine { - }, 180); - } - -- private indexButtonAtEvent(event: PointerEvent): HTMLElement | undefined { -- const direct = (event.target as Element | null)?.closest( -- '[data-section-position]' -+ private sectionIndexEntryAtEvent(event: PointerEvent): -+ | Readonly<{ -+ entry: (typeof this.sectionIndexEntries)[number]; -+ previewClientY: number; -+ }> -+ | undefined { -+ const rail = this.indexRail.getBoundingClientRect(); -+ if (this.sectionIndexEntries.length === 0 || rail.height <= 0) { -+ return undefined; -+ } -+ const availableHeight = Math.max( -+ 1, -+ rail.height - SECTION_INDEX_EDGE_PADDING * 2 - ); -- if (direct) return direct; -- return ( -- this.document -- .elementFromPoint(event.clientX, event.clientY) -- ?.closest('[data-section-position]') ?? undefined -+ const progress = Math.min( -+ 1, -+ Math.max( -+ 0, -+ (event.clientY - rail.top - SECTION_INDEX_EDGE_PADDING) / -+ availableHeight -+ ) - ); -+ const entryIndex = Math.round( -+ progress * (this.sectionIndexEntries.length - 1) -+ ); -+ const entry = this.sectionIndexEntries[entryIndex]; -+ return entry ? { entry, previewClientY: event.clientY } : undefined; - } - - private handleIndexPointer = (event: PointerEvent) => { - if (event.type === 'pointermove' && event.buttons === 0) return; -- const button = this.indexButtonAtEvent(event); -- if (!button) return; -+ const selection = this.sectionIndexEntryAtEvent(event); -+ if (!selection) return; - event.preventDefault(); -+ if (event.type === 'pointerdown') { -+ this.indexRail.setPointerCapture?.(event.pointerId); -+ } - this.selectIndexPosition( -- Number(button.dataset.sectionPosition), -- button.textContent ?? '' -+ selection.entry.position, -+ selection.entry.title, -+ selection.previewClientY - ); - }; - - private handleIndexClick = (event: Event) => { -+ if ( -+ 'detail' in event && -+ typeof event.detail === 'number' && -+ event.detail > 0 -+ ) -+ return; - const target = event.target; - if (!(target instanceof Element)) return; -- const button = target.closest('[data-section-position]'); -+ const button = target.closest('[data-section-entry-index]'); - if (!button) return; -+ const entry = -+ this.sectionIndexEntries[Number(button.dataset.sectionEntryIndex)]; -+ if (!entry) return; -+ const rect = button.getBoundingClientRect(); - this.selectIndexPosition( -- Number(button.dataset.sectionPosition), -- button.textContent ?? '' -+ entry.position, -+ entry.title, -+ rect.top + rect.height / 2 - ); - }; - -@@ -3203,12 +3858,13 @@ export class NativeListWebEngine { - const index = Number(rowElement?.dataset.nativeListRowIndex); - const row = this.rows[index]; - if (!row || !this.isReorderable(row)) return; -- if ( -- row.type === 'walletGroup' && -- target.closest('[data-native-list-group-parent]')?.dataset -- .nativeListGroupParent !== 'true' -- ) -- return; -+ // OneKey patch: a child drag reorders its parent wallet group as one item. -+ // if ( -+ // row.type === 'walletGroup' && -+ // target.closest('[data-native-list-group-parent]')?.dataset -+ // .nativeListGroupParent !== 'true' -+ // ) -+ // return; - - const view = this.document.defaultView; - const state: PointerReorderState = { -@@ -3225,9 +3881,8 @@ export class NativeListWebEngine { - active: false, - }; - this.pointerReorder = state; -- if (state.pointerType === 'mouse') { -- this.captureReorderPointer(state); -- } else { -+ // OneKey patch: normal wallet taps retain their target until a drag is activated. -+ if (state.pointerType !== 'mouse') { - state.longPressTimer = view?.setTimeout( - () => this.activatePointerReorder(state), - REORDER_TOUCH_LONG_PRESS_MS -@@ -3363,6 +4018,18 @@ export class NativeListWebEngine { - Math.max(0, state.startY - rect.top) - ); - this.reorderPreview.replaceChildren(previewRow.cloneNode(true)); -+ // Cloned previews need their own lease when a source row is recycled during dragging. -+ const originals = previewRow.querySelectorAll('img'); -+ this.reorderPreview.querySelectorAll('img').forEach((image, index) => { -+ const original = originals.item(index); -+ const avatar = original ? webAvatarSources.get(original) : undefined; -+ if (!avatar) return; -+ const paint = image.previousElementSibling; -+ if (paint?.classList.contains('ok-native-list-selector-image-background')) { -+ image.addEventListener('load', () => { (paint as HTMLElement).style.backgroundImage = 'url(' + JSON.stringify(image.currentSrc || image.src) + ')'; }); -+ } -+ configureWebAvatar(image, avatar.source, avatar.uri); -+ }); - const sourceRow = state.workingRows[state.currentIndex]; - const badgeText = sourceRow - ? webWalletGroupReorderBadge(sourceRow) -@@ -3430,6 +4097,7 @@ export class NativeListWebEngine { - - private clearReorderPreviewVisual() { - this.reorderPreview.hidden = true; -+ disposeWebImageRetries(this.reorderPreview); - this.reorderPreview.replaceChildren(); - this.reorderPreview.style.removeProperty('transform'); - this.reorderPreview.style.removeProperty('transition'); diff --git a/yarn.lock b/yarn.lock index 68831dfae6e7..71617f7a6272 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9592,36 +9592,36 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-app-update@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-app-update@npm:3.0.105" +"@onekeyfe/react-native-app-update@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-app-update@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/c3bc2fb6a24daf1bc3bb3f3dac8000771141a56182a47b3b1488b635abb48771fa30df4ec8c3b3d422e22b7ff2b381d4d173e70a8a838dba009b648e0f11f481 + checksum: 10/b69981dee6020be29f2f104dad17fa435161ab072fd602d3fe5d2870ca3154afb846daf7b161c7deeec703d6aed1526d3e60a1250864f50d759264bb4d14e9a4 languageName: node linkType: hard -"@onekeyfe/react-native-auto-size-input@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-auto-size-input@npm:3.0.105" +"@onekeyfe/react-native-auto-size-input@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-auto-size-input@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/247cbba500acab650d86096a4ae0dfe6a519aca795ec36823eb8669dfd4eda88b38e957ea41954da81cc284daae910d4de4ad3b9b82499799eb6a30a2a8b5f8f + checksum: 10/cba112c8f47b56cfbdec33c5e6f9a9f87b5ac5980c2ce6a553a34988734e27da4fad6a1d65ef2bd71588310c37a07df6b96059648a72ab424f4b8f081148d73c languageName: node linkType: hard -"@onekeyfe/react-native-background-thread@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-background-thread@npm:3.0.105" +"@onekeyfe/react-native-background-thread@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-background-thread@npm:3.0.106" peerDependencies: "@onekeyfe/react-native-bundle-update": "*" react: "*" react-native: "*" - checksum: 10/f8f7e270aa6d30c7311991932c93f5d1b47df177a1254a66a5439685b69cda4931ca79e45fc8ae839f4b2cc66fcccca9328d5c96acbf94e195ac1edb7e31ef2c + checksum: 10/7d36703b5102210177fbd99287d3ec9ff029d0d3ffeb3aac4c1708df7765628a9fd3874784f84c2bd5f143e3d09eaf6a105f06ab391c79cdb18b2ab6ea9e345a languageName: node linkType: hard @@ -9635,250 +9635,250 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-bundle-crypto@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-bundle-crypto@npm:3.0.105" +"@onekeyfe/react-native-bundle-crypto@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-bundle-crypto@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/a2793a98567bc79ef8f87e0a7ce3305adf5601fd6fc98037af53aec6dad11724aa41e027cb87bfb99310edcd5690d3e1a918492d539844cbaf5ce4222213c9eb + checksum: 10/ae255859bec8d097b52d96054094fca0da7dc09df2c6fa8afe16822be75cc66315e91c2fd4b6a5f297e9381d16da19c28f08e63a4032e7d4cf6d060aa114f2f5 languageName: node linkType: hard -"@onekeyfe/react-native-bundle-update@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-bundle-update@npm:3.0.105" +"@onekeyfe/react-native-bundle-update@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-bundle-update@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/626b113ca270562f1ff48af7759f6da68cfdfa9cb16a34b2c64172505ef4ee2a4b006dd9b8558f88497c35d7b448356c5f20396e969d99689b3332a0b5dbf8fd + checksum: 10/a738ac6000c0b75f1de66216397c085fe7975712745849d43ad96e13b3b6fdabfc5e5e54eb33a26616dd38d411d5448a83d0c897638339492990b04aab8b6b0c languageName: node linkType: hard -"@onekeyfe/react-native-chart-webview@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-chart-webview@npm:3.0.105" +"@onekeyfe/react-native-chart-webview@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-chart-webview@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/3d639b3208cd1c4f58d6d0d6b3b6a61d70109d33ebaf92ba3d4af728bba9b87dd8e017952f6d19604612232c5247d31ede45e11ab2a9cf8a48b423d3866d5166 + checksum: 10/5b6ebd18bbe77b0e2333bc9d883aea52c3f5c8b434b24460548330c69e3a63ba4b74d88d1ad5ff4a42b058451d8c762f311383784778ff5aa7e4a160628c1ea8 languageName: node linkType: hard -"@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.105" +"@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/ed06707449f79897f5db7a127f38cd1839ad4f87267fcc8db95c163caa5c7026d4d31102f87391d4636f992fe4fdcb5224c075e7876765d542d8bbda396038aa + checksum: 10/f70017d514751b98f8543c867a2ffd4c7d798f299fcda72f8a44dbce66fb1162ed167b539a38443a1c6948bd2da019ba9e8d9e98c7223c7ffbae8528539bd3b4 languageName: node linkType: hard -"@onekeyfe/react-native-cloud-kit-module@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-cloud-kit-module@npm:3.0.105" +"@onekeyfe/react-native-cloud-kit-module@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-cloud-kit-module@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/ad82d3b6997d4fe81dc4de13067922d9b07eb8def9e41cada8898181014322432edbfc7d2345ff8b0fa0cdfc38e9d3cd311d6bc1217f0fa7f1826e8afbfc1945 + checksum: 10/da3ef1173cc83bdb88174c66cc735e5a0155fb71a8c489a9c227aee46b48e409686e57c1af160884dcfa889f2d6b9d033226a458eac2ea7e8b7e566dd19a263d languageName: node linkType: hard -"@onekeyfe/react-native-device-utils@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-device-utils@npm:3.0.105" +"@onekeyfe/react-native-device-utils@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-device-utils@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/a03b85ed2d0b8de24ba9f34d2782af90fc6de5e3de86ce9fef2d106bd2e1e2fa6029604fced22a39f4689dc400e5e04d75dc34e97215502e2888c38c974fe2d8 + checksum: 10/35e066527b3bb00de9a5397e328b5207b299baae7414b9a7eb7a50e6e9861139623623898cd5ca992331b85f9f209955b585af7c1f3d7b8578cfdf4daf26724c languageName: node linkType: hard -"@onekeyfe/react-native-image@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-image@npm:3.0.105" +"@onekeyfe/react-native-image@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-image@npm:3.0.106" peerDependencies: - "@onekeyfe/react-native-skeleton": 3.0.105 + "@onekeyfe/react-native-skeleton": 3.0.106 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/1281cc68c220c62c2f4183b34ea08cc7d4249b189550616f86435b76914061ac674560ea1dcf9f9f0418c979f01edc9254216026867a30b6a3a1491e84f98810 + checksum: 10/fb6703a04cca215af72f5a4b53c2f418c0e87b318ef737f202c301a3108adb58e54f1d6b7584bf1ecfb8912f7fc0860b04c209a436eb4fa931b11d56fc52097c languageName: node linkType: hard -"@onekeyfe/react-native-keychain-module@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-keychain-module@npm:3.0.105" +"@onekeyfe/react-native-keychain-module@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-keychain-module@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/7d7f3fdff5e5c1fcf35058473eb43be6a6a2813c7a9b2e4c355d6485a147f5b47cff6838aca5b1f462d95d138331f8d429f4334ab9c407acace916a608537587 + checksum: 10/22b19e9594f9b3cab9b9fc0c3095d1e258bf1d949061e527a318251db595390e48b040a1471ac4400385ffe802b0740875127daef82127e4f105bbbdee465c7b languageName: node linkType: hard -"@onekeyfe/react-native-lite-card@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-lite-card@npm:3.0.105" +"@onekeyfe/react-native-lite-card@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-lite-card@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/236be331825686b24652dd80418b14faaf3ce2e4f4fa23d92029b391db1b55fbaf064c59c46108d45598a7a2a7cf151d386363e104f4f902083ba9bf85058327 + checksum: 10/274417787a8e531d4ef6fc3950b8ccfcf1f29fe8182a92fc91d6d58a3ed86e1c0fbe3af246ce236ea20ca42a2dec829a54f9b448352d5984c1eacf37919cfdfa languageName: node linkType: hard -"@onekeyfe/react-native-native-list@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-native-list@npm:3.0.105" +"@onekeyfe/react-native-native-list@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-native-list@npm:3.0.106" peerDependencies: - "@onekeyfe/react-native-image": 3.0.105 + "@onekeyfe/react-native-image": 3.0.106 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/8def0a0ebb9a40834402a14b7433e22ced738941603311365fa4680c6871d88c05580b22117aa8cb518cfaa41cb001424e607bfd858b65d91e06098d7b6f2049 + checksum: 10/628ef149f2b5fb35df434a8c4ab70b393d719df6bc421f19de3e1e9997b42a82c6976c15b32ef69b0b446774e7c86932ebe8ffe405cfb4a0b8ab333a7e2092d2 languageName: node linkType: hard -"@onekeyfe/react-native-native-logger@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-native-logger@npm:3.0.105" +"@onekeyfe/react-native-native-logger@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-native-logger@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/bb4897ead06d5f168cd4981f785823776d2c104a86d6fdc40bd956f12c26228638b989a6aa6c1d47818dd9a15cb5a661609574d56d3973d02562c653bf9c4fa9 + checksum: 10/2f622c0e314239fb65b132788045184f74dbb376c8589e9de5a62392b2d7fe676600d2d683e4fa0d74cfbad44f701000b90735f1d9c77ff8c6e0d20f3b56d08d languageName: node linkType: hard -"@onekeyfe/react-native-network-throttle@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-network-throttle@npm:3.0.105" +"@onekeyfe/react-native-network-throttle@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-network-throttle@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/74f70428641f44060f350fa0c47252e08d65ac1af690d7cf40db59707901b3a54addf63b48d46eeb234e534f328d68f76ea57ee670f19215bdd2b174dc6e2166 + checksum: 10/57578cf2d4112c7afa15278cd0bf8a5e5523285e954c3e797268eb237318ea00722610763e2fad336c8f9e150d3c20b69807f280533f09dbe0e6a291c0453efa languageName: node linkType: hard -"@onekeyfe/react-native-perf-memory@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-perf-memory@npm:3.0.105" +"@onekeyfe/react-native-perf-memory@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-perf-memory@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/678eb386997d09dbe957a7857de4baed75926e44f214aa3c93c66d6f8a9a9019861c4e9d7e82a7f20f3f32c54b39e0ad5cb41c54d2435793eb11eff362b3a79d + checksum: 10/5979ffb82cf42fcbfc6fd0c34acc4c13c8f47ff1b488c746d005a2493f2f3f37ef6ca1023ec928bea30fdcbcdf1267a9b1a35f70893b2ed8ea3a863e77af4a10 languageName: node linkType: hard -"@onekeyfe/react-native-perf-stats@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-perf-stats@npm:3.0.105" +"@onekeyfe/react-native-perf-stats@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-perf-stats@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/dab2e9c580dd25ee9c70c3aaa01ac3f87952696978fc886140181ebb7f451c1d9bb6bf10ed573229a4e3ddc7b592df5e0ae52d899b5f8e1e42e30c2a49268244 + checksum: 10/7da7aa70f2c3e4ed8ca4e01bcdd8aefdf1c4c5c33bfca0f8134d70d478e594c188a16a7cde4706c268b90f5b0e0a6742c5c90d4b2c8d38207100c7ed75d41ffc languageName: node linkType: hard -"@onekeyfe/react-native-perp-depth-bar@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-perp-depth-bar@npm:3.0.105" +"@onekeyfe/react-native-perp-depth-bar@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-perp-depth-bar@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/5aead61e3cba1b940857b59ad9356d8279a0e92948debc371fab9dd28586ff242e493d6208bb1139cc067c2e2cb7a8d79dc1f805c63659757ceac19fccffd4a6 + checksum: 10/707e4b50898aa324835e2d9b57b97424be71b78568d38289859d798a2849c4e270ef9561c6603ba1da9d714fc0b33efb8adc6d577efd9ea1d9be31438dbcfe17 languageName: node linkType: hard -"@onekeyfe/react-native-range-downloader@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.105" +"@onekeyfe/react-native-range-downloader@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/3a6e15efa761661c5eb251c49be2aea78a09a93eff49ad1d4580707915bda457259aeecaf3d5776f141d735af74223874b46be71c46512b2aeb345ff69fa0db8 + checksum: 10/db668d7de6ae006d56c13709caa188749abf0e896df62a2a7bbdba8006fa7577d424db8919c516b510479bbe998de4b11ce336dabdd1529856d9793e38bc4995 languageName: node linkType: hard -"@onekeyfe/react-native-scroll-guard@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-scroll-guard@npm:3.0.105" +"@onekeyfe/react-native-scroll-guard@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-scroll-guard@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/07f8f2036d1a68c6b58e4d98ef2731d254c1502cdf0927550e1ffbe3a14b7df71139e289eef78f3d14424b15c88cf928e5dbd0a0a992ef11495bb3d030a11f6e + checksum: 10/018031869815c664abc1b99f3e165f22d81b4e778adb2d0988c0accf33ba3558a688455b968795f373f410baadf994004e5764405380233e57ee114817051e38 languageName: node linkType: hard -"@onekeyfe/react-native-segment-slider@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-segment-slider@npm:3.0.105" +"@onekeyfe/react-native-segment-slider@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-segment-slider@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/1df29a9bfadd1d1c9a5a068737bd8848ad1584cc731e04f8ec1c1d974188dd77a2999f8d434efc4c27751a313c38f9bc022cbb4edd1c0090d99d90a2355b0e33 + checksum: 10/15a5821e2cde5294de955d59980bbc45068f137194a013561e92418d0696a2e0f7a9a4d7e38c643d71e6c2f49ba9d85e139391216530936cc568fa787f55c33a languageName: node linkType: hard -"@onekeyfe/react-native-skeleton@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-skeleton@npm:3.0.105" +"@onekeyfe/react-native-skeleton@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-skeleton@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/9e33d5b4d8e659b5bea5b13eb6db3c14bd0d57de71520917e32da158c303c3a0d4affe23de365dac892bd39a6b11f4b3e5ba0cd9cabb1e87985058263007b249 + checksum: 10/5986f90336d0a28d9c11e1a2dd83a722a5400e1bbce969b95fa3f1a490befb08001f74120bc6383168ba1963f351b3b26d1f29c3c75be8edd5eea9eb2abc80f2 languageName: node linkType: hard -"@onekeyfe/react-native-sni-connect@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.105" +"@onekeyfe/react-native-sni-connect@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/bf467bc866def5ac05359a129ef2af2442bb7ca09b1c557b714e0ded2becc5eb5c8386e24fad0ea8db844f3a6784c8a55aa9a2c35c93aea78c8c38e196bbd0ae + checksum: 10/2f250909cb631525f477633419231a1630497b3f1f5c84cbd06580865c9fd9e159c599048dc69615395f0473062d1eebfdfd1136b63f4c483aeb3c89f1b9393d languageName: node linkType: hard -"@onekeyfe/react-native-splash-screen@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-splash-screen@npm:3.0.105" +"@onekeyfe/react-native-splash-screen@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-splash-screen@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/83e6c52f2c551c1edab54bf2b87496384457c79a97da45677b7de48ca3de72c8f80254455abdda718c600c66568393fab087fac87b4078b5f5e158227848235b + checksum: 10/e9485887d3d625949c0514a62ac5ae8477768f4dfd13b71fd6c786c089d74f0e70365d038c23619f0309357af2677b121f76eea0f9c16f1161f3231dbb9fcea4 languageName: node linkType: hard -"@onekeyfe/react-native-split-bundle-loader@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-split-bundle-loader@npm:3.0.105" +"@onekeyfe/react-native-split-bundle-loader@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-split-bundle-loader@npm:3.0.106" peerDependencies: "@onekeyfe/react-native-bundle-update": "*" react: "*" react-native: "*" - checksum: 10/5d5a5f79a307b0ed35c3040509b29385e51f7935b1713fd8bbfa4f80a74d3b65070282fd92e07105e91701ad34ce3e849b97ad6a575252dac17108bd18a33cc7 + checksum: 10/6e1f0b4cae733514274e76b77563bf46b28f3a07c44e9910b9653c3cc2e04f309b844b913b124786ddfd4ef2bc15b74bf712ae6c752965096db3160204234d5a languageName: node linkType: hard -"@onekeyfe/react-native-tab-view@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-tab-view@npm:3.0.105" +"@onekeyfe/react-native-tab-view@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-tab-view@npm:3.0.106" dependencies: react-freeze: "npm:^1.0.0" sf-symbols-typescript: "npm:^2.0.0" @@ -9886,17 +9886,17 @@ __metadata: peerDependencies: react: "*" react-native: "*" - checksum: 10/c68887693618f012cef6141571a8dafccdef22e0cfb1dfa01cb45246828ac36b41dbfcbf37500a06e50a573e7991818dddd4fa3500496c686dbfbfd00812b647 + checksum: 10/31277a7f38e453c93615c1f11f4c6d37fb9fbd279dba3bd7f902d8aa48a22acbd702c706730409bf59c80580d5bff4ed70d4679263a2f90b6ab256cad1e7058c languageName: node linkType: hard -"@onekeyfe/react-native-text-input@npm:3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-text-input@npm:3.0.105" +"@onekeyfe/react-native-text-input@npm:3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-text-input@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/413d4817e571a2ca64850e89be4ac3c6101f50574a867e88bd5f4cdd0a7cae223957c0cdb1568e83ba1f2589b3548b5efa34b8de65d44100b52bf5aa7685690f + checksum: 10/45a223297682223b641d78a8e8be4dcff24f4d3f95f49ce4865ab531b8ca1e0a149b5ac5ed501ea6055fd7b6c3e96c4734fe4987df8fc56c3f51e81b3fde1c21 languageName: node linkType: hard @@ -10116,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.105" + react-native-get-random-values: "npm:@onekeyfe/react-native-get-random-values@3.0.106" react-native-harness: "npm:1.0.0-alpha.25" react-native-reanimated: "npm:4.5.1" react-native-screens: "npm:~4.26.0" @@ -10166,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.105" - "@onekeyfe/react-native-segment-slider": "npm:3.0.105" - "@onekeyfe/react-native-tab-view": "npm:3.0.105" + "@onekeyfe/react-native-scroll-guard": "npm:3.0.106" + "@onekeyfe/react-native-segment-slider": "npm:3.0.106" + "@onekeyfe/react-native-tab-view": "npm:3.0.106" "@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" @@ -10314,7 +10314,7 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyhq/kit@workspace:packages/kit" dependencies: - "@onekeyfe/react-native-native-list": "npm:3.0.105" + "@onekeyfe/react-native-native-list": "npm:3.0.106" "@onekeyhq/components": "npm:*" "@types/d3-scale": "npm:^4.0.3" "@types/d3-shape": "npm:^3.1.1" @@ -10348,39 +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.105" - "@onekeyfe/react-native-auto-size-input": "npm:3.0.105" - "@onekeyfe/react-native-background-thread": "npm:3.0.105" + "@onekeyfe/react-native-app-update": "npm:3.0.106" + "@onekeyfe/react-native-auto-size-input": "npm:3.0.106" + "@onekeyfe/react-native-background-thread": "npm:3.0.106" "@onekeyfe/react-native-ble-utils": "npm:0.1.6" - "@onekeyfe/react-native-bundle-crypto": "npm:3.0.105" - "@onekeyfe/react-native-bundle-update": "npm:3.0.105" - "@onekeyfe/react-native-chart-webview": "npm:3.0.105" - "@onekeyfe/react-native-check-biometric-auth-changed": "npm:3.0.105" - "@onekeyfe/react-native-cloud-kit-module": "npm:3.0.105" - "@onekeyfe/react-native-device-utils": "npm:3.0.105" - "@onekeyfe/react-native-image": "npm:3.0.105" - "@onekeyfe/react-native-keychain-module": "npm:3.0.105" - "@onekeyfe/react-native-lite-card": "npm:3.0.105" - "@onekeyfe/react-native-native-list": "npm:3.0.105" - "@onekeyfe/react-native-native-logger": "npm:3.0.105" - "@onekeyfe/react-native-network-throttle": "npm:3.0.105" - "@onekeyfe/react-native-perf-memory": "npm:3.0.105" - "@onekeyfe/react-native-perf-stats": "npm:3.0.105" - "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.105" - "@onekeyfe/react-native-range-downloader": "npm:3.0.105" - "@onekeyfe/react-native-scroll-guard": "npm:3.0.105" - "@onekeyfe/react-native-segment-slider": "npm:3.0.105" - "@onekeyfe/react-native-skeleton": "npm:3.0.105" - "@onekeyfe/react-native-sni-connect": "npm:3.0.105" - "@onekeyfe/react-native-splash-screen": "npm:3.0.105" - "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.105" - "@onekeyfe/react-native-tab-view": "npm:3.0.105" - "@onekeyfe/react-native-text-input": "npm:3.0.105" + "@onekeyfe/react-native-bundle-crypto": "npm:3.0.106" + "@onekeyfe/react-native-bundle-update": "npm:3.0.106" + "@onekeyfe/react-native-chart-webview": "npm:3.0.106" + "@onekeyfe/react-native-check-biometric-auth-changed": "npm:3.0.106" + "@onekeyfe/react-native-cloud-kit-module": "npm:3.0.106" + "@onekeyfe/react-native-device-utils": "npm:3.0.106" + "@onekeyfe/react-native-image": "npm:3.0.106" + "@onekeyfe/react-native-keychain-module": "npm:3.0.106" + "@onekeyfe/react-native-lite-card": "npm:3.0.106" + "@onekeyfe/react-native-native-list": "npm:3.0.106" + "@onekeyfe/react-native-native-logger": "npm:3.0.106" + "@onekeyfe/react-native-network-throttle": "npm:3.0.106" + "@onekeyfe/react-native-perf-memory": "npm:3.0.106" + "@onekeyfe/react-native-perf-stats": "npm:3.0.106" + "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.106" + "@onekeyfe/react-native-range-downloader": "npm:3.0.106" + "@onekeyfe/react-native-scroll-guard": "npm:3.0.106" + "@onekeyfe/react-native-segment-slider": "npm:3.0.106" + "@onekeyfe/react-native-skeleton": "npm:3.0.106" + "@onekeyfe/react-native-sni-connect": "npm:3.0.106" + "@onekeyfe/react-native-splash-screen": "npm:3.0.106" + "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.106" + "@onekeyfe/react-native-tab-view": "npm:3.0.106" + "@onekeyfe/react-native-text-input": "npm:3.0.106" "@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.105" + "@react-native-async-storage/async-storage": "npm:@onekeyfe/react-native-async-storage@3.0.106" "@react-native-community/datetimepicker": "npm:9.1.0" "@react-native-community/netinfo": "npm:12.0.1" "@react-native-community/slider": "npm:5.2.0" @@ -10441,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.105" + react-native-aes-crypto: "npm:@onekeyfe/react-native-aes-crypto@3.0.106" 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.105" + react-native-cloud-fs: "npm:@onekeyfe/react-native-cloud-fs@3.0.106" 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.105" - react-native-fast-pbkdf2: "npm:@onekeyfe/react-native-pbkdf2@3.0.105" + react-native-dns-lookup: "npm:@onekeyfe/react-native-dns-lookup@3.0.106" + react-native-fast-pbkdf2: "npm:@onekeyfe/react-native-pbkdf2@3.0.106" 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.105" + react-native-get-random-values: "npm:@onekeyfe/react-native-get-random-values@3.0.106" 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.105" + react-native-network-info: "npm:@onekeyfe/react-native-network-info@3.0.106" 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.105" + react-native-pager-view: "npm:@onekeyfe/react-native-pager-view@3.0.106" react-native-passkeys: "npm:0.3.3" react-native-permissions: "npm:5.4.4" - react-native-ping: "npm:@onekeyfe/react-native-ping@3.0.105" + react-native-ping: "npm:@onekeyfe/react-native-ping@3.0.106" react-native-purchases: "npm:10.4.3" react-native-qrcode-styled: "npm:0.4.0" react-native-quick-base64: "npm:^3.0.0" @@ -10477,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.105" + react-native-tcp-socket: "npm:@onekeyfe/react-native-tcp-socket@3.0.106" 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.105" + react-native-zip-archive: "npm:@onekeyfe/react-native-zip-archive@3.0.106" readable-stream: "npm:^3.6.0" realm: "npm:20.2.0" realm-flipper-plugin-device: "npm:^1.1.0" @@ -13556,15 +13556,15 @@ __metadata: languageName: node linkType: hard -"@react-native-async-storage/async-storage@npm:@onekeyfe/react-native-async-storage@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-async-storage@npm:3.0.105" +"@react-native-async-storage/async-storage@npm:@onekeyfe/react-native-async-storage@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-async-storage@npm:3.0.106" dependencies: merge-options: "npm:^3.0.4" peerDependencies: react: "*" react-native: "*" - checksum: 10/4dc0cc77ba52b26964a8cc0fa1143c1da9c7b5f09169fa25ca3ad6166ec39cbcdbaeac75f92606df593915ee394a3b88f435bc77af7a3a2eb9e86fe8ccbfca1b + checksum: 10/04c5b154d9c9067713a511babc2ff71a0503de863cff9198793ba621bc4003874788a579ee6ebd233b641192e09a0615ee73228cacdd69232829881d04ea4579 languageName: node linkType: hard @@ -42558,13 +42558,13 @@ __metadata: languageName: node linkType: hard -"react-native-aes-crypto@npm:@onekeyfe/react-native-aes-crypto@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-aes-crypto@npm:3.0.105" +"react-native-aes-crypto@npm:@onekeyfe/react-native-aes-crypto@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-aes-crypto@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/bee3109318e98acd383bb7dece589a16625b42e656dfec53682db0d320b67da4c078546c5cdd34be15079c9c3960f3e4bb1e049d5d753bdc37abc99773d833a7 + checksum: 10/c7694392949222b50e7787dc911b1808adc459f0195f3a9fbf5a6e827126ba965ea2fee02ba1e6dfa3f34a1efd50f1e7d3e9b739930ca95f6406019eb556474e languageName: node linkType: hard @@ -42634,13 +42634,13 @@ __metadata: languageName: node linkType: hard -"react-native-cloud-fs@npm:@onekeyfe/react-native-cloud-fs@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-cloud-fs@npm:3.0.105" +"react-native-cloud-fs@npm:@onekeyfe/react-native-cloud-fs@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-cloud-fs@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/e0a72ffa16902f296e1d7ef730d1f2e87049cd694e163a5af52080d49175dcf1fd5eb8dec78634d8fb33613dde2b22fa9ef8776d0ed2213097a34ebecad1d055 + checksum: 10/8baa9a1514fd465fc2ff10eb5f9549e070193cddba9f1bb1fe45c5ef21533173a039879501b5337941791fda40ba88926459623f25829aaf9fa677f27b8f043e languageName: node linkType: hard @@ -42714,13 +42714,13 @@ __metadata: languageName: node linkType: hard -"react-native-dns-lookup@npm:@onekeyfe/react-native-dns-lookup@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-dns-lookup@npm:3.0.105" +"react-native-dns-lookup@npm:@onekeyfe/react-native-dns-lookup@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-dns-lookup@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/a51b3331a4f9b11e8f5b5de5f08e1cc35ad7943434ae1d463a5c57a43184dfd9c82a5ba05b1c026ff8b977342ba5b8b4ef4ce8664a7db68a0b5a76b592f48862 + checksum: 10/420ce3a9880e30152110aff065fc8bc21b6876d5577d4e1ae72f59093051cc01119820de5d3a5aeb2eef5dd6ce098b4b159a0d04b358a1c74c95cee6c106f003 languageName: node linkType: hard @@ -42737,13 +42737,13 @@ __metadata: languageName: node linkType: hard -"react-native-fast-pbkdf2@npm:@onekeyfe/react-native-pbkdf2@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-pbkdf2@npm:3.0.105" +"react-native-fast-pbkdf2@npm:@onekeyfe/react-native-pbkdf2@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-pbkdf2@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/a4e433111bd29f5f54da2f4280fcca788d3ad9c1ae227b132b801f69d76c8fa207e7ac6b29638358389efd78b5e395a6064e26c52f22b6d8d03d376376265865 + checksum: 10/0e342c8b4e3a9e4d8686dc0330d8647d32b2050757f09ebd2ddd699e9aa7cbbf05ead4d8ca5555c350b4eb7a2f5b56c54c7977cdb3dcebd24133af7a4d38dafa languageName: node linkType: hard @@ -42784,14 +42784,14 @@ __metadata: languageName: node linkType: hard -"react-native-get-random-values@npm:@onekeyfe/react-native-get-random-values@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-get-random-values@npm:3.0.105" +"react-native-get-random-values@npm:@onekeyfe/react-native-get-random-values@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-get-random-values@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/28cd8cecc75289096e9e6a5e44bdc71b1bf41a9773034f4426d92ab810a2182d89b0f48cf2af268dc6529557c35e08c70076ccd418abb8a25f0bca0c5b3b4b12 + checksum: 10/041d1e8d339f475a783a5b84e6dda5b38c002050be8259604de964cf0615e24aff3f4ea182ee3504ad239d216c82f8bc6e66ae927430935cf68f455649424909 languageName: node linkType: hard @@ -42952,13 +42952,13 @@ __metadata: languageName: node linkType: hard -"react-native-network-info@npm:@onekeyfe/react-native-network-info@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-network-info@npm:3.0.105" +"react-native-network-info@npm:@onekeyfe/react-native-network-info@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-network-info@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/6b90f7ef3520b86de2897d7c30854d91d1128c811bb887677e4a5c1b6b63db19841715ef8fbb33302a4bf675aa20823a98b5ab4a55126ec1b6212d6219333c54 + checksum: 10/117826d2336e16fd546911e1e2cfe9f4a413ce230d4bbafc9e60ea0e2a8f3d5ce47a1019194af7a71b740ac48b76d3ba6bff4fe05ac2584a47b168950b37be04 languageName: node linkType: hard @@ -42982,13 +42982,13 @@ __metadata: languageName: node linkType: hard -"react-native-pager-view@npm:@onekeyfe/react-native-pager-view@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-pager-view@npm:3.0.105" +"react-native-pager-view@npm:@onekeyfe/react-native-pager-view@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-pager-view@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/babc944b0c9b1d510c7f85455259b0b7d781803eefced5d0642361c21bbf5e95bbab92265d6797adbae778ff89cfefe4c4cf4cfc9e1a6f2b8ce46b8e6f4a07b3 + checksum: 10/c55673ab15b1e65aa8524c61427d26bab2987311ae51627e68d9a68727a93bcc70a393402ff055502caf55b864104748938c348cdcb66aeeb879be62561b6932 languageName: node linkType: hard @@ -43017,13 +43017,13 @@ __metadata: languageName: node linkType: hard -"react-native-ping@npm:@onekeyfe/react-native-ping@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-ping@npm:3.0.105" +"react-native-ping@npm:@onekeyfe/react-native-ping@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-ping@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/d5062b3b9bae8cd5b6538256eabd6ae685f14775aa71c79efeb185b4e25e5a6bf6e807c49e9b9a752db95c9ed5ee425af68e1e8daf4fc544b216d41b9f7a675d + checksum: 10/440c8520708261a9a0d417187af2948303c6406be408724703ae91a3d9af961af410cbf3cf1d0ae86f9da88b577c8c1a6cdcbd515a1e141ee632d0b7730b743a languageName: node linkType: hard @@ -43207,13 +43207,13 @@ __metadata: languageName: node linkType: hard -"react-native-tcp-socket@npm:@onekeyfe/react-native-tcp-socket@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-tcp-socket@npm:3.0.105" +"react-native-tcp-socket@npm:@onekeyfe/react-native-tcp-socket@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-tcp-socket@npm:3.0.106" peerDependencies: react: "*" react-native: "*" - checksum: 10/706af0a03d86e65507ae0e34391164d5b75cd21ded8e5d4d71c267ab4a50dced8c05668106754ae335a9ea29950ecdf86955fdbdd3ca52b086470aab8c8a81cb + checksum: 10/840811f4ba91de813cf7209594b5e3c23d923dd95252274ed33b030966c327d299bbf31a921a9084d78925303fa87412969436f6897bbdc6acb2c966fe09da80 languageName: node linkType: hard @@ -43349,14 +43349,14 @@ __metadata: languageName: node linkType: hard -"react-native-zip-archive@npm:@onekeyfe/react-native-zip-archive@3.0.105": - version: 3.0.105 - resolution: "@onekeyfe/react-native-zip-archive@npm:3.0.105" +"react-native-zip-archive@npm:@onekeyfe/react-native-zip-archive@3.0.106": + version: 3.0.106 + resolution: "@onekeyfe/react-native-zip-archive@npm:3.0.106" peerDependencies: react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/95dc0eadc4423e1853bc675b42613278fe85f0e2bb55f54400802cea1b91d174dc6b8a3946c151d9b6fa9c20602cae23e0eb67beb03cd8a557710d966698255b + checksum: 10/7231f3c1d0f74737f0b56b5108da62064593a05341ffe68501d8276f101c7ab74cca70715a440109f0af8bb9e9cf04f9fdeac1f6c953501d8ea9b07b196a9f6e languageName: node linkType: hard From 91c2606165d6f543d29a3d0da7ff2c576f16d19e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 09:28:16 +0800 Subject: [PATCH 14/18] fix: align native image sizing and register wallet stress page --- .../bundle-registry/module-id-registry.json | 1 + .../primitives/Image/ImageV2.native.test.tsx | 75 +++++++++++++++ .../src/primitives/Image/ImageV2.native.tsx | 63 +------------ .../src/primitives/Image/optimization.test.ts | 6 +- .../src/primitives/Image/preload.native.ts | 74 ++++----------- .../src/primitives/Image/preload.test.ts | 94 +++++++++---------- .../useNetworkListPresentationV2.test.tsx | 3 +- .../useNetworkListPresentationV2.ts | 3 +- .../src/utils/tosImageResizeUtils.test.ts | 73 ++++++++++++-- .../shared/src/utils/tosImageResizeUtils.ts | 40 ++++---- 10 files changed, 231 insertions(+), 201 deletions(-) create mode 100644 packages/components/src/primitives/Image/ImageV2.native.test.tsx diff --git a/apps/mobile/bundle-registry/module-id-registry.json b/apps/mobile/bundle-registry/module-id-registry.json index a555f2e41edc..8722e4a908dd 100644 --- a/apps/mobile/bundle-registry/module-id-registry.json +++ b/apps/mobile/bundle-registry/module-id-registry.json @@ -22645,6 +22645,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/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/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx index ff57a80f317a..469c23a8e299 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.test.tsx @@ -64,8 +64,7 @@ describe('network currency presentation V2', () => { uri: 'https://example.com/eth.png', width: 32, height: 32, - resizeWidth: 32, - optimize: false, + optimize: true, cachePolicy: 'memory-disk', }, ]); diff --git a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts index ac093bbeffe8..9991eaba0899 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/useNetworkListPresentationV2.ts @@ -99,8 +99,7 @@ export async function preloadNetworkImagesV2(networks: IServerNetwork[]) { uri, width: 32, height: 32, - resizeWidth: 32, - optimize: false, + optimize: true, cachePolicy: 'memory-disk', })), ); 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({ From fbcadf203a3fac4e30a878d209c2c29a79c2c2e5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 09:37:50 +0800 Subject: [PATCH 15/18] chore: upgrade native modules to 3.0.107 --- apps/mobile/ios/Podfile.lock | 160 ++++++------- apps/mobile/package.json | 76 +++--- package.json | 4 +- packages/components/package.json | 6 +- packages/kit/package.json | 2 +- yarn.lock | 394 +++++++++++++++---------------- 6 files changed, 321 insertions(+), 321 deletions(-) diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock index a35285821ca7..4bf1af92668a 100644 --- a/apps/mobile/ios/Podfile.lock +++ b/apps/mobile/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.106): + - 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.106): + - AsyncStorage (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -51,7 +51,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.106): + - AutoSizeInput (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -74,7 +74,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.106): + - BackgroundThread (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -121,7 +121,7 @@ PODS: - ExpoModulesCore - SPAlert (~> 4.2) - SPIndicator (~> 1.6) - - ChartWebview (3.0.106): + - ChartWebview (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -145,7 +145,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.106): + - CloudFs (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -166,7 +166,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.106): + - 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.106): + - 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.106): + - 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.106): + - NetworkInfo (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -525,7 +525,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.106): + - 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.106) + - Skeleton (= 3.0.107) - Yoga - - OneKeyTextInput (3.0.106): + - OneKeyTextInput (3.0.107): - React-Core - OpenSSL-Universal (3.6.2000) - - Pbkdf2 (3.0.106): + - Pbkdf2 (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -576,7 +576,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.106): + - PerpDepthBar (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -599,7 +599,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.106): + - Ping (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2116,10 +2116,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list (3.0.106): + - react-native-native-list (3.0.107): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.106) + - OneKeyImage (= 3.0.107) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -2161,7 +2161,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (3.0.106): + - react-native-pager-view (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2335,7 +2335,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view (3.0.106): + - react-native-tab-view (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2346,7 +2346,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.106) + - react-native-tab-view/common (= 3.0.107) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2357,7 +2357,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view/common (3.0.106): + - react-native-tab-view/common (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2788,7 +2788,7 @@ PODS: - React-perflogger (= 0.86.2) - React-utils (= 0.86.2) - ReactNativeDependencies - - ReactNativeAppUpdate (3.0.106): + - ReactNativeAppUpdate (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2812,7 +2812,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.106): + - ReactNativeBundleCrypto (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2836,7 +2836,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.106): + - ReactNativeBundleUpdate (3.0.107): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2885,7 +2885,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.106): + - ReactNativeCheckBiometricAuthChanged (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2910,7 +2910,7 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.106): + - ReactNativeDeviceUtils (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2955,7 +2955,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeGetRandomValues (3.0.106): + - ReactNativeGetRandomValues (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -2979,7 +2979,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.106): + - ReactNativeLiteCard (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3001,7 +3001,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.106): + - ReactNativeNativeLogger (3.0.107): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -3025,7 +3025,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeNetworkThrottle (3.0.106): + - ReactNativeNetworkThrottle (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3048,7 +3048,7 @@ PODS: - Yoga - ReactNativePasskeys (0.3.3): - ExpoModulesCore - - ReactNativePerfMemory (3.0.106): + - ReactNativePerfMemory (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3072,7 +3072,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.106): + - ReactNativePerfStats (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3096,7 +3096,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.106): + - ReactNativeRangeDownloader (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3121,7 +3121,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.106): + - ReactNativeSplashScreen (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3167,7 +3167,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativeZipArchive (3.0.106): + - ReactNativeZipArchive (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3650,7 +3650,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.106): + - ScrollGuard (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3681,7 +3681,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - SegmentSlider (3.0.106): + - SegmentSlider (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3707,7 +3707,7 @@ PODS: - Sentry (9.5.1): - Sentry/Core (= 9.5.1) - Sentry/Core (9.5.1) - - Skeleton (3.0.106): + - Skeleton (3.0.107): - hermes-engine - NitroModules - RCTRequired @@ -3730,7 +3730,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.106): + - SniConnect (3.0.107): - EMASCurl (= 1.5.5) - hermes-engine - RCTRequired @@ -3755,7 +3755,7 @@ PODS: - Yoga - SPAlert (4.2.0) - SPIndicator (1.6.4) - - SplitBundleLoader (3.0.106): + - SplitBundleLoader (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -3778,7 +3778,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.106): + - TcpSocket (3.0.107): - hermes-engine - RCTRequired - RCTTypeSafety @@ -4427,19 +4427,19 @@ CHECKOUT OPTIONS: :git: https://github.com/OneKeyHQ/app-modules.git SPEC CHECKSUMS: - AesCrypto: 7909e4a082134b01fb2d64e3249f78e4911f2f1e + AesCrypto: 5252c73c9e6e7659a24c470c83ab7adc54b233f5 AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063 AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f - AsyncStorage: 852df8e7bad2af341ced8208a0b0484e49fc6105 - AutoSizeInput: c8ab18e22a148c270b59a9342b32b11e3fd2d15b - BackgroundThread: c0a4f0ea1613ad742f23a90d57b5a44130289544 + AsyncStorage: 8582cc2f35600a5cc6867481a88f3bd9b86cf625 + AutoSizeInput: b7fd89c5da5911bebd882af221bcfcf93ab722de + BackgroundThread: 901fab25beddf6be56706293162d3ebe7573531f BleUtils: 00f63c6bf8115301f8f47774b6e978f7174b4bc1 Burnt: e3a3397e26172fca31a59bb27421475a58068836 - ChartWebview: 4826820cef1a13c401e3571a18f2c5e81a21af67 - CloudFs: 7d02ff7cc5690c4bec2e411675971d1eee7ff91c - CloudKitModule: 9f62ca7c1682ae1ee7d83d5b2d7bd1798b581e32 + ChartWebview: 76d4f6ca8b9ad507613e63ccd24fc0be68f02dea + CloudFs: cb3287eaa28e96d15e7a1c7e7d14316d3d558c1b + CloudKitModule: 18821fe56070ba6cac43d4b82b39929907331d71 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 04af040f1ac40d201f4bef8fbb6266843f638879 + DnsLookup: b245cb29d6033486e278cad3730f9a82886b8401 EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f EXApplication: bbd517d50878ca1d121fb3843392beb210181e28 EXConstants: e283cc77f61bddf6537e8ec1d351985e2394168a @@ -4488,22 +4488,22 @@ SPEC CHECKSUMS: JCoreRN: d985de509185b381c177fde4bb6bfc686089fdbd JPushRN: 807bc962b2f25860e5c0caa5fcb7b4910572b890 JuiceboxSdk: b2222491ce92b263694c217ea202a54b66c5ec5f - KeychainModule: 40c8cbb7bf307f0dab48dabfee0a87a299ca6fd7 + KeychainModule: 1854974e50ce9494c3b55ab9ad73897f1c2a6144 libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 lottie-ios: 8f959969761e9c45d70353667d00af0e5b9cadb3 lottie-react-native: 3234694e4dbd43853060e5eba15a35c323ed29ee MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa MultiplatformBleAdapter: b1fddd0d499b96b607e00f0faa8e60648343dc1d - NetworkInfo: 7524267e5076b1972ae36d5f39c3d7517e863d37 + NetworkInfo: a05db6c51e814671e3ce49e6f0341c2a3fba9eb9 NitroMmkv: 437a1303946283cfefe556d74e989228874aa8c0 NitroModules: d5be9f4559fc5178388ccf3ba2e152230b766d67 - OneKeyImage: f2c8ba7e13442cdd583360dadae3890e41390d42 - OneKeyTextInput: 679bd3e51ff58a227e1043e7e6899eda50cd7b58 + OneKeyImage: 971ddaf24649cd3dc624a36fbc06b4991f9220e1 + OneKeyTextInput: cb87871532056f8f450915855e878ab122357b5c OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e - Pbkdf2: 5f11d2a96171c0233f2ebcc24a6329a9bcdabc96 - PerpDepthBar: 620f028a5794f6021344f833ef24284c4fe72d6b - Ping: 60410bd86683b4381bd6607ef8fd270d2a4dba05 + Pbkdf2: 9005e2cd770f24f1ace79966eea9c5048cb32fa7 + PerpDepthBar: d55e289b31bb0ab800e8080e32db101c6928d9cb + Ping: 5406b36148c112cc21f8a60618d142c1b7a5cbe3 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PurchasesHybridCommon: 097af88db3cb39b415bef2f3140e6b3324899756 QuickCrypto: 459dd2f5b5c33f115238c08a9d75ace23bdf5601 @@ -4548,14 +4548,14 @@ SPEC CHECKSUMS: react-native-compat: 27466917b93b2da7c1eed1022227dba8c69d4ef5 react-native-document-picker: 598c549d1cbcc8c1f73abfbb5369d2faa10da242 react-native-keyboard-controller: 2ef1abca0f1b5ffc41c282c9e5f9c5576f83a53d - react-native-native-list: faa479ef001f5644c1b2a0a49176d4d5e96a668b + react-native-native-list: 68ce1746c104f0981554927466bdf0334537d979 react-native-netinfo: ac0848a4773ef5bd015399444409b5ea9ed75fa3 - react-native-pager-view: 1868a50140dd388bf99383d9d54193e414b9be9f + 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: 52652a951cdea8ca2fedcc8822924e4ec68ff95b + react-native-tab-view: 2fd661e6e43a9f9e06481de3c00d4179ea295ca1 react-native-view-shot: e31564b1d0c57add676123f74ed1b6e7c7e22851 react-native-webview: d2b92fc3878f206cc5fecd618f4fb5b8d1652bd6 react-native-webview-cleaner: c2d3bbb850105553c845303044234ca82062d592 @@ -4593,25 +4593,25 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 ReactCodegen: 5f06f18a986ad124808f4bdae7d4051fe157a5b1 ReactCommon: df0928b8d2064de53c42645dcf684c6e7bdea48c - ReactNativeAppUpdate: a6fadb2428d6b2b0fa6af5f1a749578a185877a7 - ReactNativeBundleCrypto: b58243c919f2afeab869538e79b20d81a0d532f0 - ReactNativeBundleUpdate: 2b1680db9671f69d489e3feaba99d5e379dc989a + ReactNativeAppUpdate: 5dd9f61bff6be5138d1ac9742b8bf4aa88e9e8ae + ReactNativeBundleCrypto: d1f65ffbac1f35aa4f549dc3bae59a0e19cca249 + ReactNativeBundleUpdate: f7fefaec703083f0ded864b9eea2536f55fcfd00 ReactNativeCameraKit: 21f6c85397cfcb494dfa453a6fb4c9933a7c1bef - ReactNativeCheckBiometricAuthChanged: a8c1bf582bc3bfde5efb154db20d632168b18a77 + ReactNativeCheckBiometricAuthChanged: da1901aceda741530084663a173deac3762363cb ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: 30ee562d8198bd7586b445364b4640f878200acb + ReactNativeDeviceUtils: 80695e7c13c93ff324438f230776f4833571876a ReactNativeFs: 4704ddcd290a4f26195f7236c7237611360df74b - ReactNativeGetRandomValues: 864eae0a5da79341367b5f401b7694a4f95e2101 - ReactNativeLiteCard: 7a1bca1bad58cc431f26ce9c1cd2eab245cdb374 - ReactNativeNativeLogger: 493986816ca6a5ad1832c6bc7936403b9bc79340 - ReactNativeNetworkThrottle: dbebabf25d19eaee78db3d3925f53621c0ea2621 + ReactNativeGetRandomValues: 97bb7325810e140de7d6d12f1c6899ade7a8f07d + ReactNativeLiteCard: 972d50dd7653d537c706cda7513f026fe3f0695d + ReactNativeNativeLogger: 5d57c92f2542f5b35284ed2d86d33856ad5450a9 + ReactNativeNetworkThrottle: 0ae3db713234fc86dca774c54b75126df5ddb239 ReactNativePasskeys: 9e950e8cbf0e7d6aad9df4dcd21cee0efeb4e5cd - ReactNativePerfMemory: 53ae7e4ed47e291bc02e865ee14b5c7c5bdf7bb1 - ReactNativePerfStats: 432b69f23e6dc0bf6e299724292937b3c23b0829 - ReactNativeRangeDownloader: c813e2a6ee477bc1e916f2024051e53fc524f45d - ReactNativeSplashScreen: 4d73adce994f5c27d178ca0cd0169b5f7178cfbb + ReactNativePerfMemory: d9f6950a48c8946fcda5305e405e9f21462eebf3 + ReactNativePerfStats: 510936e1c8fed0d9ffa49d2ec4ee9f97e7b9b448 + ReactNativeRangeDownloader: 7e20d3503ccb77cb05590334e07778fba9b18a59 + ReactNativeSplashScreen: 91ad22849f777b705a34be231ab9f850c19aa96d ReactNativeVideo: 36938964abd84cb1355c5844d89feb501f3fbf93 - ReactNativeZipArchive: 464042ea7d079449571716072bd055b9a3b6e672 + ReactNativeZipArchive: 427024f750cb2913ba880fef941cb3347b4c1539 RealmJS: 1c37c6bdfe060f4caa0f9175aa0eedb962622ee1 RevenueCat: 72d1e14966339bf38c41d9b2841ef64c8fd79646 RNCMaskedView: a543b7a36c195a519d1bd7ef0291f1cbc6a84a0d @@ -4628,19 +4628,19 @@ SPEC CHECKSUMS: RNSentry: f9219292e372d83cccb99b7f5fa7a9d06c204adf RNSVG: 26c9fd280121dbb1639fae26d476a54c8016ed81 RNWorklets: 4405c9ce44ccd5af4e389229bbdc5e0314f2eee1 - ScrollGuard: 971d2a7589962614a210ca815e580a0b2338bb84 + ScrollGuard: c369797d82fc6895ddfe4735ccfe391d80074c9c SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 6f501d7103882d1ebd9465ce412c39a286aeb60d + SegmentSlider: 39036f647b488528ff29064993c22ca5c11c1ee7 Sentry: 7475eb7bf6a41d7505f46341706015ad2d1766b9 - Skeleton: a66ac67dc8e30d32a5b82906601f6947f45b628f - SniConnect: 89b01ad65feab2929488c1100e1cf2998f669929 + Skeleton: 2cb116c0955e2eb92b5b1937aedb895a1f704b6c + SniConnect: 291f2b9fb3daddc878a8eef2bfa7a2e0b88389bb SPAlert: 735da1f16a887e294719217572ce1f936d8c8782 SPIndicator: 93e0a4fb23de51294ac48e874c0f081a5e293e4f - SplitBundleLoader: eaacec9de73fc174ff3a5b244349261e46a0ee54 + SplitBundleLoader: 6b34908dc35955e2b4a70de723457468da73702d SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: d38cd72416df7b199d746e6851d643659aeecb86 + TcpSocket: 53c9103368b3782d890da7a416f44f34d961b819 TOCropViewController: 5fa42dd0ac8c32790c06fc6057831d17b3b16857 Yoga: 0b38f02674a32b9a15de1f41f8680ffa06eaf8ef ZXingObjC: 8898711ab495761b2dbbdec76d90164a6d7e14c5 diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9735fdaf90a7..fd7f9df5af93 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -77,39 +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.106", - "@onekeyfe/react-native-auto-size-input": "3.0.106", - "@onekeyfe/react-native-background-thread": "3.0.106", + "@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.106", - "@onekeyfe/react-native-bundle-update": "3.0.106", - "@onekeyfe/react-native-chart-webview": "3.0.106", - "@onekeyfe/react-native-check-biometric-auth-changed": "3.0.106", - "@onekeyfe/react-native-cloud-kit-module": "3.0.106", - "@onekeyfe/react-native-device-utils": "3.0.106", - "@onekeyfe/react-native-image": "3.0.106", - "@onekeyfe/react-native-keychain-module": "3.0.106", - "@onekeyfe/react-native-lite-card": "3.0.106", - "@onekeyfe/react-native-native-list": "3.0.106", - "@onekeyfe/react-native-native-logger": "3.0.106", - "@onekeyfe/react-native-network-throttle": "3.0.106", - "@onekeyfe/react-native-perf-memory": "3.0.106", - "@onekeyfe/react-native-perf-stats": "3.0.106", - "@onekeyfe/react-native-perp-depth-bar": "3.0.106", - "@onekeyfe/react-native-range-downloader": "3.0.106", - "@onekeyfe/react-native-scroll-guard": "3.0.106", - "@onekeyfe/react-native-segment-slider": "3.0.106", - "@onekeyfe/react-native-skeleton": "3.0.106", - "@onekeyfe/react-native-sni-connect": "3.0.106", - "@onekeyfe/react-native-splash-screen": "3.0.106", - "@onekeyfe/react-native-split-bundle-loader": "3.0.106", - "@onekeyfe/react-native-tab-view": "3.0.106", - "@onekeyfe/react-native-text-input": "3.0.106", + "@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.106", + "@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", @@ -158,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.106", + "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.106", + "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.106", - "react-native-fast-pbkdf2": "npm:@onekeyfe/react-native-pbkdf2@3.0.106", + "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.106", + "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.106", + "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.106", + "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.106", + "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", @@ -194,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.106", + "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.106", + "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/package.json b/package.json index d82318e8c8bf..72f5c2f3cc0d 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.106", + "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.106", + "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 2edeed9c00e2..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.106", - "@onekeyfe/react-native-segment-slider": "3.0.106", - "@onekeyfe/react-native-tab-view": "3.0.106", + "@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/kit/package.json b/packages/kit/package.json index c952e88a8b4e..4f3744f1f957 100644 --- a/packages/kit/package.json +++ b/packages/kit/package.json @@ -4,7 +4,7 @@ "private": true, "main": "src/index.tsx", "dependencies": { - "@onekeyfe/react-native-native-list": "3.0.106", + "@onekeyfe/react-native-native-list": "3.0.107", "@onekeyhq/components": "*", "@types/url-parse": "^1.4.8", "date-fns": "2.30.0", diff --git a/yarn.lock b/yarn.lock index 71617f7a6272..3ba5e1376f28 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9592,36 +9592,36 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-app-update@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-app-update@npm:3.0.106" +"@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/b69981dee6020be29f2f104dad17fa435161ab072fd602d3fe5d2870ca3154afb846daf7b161c7deeec703d6aed1526d3e60a1250864f50d759264bb4d14e9a4 + checksum: 10/d3e624ddcc831f6a630dd9d10b5cbf0d05f953b1cc285408d218a2c4b0a9457e47321bc94ba6a2ca5a262406e14593152305df4fe6c4fa47d4009b5b511f0591 languageName: node linkType: hard -"@onekeyfe/react-native-auto-size-input@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-auto-size-input@npm:3.0.106" +"@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/cba112c8f47b56cfbdec33c5e6f9a9f87b5ac5980c2ce6a553a34988734e27da4fad6a1d65ef2bd71588310c37a07df6b96059648a72ab424f4b8f081148d73c + checksum: 10/c030ab5bd431088baa7fa92f1e6f7e7e956bc16406e11b631b7d9228838faba04cd4cce9b8179fc1f226cc1a9b17ae35ca26bdd62b6ff5f583b8d29c9160c21d languageName: node linkType: hard -"@onekeyfe/react-native-background-thread@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-background-thread@npm:3.0.106" +"@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/7d36703b5102210177fbd99287d3ec9ff029d0d3ffeb3aac4c1708df7765628a9fd3874784f84c2bd5f143e3d09eaf6a105f06ab391c79cdb18b2ab6ea9e345a + checksum: 10/9b4a26603640363b88840435e3a3fb2ea51f30821994bb4140a75e373a83fc5b001e794af55b3593600c5cc433eec66103778719e821d4f23f51fdd4a3ff9e83 languageName: node linkType: hard @@ -9635,250 +9635,250 @@ __metadata: languageName: node linkType: hard -"@onekeyfe/react-native-bundle-crypto@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-bundle-crypto@npm:3.0.106" +"@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/ae255859bec8d097b52d96054094fca0da7dc09df2c6fa8afe16822be75cc66315e91c2fd4b6a5f297e9381d16da19c28f08e63a4032e7d4cf6d060aa114f2f5 + checksum: 10/68222fd7d1747eed836ce3417c707f94b5a91e8a0e65bfcd8b9688166dba110794b345fd10382dab7a37d57d581ec9f950916cf2fa06126dea76ecfa43219e8f languageName: node linkType: hard -"@onekeyfe/react-native-bundle-update@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-bundle-update@npm:3.0.106" +"@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/a738ac6000c0b75f1de66216397c085fe7975712745849d43ad96e13b3b6fdabfc5e5e54eb33a26616dd38d411d5448a83d0c897638339492990b04aab8b6b0c + checksum: 10/8b095b6701157e17500e52f3f452d81589740a23ca4d23cc97f13685700dbd24af044a878ea5a0d662e760160c73d7ff5214fd987e22235a13f85a6b3a50648d languageName: node linkType: hard -"@onekeyfe/react-native-chart-webview@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-chart-webview@npm:3.0.106" +"@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/5b6ebd18bbe77b0e2333bc9d883aea52c3f5c8b434b24460548330c69e3a63ba4b74d88d1ad5ff4a42b058451d8c762f311383784778ff5aa7e4a160628c1ea8 + checksum: 10/5d5f879ef864e8f5183ee028035da2df8e61e97f80dcbe09bb752355d8158430276a0f48e9b98cb98d5134f5666e654987ee9da70173e869421a166bc51e2f34 languageName: node linkType: hard -"@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-check-biometric-auth-changed@npm:3.0.106" +"@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/f70017d514751b98f8543c867a2ffd4c7d798f299fcda72f8a44dbce66fb1162ed167b539a38443a1c6948bd2da019ba9e8d9e98c7223c7ffbae8528539bd3b4 + checksum: 10/70fbd5e8bfda3d7cfecfcdcca6bab9db69684b2ac7ce9301d7ba49cb69540236f576303c53148fc457fff338e662795bcc25953bb48acdc6caa2be7812c61966 languageName: node linkType: hard -"@onekeyfe/react-native-cloud-kit-module@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-cloud-kit-module@npm:3.0.106" +"@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/da3ef1173cc83bdb88174c66cc735e5a0155fb71a8c489a9c227aee46b48e409686e57c1af160884dcfa889f2d6b9d033226a458eac2ea7e8b7e566dd19a263d + checksum: 10/f64aae932a725923b42d5aea86f30cee58aff9568171f0e13f16f37070ae1aa3f51916515513e900d29384cac0c682cbcea2b7aeb92c28bb939e004b6a32d979 languageName: node linkType: hard -"@onekeyfe/react-native-device-utils@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-device-utils@npm:3.0.106" +"@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/35e066527b3bb00de9a5397e328b5207b299baae7414b9a7eb7a50e6e9861139623623898cd5ca992331b85f9f209955b585af7c1f3d7b8578cfdf4daf26724c + checksum: 10/a89cfb396d6575ee63f250ca675228dc803c2263bc446100dd6317460971cdf56c4a251b14177ddc0e71dea4194ffa0268df4f04273246693503e91ad6a0a77d languageName: node linkType: hard -"@onekeyfe/react-native-image@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-image@npm:3.0.106" +"@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.106 + "@onekeyfe/react-native-skeleton": 3.0.107 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/fb6703a04cca215af72f5a4b53c2f418c0e87b318ef737f202c301a3108adb58e54f1d6b7584bf1ecfb8912f7fc0860b04c209a436eb4fa931b11d56fc52097c + checksum: 10/e72d7538c0c1268d209425e492dfe0268a89f011dfca085c8c7cfc8b2614caf1d0a84382f2c4342f9ba5bd291774b86ec72fed18bb34e933148169fabe3afcd8 languageName: node linkType: hard -"@onekeyfe/react-native-keychain-module@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-keychain-module@npm:3.0.106" +"@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/22b19e9594f9b3cab9b9fc0c3095d1e258bf1d949061e527a318251db595390e48b040a1471ac4400385ffe802b0740875127daef82127e4f105bbbdee465c7b + checksum: 10/418c0f1f3cf67f6c24866d1693be46879456c04c48c6261871c2cfdf74241e74237b2acd29bdad99419d525edd0c412dabf099c89c5b54fc09e8cd661acbaf88 languageName: node linkType: hard -"@onekeyfe/react-native-lite-card@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-lite-card@npm:3.0.106" +"@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/274417787a8e531d4ef6fc3950b8ccfcf1f29fe8182a92fc91d6d58a3ed86e1c0fbe3af246ce236ea20ca42a2dec829a54f9b448352d5984c1eacf37919cfdfa + checksum: 10/61ca76e4d14403855be3bdb422bd370e17aa8190d2fe3fed0f955416d6fe01635915ae8f9514124aae51d1b0d4d5ada5efcad5210dcfb271a7b65a9d173735f7 languageName: node linkType: hard -"@onekeyfe/react-native-native-list@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-native-list@npm:3.0.106" +"@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.106 + "@onekeyfe/react-native-image": 3.0.107 react: "*" react-native: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/628ef149f2b5fb35df434a8c4ab70b393d719df6bc421f19de3e1e9997b42a82c6976c15b32ef69b0b446774e7c86932ebe8ffe405cfb4a0b8ab333a7e2092d2 + checksum: 10/5b97af4c96aee8dd43ef73a8d37812ac527e53faf583447200bd2bb8e81ae2bd8fdf3f9ff8b574db0197b06f72aa1ecf45ded1164eafcdd3b40e62edf6de3a71 languageName: node linkType: hard -"@onekeyfe/react-native-native-logger@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-native-logger@npm:3.0.106" +"@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: "*" react-native-nitro-modules: 0.37.0 - checksum: 10/2f622c0e314239fb65b132788045184f74dbb376c8589e9de5a62392b2d7fe676600d2d683e4fa0d74cfbad44f701000b90735f1d9c77ff8c6e0d20f3b56d08d + checksum: 10/1a9c98e8f4d84fa7e91aa7ff9449f86fc0110ab76b9e62da744a11564400c463e8d06230b05cb558b13047df0e718b2b3f4dd6a77ab5be19d1813a02739671ba languageName: node linkType: hard -"@onekeyfe/react-native-network-throttle@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-network-throttle@npm:3.0.106" +"@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/57578cf2d4112c7afa15278cd0bf8a5e5523285e954c3e797268eb237318ea00722610763e2fad336c8f9e150d3c20b69807f280533f09dbe0e6a291c0453efa + checksum: 10/1ccb15f3cbcae6a48196ddfc89e3d978e45582b6d8f680d3fbd5af1101349bf5d462e4c65d826262bca4922cffd7d3c2453f4f54da7b43a7a1fd1c684f5d8a2d languageName: node linkType: hard -"@onekeyfe/react-native-perf-memory@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-perf-memory@npm:3.0.106" +"@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/5979ffb82cf42fcbfc6fd0c34acc4c13c8f47ff1b488c746d005a2493f2f3f37ef6ca1023ec928bea30fdcbcdf1267a9b1a35f70893b2ed8ea3a863e77af4a10 + checksum: 10/a90b0857caedf3a2d14d4fccd322ccfeba84d2d5d0efec4b93a4eb5998fff937bcf4c3649e2d3453214103ceeed34dc524501f0ca60431b5bccc655d5407fc6d languageName: node linkType: hard -"@onekeyfe/react-native-perf-stats@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-perf-stats@npm:3.0.106" +"@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/7da7aa70f2c3e4ed8ca4e01bcdd8aefdf1c4c5c33bfca0f8134d70d478e594c188a16a7cde4706c268b90f5b0e0a6742c5c90d4b2c8d38207100c7ed75d41ffc + checksum: 10/04386db893acf22c75d3b06bb3f48c289949c5723c2247a26133edd8b7e5edb7129c4dd20dffba01ea16c02b4b37156b25d744f9be0e8954caeb304aae0b2471 languageName: node linkType: hard -"@onekeyfe/react-native-perp-depth-bar@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-perp-depth-bar@npm:3.0.106" +"@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/707e4b50898aa324835e2d9b57b97424be71b78568d38289859d798a2849c4e270ef9561c6603ba1da9d714fc0b33efb8adc6d577efd9ea1d9be31438dbcfe17 + checksum: 10/fcb8badd9edf16eca513ac4303135aa138278b1324d294bbb4d3e4bc3e021ada04669066a1540c60b33e3df5fe2d912178c55ce2b02750892f2d88b9d359cdd8 languageName: node linkType: hard -"@onekeyfe/react-native-range-downloader@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-range-downloader@npm:3.0.106" +"@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/db668d7de6ae006d56c13709caa188749abf0e896df62a2a7bbdba8006fa7577d424db8919c516b510479bbe998de4b11ce336dabdd1529856d9793e38bc4995 + checksum: 10/47da39cfc611f830282a1ae9e0ef8c200be66610393d63ca7a258e0fca49353c9e52fdb4a3f7ce10b712baade52189abf64e92f5df11eaafa7968a1894b79790 languageName: node linkType: hard -"@onekeyfe/react-native-scroll-guard@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-scroll-guard@npm:3.0.106" +"@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/018031869815c664abc1b99f3e165f22d81b4e778adb2d0988c0accf33ba3558a688455b968795f373f410baadf994004e5764405380233e57ee114817051e38 + checksum: 10/eb4a2b5285813070cfb159c5a0aac50c6b8a6391d34ea28948896673b12f8c3506bdfb23aa812a61cebf9f40ed2d09b95d20a1dd0709c040d849659257e71b43 languageName: node linkType: hard -"@onekeyfe/react-native-segment-slider@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-segment-slider@npm:3.0.106" +"@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/15a5821e2cde5294de955d59980bbc45068f137194a013561e92418d0696a2e0f7a9a4d7e38c643d71e6c2f49ba9d85e139391216530936cc568fa787f55c33a + checksum: 10/3264b8cf22efd22201d6bd09bd3b74ca1071997fb69b77cadce56f2d91455fefae25985e1cb99cd8f20b9cf08b0916ed9a6d6b9ab6d648cbb393a966ea169438 languageName: node linkType: hard -"@onekeyfe/react-native-skeleton@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-skeleton@npm:3.0.106" +"@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/5986f90336d0a28d9c11e1a2dd83a722a5400e1bbce969b95fa3f1a490befb08001f74120bc6383168ba1963f351b3b26d1f29c3c75be8edd5eea9eb2abc80f2 + checksum: 10/503f41f28680c01ea400e23c0d752232a0cc8e9e9f4a166dfad30275ee118e1884df1d3d70dfa3bbe9055711a85938759da270fe71e8b1585227d8758400b098 languageName: node linkType: hard -"@onekeyfe/react-native-sni-connect@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-sni-connect@npm:3.0.106" +"@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/2f250909cb631525f477633419231a1630497b3f1f5c84cbd06580865c9fd9e159c599048dc69615395f0473062d1eebfdfd1136b63f4c483aeb3c89f1b9393d + checksum: 10/9c45df3f9cadbd0a1a0fd12bef4ccfdc17937452f4e52bf08af793972f306ba57b8a6eb123d442c1b3d71f52f45067d59c90fa1825e97696f319bc430c9c1804 languageName: node linkType: hard -"@onekeyfe/react-native-splash-screen@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-splash-screen@npm:3.0.106" +"@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/e9485887d3d625949c0514a62ac5ae8477768f4dfd13b71fd6c786c089d74f0e70365d038c23619f0309357af2677b121f76eea0f9c16f1161f3231dbb9fcea4 + checksum: 10/7e433b30bf3112bf2b787ddf0bc039f69fb83b8b58083e7389c58a32bdcd2ab89797bd035a5ab570d80d2dec0c6144672285e2c956050c9c28a5769fb92393a0 languageName: node linkType: hard -"@onekeyfe/react-native-split-bundle-loader@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-split-bundle-loader@npm:3.0.106" +"@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/6e1f0b4cae733514274e76b77563bf46b28f3a07c44e9910b9653c3cc2e04f309b844b913b124786ddfd4ef2bc15b74bf712ae6c752965096db3160204234d5a + checksum: 10/9be9a49ce7febd6f8f3f1aee1f3c3e24b4534b9ef33ea7c79ee7d0d9fedd79ee6740cfeea88397612b4e395c0ffb753dd307cd5ccb37f9683e4931a11b7f99bf languageName: node linkType: hard -"@onekeyfe/react-native-tab-view@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-tab-view@npm:3.0.106" +"@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" @@ -9886,17 +9886,17 @@ __metadata: peerDependencies: react: "*" react-native: "*" - checksum: 10/31277a7f38e453c93615c1f11f4c6d37fb9fbd279dba3bd7f902d8aa48a22acbd702c706730409bf59c80580d5bff4ed70d4679263a2f90b6ab256cad1e7058c + checksum: 10/8edead0b331f833b1e481007ce713d65efed044b6aeeb00a330ea0bd4951daaca7898f97b5ca70efa5bf399bd3bf049a7b560d9f781a7f57f387c5341472d124 languageName: node linkType: hard -"@onekeyfe/react-native-text-input@npm:3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-text-input@npm:3.0.106" +"@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/45a223297682223b641d78a8e8be4dcff24f4d3f95f49ce4865ab531b8ca1e0a149b5ac5ed501ea6055fd7b6c3e96c4734fe4987df8fc56c3f51e81b3fde1c21 + checksum: 10/f668b6bd7852afa6b29045e21664d6916e8a7248789bf6ef06d1da104c42bad33b0c147829547958953471978c11a0574e8955defdb481d6392588ace2c85ca3 languageName: node linkType: hard @@ -10116,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.106" + 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" @@ -10166,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.106" - "@onekeyfe/react-native-segment-slider": "npm:3.0.106" - "@onekeyfe/react-native-tab-view": "npm:3.0.106" + "@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" @@ -10314,7 +10314,7 @@ __metadata: version: 0.0.0-use.local resolution: "@onekeyhq/kit@workspace:packages/kit" dependencies: - "@onekeyfe/react-native-native-list": "npm:3.0.106" + "@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" @@ -10348,39 +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.106" - "@onekeyfe/react-native-auto-size-input": "npm:3.0.106" - "@onekeyfe/react-native-background-thread": "npm:3.0.106" + "@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.106" - "@onekeyfe/react-native-bundle-update": "npm:3.0.106" - "@onekeyfe/react-native-chart-webview": "npm:3.0.106" - "@onekeyfe/react-native-check-biometric-auth-changed": "npm:3.0.106" - "@onekeyfe/react-native-cloud-kit-module": "npm:3.0.106" - "@onekeyfe/react-native-device-utils": "npm:3.0.106" - "@onekeyfe/react-native-image": "npm:3.0.106" - "@onekeyfe/react-native-keychain-module": "npm:3.0.106" - "@onekeyfe/react-native-lite-card": "npm:3.0.106" - "@onekeyfe/react-native-native-list": "npm:3.0.106" - "@onekeyfe/react-native-native-logger": "npm:3.0.106" - "@onekeyfe/react-native-network-throttle": "npm:3.0.106" - "@onekeyfe/react-native-perf-memory": "npm:3.0.106" - "@onekeyfe/react-native-perf-stats": "npm:3.0.106" - "@onekeyfe/react-native-perp-depth-bar": "npm:3.0.106" - "@onekeyfe/react-native-range-downloader": "npm:3.0.106" - "@onekeyfe/react-native-scroll-guard": "npm:3.0.106" - "@onekeyfe/react-native-segment-slider": "npm:3.0.106" - "@onekeyfe/react-native-skeleton": "npm:3.0.106" - "@onekeyfe/react-native-sni-connect": "npm:3.0.106" - "@onekeyfe/react-native-splash-screen": "npm:3.0.106" - "@onekeyfe/react-native-split-bundle-loader": "npm:3.0.106" - "@onekeyfe/react-native-tab-view": "npm:3.0.106" - "@onekeyfe/react-native-text-input": "npm:3.0.106" + "@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.106" + "@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" @@ -10441,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.106" + 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.106" + 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.106" - react-native-fast-pbkdf2: "npm:@onekeyfe/react-native-pbkdf2@3.0.106" + 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.106" + 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.106" + 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.106" + 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.106" + 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" @@ -10477,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.106" + 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.106" + 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" @@ -13556,15 +13556,15 @@ __metadata: languageName: node linkType: hard -"@react-native-async-storage/async-storage@npm:@onekeyfe/react-native-async-storage@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-async-storage@npm:3.0.106" +"@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/04c5b154d9c9067713a511babc2ff71a0503de863cff9198793ba621bc4003874788a579ee6ebd233b641192e09a0615ee73228cacdd69232829881d04ea4579 + checksum: 10/26f47ca8c08ab2b93cf563d98e9f38f72ae8cc631c653e8ee7ff264d97eabfd6d9e77f251ba70564ed9b7a701a458ececcc0b6644e418ef2a7227e3f806e8c69 languageName: node linkType: hard @@ -42558,13 +42558,13 @@ __metadata: languageName: node linkType: hard -"react-native-aes-crypto@npm:@onekeyfe/react-native-aes-crypto@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-aes-crypto@npm:3.0.106" +"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/c7694392949222b50e7787dc911b1808adc459f0195f3a9fbf5a6e827126ba965ea2fee02ba1e6dfa3f34a1efd50f1e7d3e9b739930ca95f6406019eb556474e + checksum: 10/97a925a39c80aa7265ca90834504f5660d20ae08f378802fe6bd7c8670fd97020df0f24eefdce531b291cfd453ec4762a6b2d3fa3d9370f0077de1e8a9d9c69e languageName: node linkType: hard @@ -42634,13 +42634,13 @@ __metadata: languageName: node linkType: hard -"react-native-cloud-fs@npm:@onekeyfe/react-native-cloud-fs@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-cloud-fs@npm:3.0.106" +"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/8baa9a1514fd465fc2ff10eb5f9549e070193cddba9f1bb1fe45c5ef21533173a039879501b5337941791fda40ba88926459623f25829aaf9fa677f27b8f043e + checksum: 10/435a67698fde4f27ef77036198712fd75e058637849e5a70c5014cb00fcddb2b69f77b81360affb2a1422cebc9b4f1c1f5ed8d99be52637da4cdd7c71e1df10e languageName: node linkType: hard @@ -42714,13 +42714,13 @@ __metadata: languageName: node linkType: hard -"react-native-dns-lookup@npm:@onekeyfe/react-native-dns-lookup@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-dns-lookup@npm:3.0.106" +"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/420ce3a9880e30152110aff065fc8bc21b6876d5577d4e1ae72f59093051cc01119820de5d3a5aeb2eef5dd6ce098b4b159a0d04b358a1c74c95cee6c106f003 + checksum: 10/5e5ba1c096b726904ae81d80ea8c7e9349dda9310a43511ea6a589df1afdb6d74c2fd348b66184bf3d668a31e125e2c8bffe1dfa2420e21fbc0ec66e0dea354b languageName: node linkType: hard @@ -42737,13 +42737,13 @@ __metadata: languageName: node linkType: hard -"react-native-fast-pbkdf2@npm:@onekeyfe/react-native-pbkdf2@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-pbkdf2@npm:3.0.106" +"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/0e342c8b4e3a9e4d8686dc0330d8647d32b2050757f09ebd2ddd699e9aa7cbbf05ead4d8ca5555c350b4eb7a2f5b56c54c7977cdb3dcebd24133af7a4d38dafa + checksum: 10/176f5655c0fa78705710a4598a52dcf8ac24933c643501c0e07d3ca10b52cb4f8cad07628927eb44fc115be36561bf4fbf87a43949b175a5b11c3a3f89b5b6f5 languageName: node linkType: hard @@ -42784,14 +42784,14 @@ __metadata: languageName: node linkType: hard -"react-native-get-random-values@npm:@onekeyfe/react-native-get-random-values@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-get-random-values@npm:3.0.106" +"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/041d1e8d339f475a783a5b84e6dda5b38c002050be8259604de964cf0615e24aff3f4ea182ee3504ad239d216c82f8bc6e66ae927430935cf68f455649424909 + checksum: 10/8f09092f66f9487cd4c05a05dede65939b17307093c074ee0bb99eb0798a105c48c89796de757d2c4360404ea2068cb29b53c11d77e84a11accdf49360a2c0bb languageName: node linkType: hard @@ -42952,13 +42952,13 @@ __metadata: languageName: node linkType: hard -"react-native-network-info@npm:@onekeyfe/react-native-network-info@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-network-info@npm:3.0.106" +"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/117826d2336e16fd546911e1e2cfe9f4a413ce230d4bbafc9e60ea0e2a8f3d5ce47a1019194af7a71b740ac48b76d3ba6bff4fe05ac2584a47b168950b37be04 + checksum: 10/648084e668b61a312a05b594e48c516c78faccf05162905a5a209ebe4135e619cd3c5fc9fa51687550cfe12c8416c035da0aa89c2de64462869b929ad02f84d2 languageName: node linkType: hard @@ -42982,13 +42982,13 @@ __metadata: languageName: node linkType: hard -"react-native-pager-view@npm:@onekeyfe/react-native-pager-view@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-pager-view@npm:3.0.106" +"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/c55673ab15b1e65aa8524c61427d26bab2987311ae51627e68d9a68727a93bcc70a393402ff055502caf55b864104748938c348cdcb66aeeb879be62561b6932 + checksum: 10/f46c101931277d0f6c9c91003e6a921b51730972a35033aca752fbf93729aae29bd93ffed484501bc26eb55ec60c45a8d3b190a66a161ac660cea7b3eb14a50c languageName: node linkType: hard @@ -43017,13 +43017,13 @@ __metadata: languageName: node linkType: hard -"react-native-ping@npm:@onekeyfe/react-native-ping@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-ping@npm:3.0.106" +"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/440c8520708261a9a0d417187af2948303c6406be408724703ae91a3d9af961af410cbf3cf1d0ae86f9da88b577c8c1a6cdcbd515a1e141ee632d0b7730b743a + checksum: 10/86c558fbc4fc3701f3f6d5c77a25be7755d11e16ea4c0a09ddf3209cd70b91f3c104b1485e25cb6ce067fb119b3acac75a4ed746ed23c6f38786653cd4a457dc languageName: node linkType: hard @@ -43207,13 +43207,13 @@ __metadata: languageName: node linkType: hard -"react-native-tcp-socket@npm:@onekeyfe/react-native-tcp-socket@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-tcp-socket@npm:3.0.106" +"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/840811f4ba91de813cf7209594b5e3c23d923dd95252274ed33b030966c327d299bbf31a921a9084d78925303fa87412969436f6897bbdc6acb2c966fe09da80 + checksum: 10/dfe5f828dea6b3c5bfe782c8184ac245c4a7952e833414851e208f5afe5a72c0d25d6c7256fece57b23f7c0628dc0ae5d4d63cd1aa3a7e7ec6fef04ec93647e5 languageName: node linkType: hard @@ -43349,14 +43349,14 @@ __metadata: languageName: node linkType: hard -"react-native-zip-archive@npm:@onekeyfe/react-native-zip-archive@3.0.106": - version: 3.0.106 - resolution: "@onekeyfe/react-native-zip-archive@npm:3.0.106" +"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/7231f3c1d0f74737f0b56b5108da62064593a05341ffe68501d8276f101c7ab74cca70715a440109f0af8bb9e9cf04f9fdeac1f6c953501d8ea9b07b196a9f6e + checksum: 10/ce81cc5d135f243370b01ec2a1e84e3399acbd600231dd815ca54a0b754d612821e6c3451606ce71d8b961c39b8112ac46891b73a8e93d20927253bb74794d11 languageName: node linkType: hard From 36702c6a2bf06d8c38ee908e23098c1712453cff Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 09:39:04 +0800 Subject: [PATCH 16/18] fix: remove selector entry loading on all platforms --- .../pages/AccountSelectorStack/AccountSelectorStackV2.tsx | 2 +- packages/kit/src/views/AccountManagerStacks/router/index.tsx | 3 +++ packages/kit/src/views/ChainSelector/router/index.ts | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx index f72e8c289905..bfe10c78c64e 100644 --- a/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx +++ b/packages/kit/src/views/AccountManagerStacks/pages/AccountSelectorStack/AccountSelectorStackV2.tsx @@ -26,7 +26,7 @@ export function AccountSelectorStackV2({ }); return ( - + {shouldHideWalletList ? null : ( diff --git a/packages/kit/src/views/AccountManagerStacks/router/index.tsx b/packages/kit/src/views/AccountManagerStacks/router/index.tsx index 24e47a4c7e0e..6ba1067e20c6 100644 --- a/packages/kit/src/views/AccountManagerStacks/router/index.tsx +++ b/packages/kit/src/views/AccountManagerStacks/router/index.tsx @@ -5,6 +5,9 @@ import { EAccountManagerStacksRoutes } from '@onekeyhq/shared/src/routes/account const AccountSelectorStackPage = LazyLoadPage( () => import('../pages/AccountSelectorStack/AccountSelectorStackV2'), + undefined, + undefined, + false, ); const ExportPrivateKeys = LazyLoadPage( diff --git a/packages/kit/src/views/ChainSelector/router/index.ts b/packages/kit/src/views/ChainSelector/router/index.ts index 10037cc7ed3f..256a89cd9aeb 100644 --- a/packages/kit/src/views/ChainSelector/router/index.ts +++ b/packages/kit/src/views/ChainSelector/router/index.ts @@ -28,6 +28,9 @@ const ChainListSearch = LazyLoadPage(() => import('../pages/ChainListSearch')); const UnifiedNetworkSelector = LazyLoadPage( () => import('../components/UnifiedNetworkSelectorV2'), + undefined, + undefined, + false, ); export const ChainSelectorRouter: IModalFlowNavigatorConfig< From f363ca776357c06c74891740971286d2532d4ca5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 12:28:47 +0800 Subject: [PATCH 17/18] feat: add real wallet stress test presets --- packages/kit-bg/src/services/ServiceDemo.ts | 28 +++--- .../DevLargeWalletDataCreation/index.tsx | 98 +++++++++++-------- .../pages/Tab/DevSettingsSection/index.tsx | 4 +- 3 files changed, 71 insertions(+), 59 deletions(-) diff --git a/packages/kit-bg/src/services/ServiceDemo.ts b/packages/kit-bg/src/services/ServiceDemo.ts index 0f4899cbc979..e48f2737462f 100644 --- a/packages/kit-bg/src/services/ServiceDemo.ts +++ b/packages/kit-bg/src/services/ServiceDemo.ts @@ -58,8 +58,7 @@ import type { IDBExternalAccount } from '../dbs/local/types'; import type { ITransferInfo } from '../vaults/types'; import type { AllNetworkAddressParams } from '@onekeyfe/hd-core'; -const LARGE_WALLET_DATA_WALLET_COUNT = 1000; -const LARGE_WALLET_DATA_ACCOUNT_COUNT = 1000; +const LARGE_WALLET_DATA_ACCOUNT_COUNT = 100; const LARGE_WALLET_DATA_ACCOUNT_BATCH_SIZE = 100; @backgroundClass() @@ -145,11 +144,18 @@ class ServiceDemo extends ServiceBase { } @backgroundMethod() - async createLargeWalletsAndAccounts() { + 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', @@ -163,14 +169,13 @@ class ServiceDemo extends ServiceBase { let currentWalletIndex = 0; let accountsCreatedInCurrentWallet = 0; let password = ''; - const accountsTotal = - LARGE_WALLET_DATA_WALLET_COUNT * LARGE_WALLET_DATA_ACCOUNT_COUNT; + const accountsTotal = walletCount * LARGE_WALLET_DATA_ACCOUNT_COUNT; const emitProgress = () => { appEventBus.emit(EAppEventBusNames.DevLargeWalletDataCreationProgress, { isRunning: this.isCreatingLargeWalletData, walletIndex: currentWalletIndex, walletsCreated, - walletsTotal: LARGE_WALLET_DATA_WALLET_COUNT, + walletsTotal: walletCount, accountsCreatedInWallet: accountsCreatedInCurrentWallet, accountsPerWallet: LARGE_WALLET_DATA_ACCOUNT_COUNT, accountsCreated, @@ -181,11 +186,7 @@ class ServiceDemo extends ServiceBase { try { emitProgress(); - for ( - let walletIndex = 0; - walletIndex < LARGE_WALLET_DATA_WALLET_COUNT; - walletIndex += 1 - ) { + for (let walletIndex = 0; walletIndex < walletCount; walletIndex += 1) { currentWalletIndex = walletIndex + 1; accountsCreatedInCurrentWallet = 0; let createdWalletId = ''; @@ -282,11 +283,10 @@ class ServiceDemo extends ServiceBase { return { accountsCreated, - accountsRequested: - LARGE_WALLET_DATA_WALLET_COUNT * LARGE_WALLET_DATA_ACCOUNT_COUNT, + accountsRequested: accountsTotal, durationMs: Date.now() - startedAt, walletsCreated, - walletsRequested: LARGE_WALLET_DATA_WALLET_COUNT, + walletsRequested: walletCount, }; } diff --git a/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx b/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx index c1d4896677ef..b750c7d12be8 100644 --- a/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx +++ b/packages/kit/src/views/Setting/pages/DevLargeWalletDataCreation/index.tsx @@ -20,11 +20,11 @@ const INITIAL_PROGRESS: ILargeWalletDataCreationProgress = { isRunning: false, walletIndex: 0, walletsCreated: 0, - walletsTotal: 1000, + walletsTotal: 10, accountsCreatedInWallet: 0, - accountsPerWallet: 1000, + accountsPerWallet: 100, accountsCreated: 0, - accountsTotal: 1_000_000, + accountsTotal: 1000, }; function getErrorMessage(error: unknown) { @@ -60,31 +60,40 @@ export default function DevLargeWalletDataCreation() { }; }, []); - const handleCreate = useCallback(async () => { - if (isRunning) { - return; - } + const handleCreate = useCallback( + async (walletCount: 10 | 100) => { + if (isRunning) { + return; + } - setProgress(INITIAL_PROGRESS); - setIsRunning(true); - try { - const result = - await backgroundApiProxy.serviceDemo.createLargeWalletsAndAccounts(); - 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`, + setProgress({ + ...INITIAL_PROGRESS, + walletsTotal: walletCount, + accountsTotal: walletCount * INITIAL_PROGRESS.accountsPerWallet, }); - } catch (error) { - Toast.error({ - title: 'Failed to create real HD wallet data', - message: getErrorMessage(error), - }); - } finally { - setIsRunning(false); - } - }, [isRunning]); + 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, @@ -97,12 +106,10 @@ export default function DevLargeWalletDataCreation() { - - Create 1,000 Real HD Wallets × 1,000 Accounts - + Create Real HD Wallets - Creates 1,000 independent HD wallets with encrypted recovery - phrases and 1,000 indexed accounts in each wallet. The 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. @@ -140,18 +147,23 @@ export default function DevLargeWalletDataCreation() { - + + {([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 983c539cce67..adafb2954105 100644 --- a/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx +++ b/packages/kit/src/views/Setting/pages/Tab/DevSettingsSection/index.tsx @@ -2489,8 +2489,8 @@ const BaseDevSettingsSection = () => { { From d4636b397fc67018b2a50d7495702ff9822edffd Mon Sep 17 00:00:00 2001 From: huhuanming Date: Tue, 8 Sep 2026 12:42:32 +0800 Subject: [PATCH 18/18] fix: address selector review feedback --- .../ActionList/imperativeShowUtils.test.ts | 31 +++ .../actions/ActionList/imperativeShowUtils.ts | 13 +- .../src/actions/ActionList/index.native.tsx | 9 +- .../src/actions/ActionList/index.tsx | 9 +- .../UnifiedNetworkSelectorV2.test.tsx | 252 ++++++++++++++++++ .../UnifiedNetworkSelectorV2.tsx | 42 ++- 6 files changed, 337 insertions(+), 19 deletions(-) create mode 100644 packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.test.tsx diff --git a/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts b/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts index 43cebd39ccd4..f095f162e664 100644 --- a/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts +++ b/packages/components/src/actions/ActionList/imperativeShowUtils.test.ts @@ -91,6 +91,37 @@ describe('imperative ActionList geometry', () => { }); 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() }; diff --git a/packages/components/src/actions/ActionList/imperativeShowUtils.ts b/packages/components/src/actions/ActionList/imperativeShowUtils.ts index 8a401e05b8e6..783d9d537032 100644 --- a/packages/components/src/actions/ActionList/imperativeShowUtils.ts +++ b/packages/components/src/actions/ActionList/imperativeShowUtils.ts @@ -39,6 +39,13 @@ export function createImperativeActionListLifecycle({ 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) { @@ -54,12 +61,16 @@ export function createImperativeActionListLifecycle({ if (onClose) { schedule(onClose, 0); } - schedule(destroy, CLOSE_ANIMATION_DURATION); + schedule(destroyOnce, CLOSE_ANIMATION_DURATION); }; return { handleOpenChange, close: () => handleOpenChange(false), + closeImmediately: () => { + handleOpenChange(false); + destroyOnce(); + }, }; } diff --git a/packages/components/src/actions/ActionList/index.native.tsx b/packages/components/src/actions/ActionList/index.native.tsx index bbb190bb453f..e04710a6eaf5 100644 --- a/packages/components/src/actions/ActionList/index.native.tsx +++ b/packages/components/src/actions/ActionList/index.native.tsx @@ -503,7 +503,7 @@ const showActionList = ( pageContextValue?: ReturnType; } | undefined, -): IActionListShowHandle => { +): IActionListShowHandle & { closeImmediately: () => void } => { const { modalNavigatorContext, pageContextValue } = contexts || {}; const { onClose, triggerPosition, triggerRect, ...restProps } = props; dismissKeyboard(); @@ -610,7 +610,10 @@ const showActionList = ( , ); - return { close: lifecycle.close }; + return { + close: lifecycle.close, + closeImmediately: lifecycle.closeImmediately, + }; }; function ActionListFrame(props: IActionListProps) { const isProcessing = useRef(false); @@ -652,7 +655,7 @@ 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): IActionListShowHandle => { - imperativeActionList?.close(); + imperativeActionList?.closeImmediately(); imperativeActionList = showActionList(props, undefined); return imperativeActionList; }; diff --git a/packages/components/src/actions/ActionList/index.tsx b/packages/components/src/actions/ActionList/index.tsx index 899729dfb451..ed2760121168 100644 --- a/packages/components/src/actions/ActionList/index.tsx +++ b/packages/components/src/actions/ActionList/index.tsx @@ -493,7 +493,7 @@ const showActionList = ( pageContextValue?: ReturnType; } | undefined, -): IActionListShowHandle => { +): IActionListShowHandle & { closeImmediately: () => void } => { const { modalNavigatorContext, pageContextValue } = contexts || {}; const { onClose, triggerPosition, triggerRect, ...restProps } = props; dismissKeyboard(); @@ -600,7 +600,10 @@ const showActionList = ( , ); - return { close: lifecycle.close }; + return { + close: lifecycle.close, + closeImmediately: lifecycle.closeImmediately, + }; }; function ActionListFrame(props: IActionListProps) { const isProcessing = useRef(false); @@ -642,7 +645,7 @@ 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): IActionListShowHandle => { - imperativeActionList?.close(); + imperativeActionList?.closeImmediately(); imperativeActionList = showActionList(props, undefined); return imperativeActionList; }; 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 index 065f11f9fef6..f41bb60b4f5a 100644 --- a/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx +++ b/packages/kit/src/views/ChainSelector/components/UnifiedNetworkSelectorV2/UnifiedNetworkSelectorV2.tsx @@ -141,6 +141,19 @@ function UnifiedNetworkSelectorV2() { }); 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[] @@ -250,12 +263,12 @@ function UnifiedNetworkSelectorV2() { void preloadNetworkImagesV2(networks.allNetworks); }, [networks.allNetworks]); - // Keep networksState in sync with revalidation. The seed above handles - // first paint; this effect picks up later updates from the SWR fetch. + // Revalidation may refresh the initial selection, but must not overwrite + // edits made while cached networks are already interactive. useEffect(() => { - if (!networkMeta) return; + if (!networkMeta || hasLocalNetworkChanges.current) return; setNetworksState(networkMeta.allNetworksState); - }, [networkMeta]); + }, [accountId, networkMeta, walletId]); // Keep the summary and checkboxes in the same render as the selection change. const enabledNetworks = useMemo( @@ -443,10 +456,8 @@ function UnifiedNetworkSelectorV2() { onSuccess: async (network: IServerNetwork) => { if (activeTabRef.current === 'portfolio') { // Portfolio tab: enable the new network and persist to backend. - // Persist first to avoid race condition: refreshNetworkMeta - // (triggered by AddedCustomNetwork event) fetches backend state - // and overwrites local state. By persisting before the event, - // the backend already includes the enabled state. + // 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, @@ -455,7 +466,7 @@ function UnifiedNetworkSelectorV2() { ...networksState.disabledNetworks, [network.id]: false, }; - setNetworksState({ + updateNetworksSelection({ enabledNetworks: newEnabledNetworks, disabledNetworks: newDisabledNetworks, }); @@ -471,7 +482,14 @@ function UnifiedNetworkSelectorV2() { } }, }); - }, [navigation, handleNetworkPressItem, networksState, walletId, accountId]); + }, [ + navigation, + handleNetworkPressItem, + networksState, + updateNetworksSelection, + walletId, + accountId, + ]); const handleEditCustomNetwork = useCallback( async (network: IServerNetwork) => { @@ -707,7 +725,7 @@ function UnifiedNetworkSelectorV2() { accountId={accountId} indexedAccountId={indexedAccountId} networksState={networksState} - setNetworksState={setNetworksState} + setNetworksState={updateNetworksSelection} enabledNetworks={enabledNetworks} searchKey={searchKey} setSearchKey={setSearchKey} @@ -748,7 +766,7 @@ function UnifiedNetworkSelectorV2() { accountId={accountId} indexedAccountId={indexedAccountId} networksState={networksState} - setNetworksState={setNetworksState} + setNetworksState={updateNetworksSelection} enabledNetworks={enabledNetworks} searchKey={searchKey} setSearchKey={setSearchKey}