diff --git a/.github/workflows/update-release-changelog.yml b/.github/workflows/update-release-changelog.yml index b5bfa166975b..3a68e31a1ed1 100644 --- a/.github/workflows/update-release-changelog.yml +++ b/.github/workflows/update-release-changelog.yml @@ -1,5 +1,7 @@ -# On every push to a release branch (Version-v* or release/*), this workflow rebuilds the matching +# On every push to a release branch (release/x.y.z format), this workflow rebuilds the matching # changelog branch, re-runs the auto-changelog script, and either updates or recreates the changelog PR. +# Note: This workflow validates the branch name to ensure it matches the semantic versioning pattern +# (release/x.y.z) and skips execution for other branch names like release/x.y.z-Changelog. name: Update Release Changelog PR on: @@ -12,7 +14,34 @@ concurrency: cancel-in-progress: false jobs: + validate-branch: + name: Validate release branch format + runs-on: ubuntu-latest + outputs: + is-valid-release: ${{ steps.check.outputs.is-valid }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Check if branch matches release/x.y.z pattern + id: check + run: | + BRANCH_NAME="${{ github.ref_name }}" + echo "Checking branch: $BRANCH_NAME" + + # Validate branch matches release/x.y.z format (semantic versioning) + if [[ "$BRANCH_NAME" =~ ^release/[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION="${BRANCH_NAME#release/}" + echo "Valid release branch detected: $BRANCH_NAME (version: $VERSION)" + echo "is-valid=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + else + echo "Branch '$BRANCH_NAME' does not match release/x.y.z pattern. Skipping changelog update." + echo "is-valid=false" >> "$GITHUB_OUTPUT" + fi + refresh-changelog: + name: Update changelog + needs: validate-branch + if: needs.validate-branch.outputs.is-valid-release == 'true' permissions: contents: write pull-requests: write diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index 7fc582cd9c74..3fc74038b913 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -597,6 +597,7 @@ const ImportPrivateKeyView = () => ( const ImportSRPView = () => ( ( name={Routes.MULTI_SRP.IMPORT} component={ImportNewSecretRecoveryPhrase} /> + + ); diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx index a5ff8d3193d2..315fad9e843f 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.test.tsx @@ -88,7 +88,14 @@ jest.mock('../../../Rewards/utils', () => ({ (code) => `https://link.metamask.io/rewards?referral=${code}`, ), })); +jest.mock('../../../Rewards/hooks/useReferralDetails', () => ({ + useReferralDetails: jest.fn(), +})); jest.mock('@metamask/design-tokens', () => ({ + brandColor: { + black: '#000000', + white: '#FFFFFF', + }, darkTheme: { colors: { background: { diff --git a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx index f442eb268fd3..7dde0afb2d9b 100644 --- a/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx +++ b/app/components/UI/Perps/Views/PerpsHeroCardView/PerpsHeroCardView.tsx @@ -35,7 +35,6 @@ import RewardsReferralCodeTag from '../../../Rewards/components/RewardsReferralC import { formatPerpsFiat, parseCurrencyString, - PRICE_RANGES_MINIMAL_VIEW, PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import MetaMaskLogo from '../../../../../images/branding/metamask-name.png'; @@ -60,6 +59,7 @@ import { PerpsHeroCardViewSelectorsIDs, getPerpsHeroCardViewSelector, } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; +import { useReferralDetails } from '../../../Rewards/hooks/useReferralDetails'; // To add a new card, add the image to the array. const CARD_IMAGES: { image: ImageSourcePropType; id: number; name: string }[] = @@ -87,6 +87,9 @@ const PerpsHeroCardView: React.FC = () => { const rewardsReferralCode = useSelector(selectReferralCode); + // Fetch referral details to ensure code is available for display + useReferralDetails(); + const { track } = usePerpsEventTracking(); const { showToast, PerpsToastOptions } = usePerpsToasts(); @@ -232,7 +235,7 @@ const PerpsHeroCardView: React.FC = () => { variant={TextVariant.BodySMMedium} > {formatPerpsFiat(data.entryPrice, { - ranges: PRICE_RANGES_MINIMAL_VIEW, + ranges: PRICE_RANGES_UNIVERSAL, })} diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 83b03042643f..42f1dd396021 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -32,6 +32,7 @@ import { import { formatPerpsFiat, formatTransactionDate, + PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { styleSheet } from './PerpsPositionTransactionView.styles'; @@ -103,7 +104,9 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.action === 'Closed' ? strings('perps.transactions.position.close_price') : strings('perps.transactions.position.entry_price'), - value: formatPerpsFiat(transaction.fill.entryPrice), + value: formatPerpsFiat(transaction.fill.entryPrice, { + ranges: PRICE_RANGES_UNIVERSAL, + }), }, ].filter(Boolean); diff --git a/app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts b/app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts new file mode 100644 index 000000000000..85690f8ea063 --- /dev/null +++ b/app/components/UI/SrpInputGrid/SrpInputGrid.styles.ts @@ -0,0 +1,107 @@ +import { StyleSheet, Platform, TextStyle } from 'react-native'; +import { fontStyles, colors as commonColors } from '../../../styles/common'; +import { Colors } from '../../../util/theme/models'; + +/** + * Creates styles for the SrpInputGrid component + * @param colors - Theme colors object + * @returns StyleSheet object with all component styles + */ +export const createStyles = (colors: Colors) => + StyleSheet.create({ + seedPhraseRoot: { + flexDirection: 'column' as const, + gap: 4, + marginBottom: 24, + }, + seedPhraseContainer: { + paddingTop: 16, + backgroundColor: colors.background.section, + borderRadius: 10, + marginTop: 16, + minHeight: 264, + maxHeight: 'auto', + }, + seedPhraseInnerContainer: { + paddingHorizontal: Platform.select({ + ios: 16, + macos: 16, + default: 14, + }), + }, + seedPhraseInputContainer: { + flexDirection: 'row' as const, + flexWrap: 'wrap' as const, + width: '100%', + }, + seedPhraseDefaultInput: { + borderWidth: 0, + paddingHorizontal: 0, + display: 'flex' as const, + flex: 1, + backgroundColor: commonColors.transparent, + }, + seedPhraseInputItem: { + width: '31.33%', + marginRight: '3%', + marginBottom: 8, + flex: 0, + minWidth: 0, + }, + seedPhraseInputItemLast: { + marginRight: 0, + }, + textAreaInput: { + display: 'flex' as const, + height: 66, + color: colors.text.alternative, + backgroundColor: commonColors.transparent, + ...fontStyles.normal, + fontSize: 16, + lineHeight: 20, + paddingTop: Platform.OS === 'ios' ? 12 : 8, + paddingBottom: 12, + } satisfies TextStyle, + inputItem: { + flex: 1, + minWidth: 0, + maxWidth: '100%', + paddingRight: 8, + } satisfies TextStyle, + input: { + paddingVertical: Platform.select({ + ios: 4, + macos: 4, + default: 0, + }), + borderRadius: 8, + backgroundColor: colors.background.default, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'flex-start' as const, + height: 40, + fontSize: 16, + color: colors.text.default, + ...fontStyles.normal, + textAlignVertical: 'center' as const, + paddingLeft: 8, + overflow: 'hidden' as const, + } satisfies TextStyle, + inputIndex: { + marginRight: -4, + }, + pasteText: { + textAlign: 'right' as const, + paddingTop: 12, + paddingBottom: 16, + alignSelf: 'flex-end' as const, + } satisfies TextStyle, + + hiddenInput: { + opacity: 0, + position: 'absolute' as const, + height: 0, + top: 0, + left: 0, + } satisfies TextStyle, + }); diff --git a/app/components/UI/SrpInputGrid/SrpInputGrid.types.ts b/app/components/UI/SrpInputGrid/SrpInputGrid.types.ts new file mode 100644 index 000000000000..ef68f100372a --- /dev/null +++ b/app/components/UI/SrpInputGrid/SrpInputGrid.types.ts @@ -0,0 +1,46 @@ +/** + * Props for the SrpInputGrid component + * This component provides a reusable Secret Recovery Phrase input grid + * that handles both single textarea and multi-input modes + */ +export interface SrpInputGridProps { + /** + * Array of seed phrase words + */ + seedPhrase: string[]; + + /** + * Callback when seed phrase array changes + */ + onSeedPhraseChange: React.Dispatch>; + + /** + * Callback when error state changes + */ + onError?: (error: string) => void; + + /** + * External error message to display from parent + */ + externalError?: string; + + /** + * Prefix for test IDs (e.g., 'seed-phrase-input' or 'import-from-seed-input') + */ + testIdPrefix: string; + + /** + * Placeholder text for the first input (textarea mode) + */ + placeholderText: string; + + /** + * Unique ID for key generation (optional, will generate if not provided) + */ + uniqueId?: string; + + /** + * Whether the inputs should be disabled + */ + disabled?: boolean; +} diff --git a/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap b/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000..d21fa24bec8f --- /dev/null +++ b/app/components/UI/SrpInputGrid/__snapshots__/index.test.tsx.snap @@ -0,0 +1,1295 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SrpInputGrid renders with custom uniqueId 1`] = ` + + + + + + + + + + + + + + + + + + + Paste + + +`; + +exports[`SrpInputGrid renders with disabled state 1`] = ` + + + + + + + + + + + + + + + + + + + Paste + + +`; + +exports[`SrpInputGrid renders with empty seed phrase 1`] = ` + + + + + + + + + + + + + + + + + + + Paste + + +`; + +exports[`SrpInputGrid renders with multiple words 1`] = ` + + + + + + + + 1 + . + + + + + + + + + + 2 + . + + + + + + + + + + 3 + . + + + + + + + + + + + + + + + + Clear all + + +`; diff --git a/app/components/UI/SrpInputGrid/index.test.tsx b/app/components/UI/SrpInputGrid/index.test.tsx new file mode 100644 index 000000000000..7efc2ef6b712 --- /dev/null +++ b/app/components/UI/SrpInputGrid/index.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { ImportSRPIDs } from '../../../../e2e/selectors/MultiSRP/SRPImport.selectors'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import SrpInputGrid from './index'; + +// Mock Keyboard +jest.mock('react-native/Libraries/Components/Keyboard/Keyboard', () => ({ + dismiss: jest.fn(), +})); + +describe('SrpInputGrid', () => { + const mockOnSeedPhraseChange = jest.fn(); + const mockOnError = jest.fn(); + + const defaultProps = { + seedPhrase: [''], + onSeedPhraseChange: mockOnSeedPhraseChange, + onError: mockOnError, + testIdPrefix: ImportSRPIDs.SEED_PHRASE_INPUT_ID, + placeholderText: 'Enter your Secret Recovery Phrase', + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('renders with empty seed phrase', () => { + const { toJSON } = renderWithProvider(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with multiple words', () => { + const seedPhrase = ['word1', 'word2', 'word3']; + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with disabled state', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); + + it('renders with custom uniqueId', () => { + const { toJSON } = renderWithProvider( + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/UI/SrpInputGrid/index.tsx b/app/components/UI/SrpInputGrid/index.tsx new file mode 100644 index 000000000000..446ea443fbe8 --- /dev/null +++ b/app/components/UI/SrpInputGrid/index.tsx @@ -0,0 +1,463 @@ +import React, { + useCallback, + useMemo, + useRef, + useState, + useEffect, +} from 'react'; +import { View, Keyboard } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { v4 as uuidv4 } from 'uuid'; +import Text, { + TextVariant, + TextColor, +} from '../../../component-library/components/Texts/Text'; +import SrpInput from '../../Views/SrpInput'; +import { TextFieldSize } from '../../../component-library/components/Form/TextField'; +import { useAppTheme } from '../../../util/theme'; +import { createStyles } from './SrpInputGrid.styles'; +import { SrpInputGridProps } from './SrpInputGrid.types'; +import { strings } from '../../../../locales/i18n'; +import { + getTrimmedSeedPhraseLength, + isFirstInput as isFirstInputUtil, + getInputValue, + SRP_LENGTHS, + SPACE_CHAR, + checkValidSeedWord, +} from '../../../util/srp/srpInputUtils'; +import { isValidMnemonic } from '../../../util/validators'; +import { formatSeedPhraseToSingleLine } from '../../../util/string'; +import Logger from '../../../util/Logger'; + +export interface SrpInputGridRef { + handleSeedPhraseChange: (seedPhraseText: string) => void; +} +/** + * SrpInputGrid Component + * + * A reusable component for Secret Recovery Phrase input that supports: + * - Single textarea mode for initial input + * - Dynamic grid mode after paste/input + * - Paste/Clear functionality + * - Error validation and display + * - Keyboard navigation + * + */ +const SrpInputGrid = React.forwardRef( + ( + { + seedPhrase, + onSeedPhraseChange, + onError, + externalError = '', + testIdPrefix, + placeholderText, + uniqueId = uuidv4(), + disabled = false, + }, + ref, + ) => { + const { colors } = useAppTheme(); + const styles = createStyles(colors); + + // Internal state + const [ + nextSeedPhraseInputFocusedIndex, + setNextSeedPhraseInputFocusedIndex, + ] = useState(null); + const [errorWordIndexes, setErrorWordIndexes] = useState< + Record + >({}); + + const seedPhraseInputRefs = useRef void; blur: () => void } + > | null>(null); + + // Calculate trimmed seed phrase length + const trimmedSeedPhraseLength = useMemo( + () => getTrimmedSeedPhraseLength(seedPhrase), + [seedPhrase], + ); + + // Determine if we're in single input (textarea) mode + const isFirstInput = useMemo( + () => isFirstInputUtil(seedPhrase), + [seedPhrase], + ); + + // Initialize seed phrase input refs + const getSeedPhraseInputRef = useCallback(() => { + if (!seedPhraseInputRefs.current) { + seedPhraseInputRefs.current = new Map(); + } + return seedPhraseInputRefs.current; + }, []); + + // Handle seed phrase change at a specific index (for grid mode) + const handleSeedPhraseChangeAtIndex = useCallback( + (seedPhraseText: string, index: number) => { + try { + const text = formatSeedPhraseToSingleLine(seedPhraseText); + + if (text.includes(SPACE_CHAR)) { + const isEndWithSpace = text.at(-1) === SPACE_CHAR; + const splitArray = text + .trim() + .split(SPACE_CHAR) + .filter((word) => word.trim() !== ''); + + if (splitArray.length === 0) { + onSeedPhraseChange((prev) => { + const newSeedPhrase = [...prev]; + newSeedPhrase[index] = ''; + return newSeedPhrase; + }); + return; + } + + const mergedSeedPhrase = [ + ...seedPhrase.slice(0, index), + ...splitArray, + ...seedPhrase.slice(index + 1), + ]; + + const normalizedWords = mergedSeedPhrase + .map((w) => w.trim()) + .filter((w) => w !== ''); + const maxAllowed = Math.max(...SRP_LENGTHS); + const hasReachedMax = normalizedWords.length >= maxAllowed; + const isCompleteAndValid = + SRP_LENGTHS.includes(normalizedWords.length) && + isValidMnemonic(normalizedWords.join(SPACE_CHAR)); + + let nextSeedPhraseState = normalizedWords; + if ( + isEndWithSpace && + index === seedPhrase.length - 1 && + !isCompleteAndValid && + !hasReachedMax + ) { + nextSeedPhraseState = [...normalizedWords, '']; + } + + onSeedPhraseChange(nextSeedPhraseState); + + if (isCompleteAndValid || hasReachedMax) { + Keyboard.dismiss(); + setNextSeedPhraseInputFocusedIndex(null); + return; + } + + const targetIndex = Math.min( + nextSeedPhraseState.length - 1, + index + splitArray.length, + ); + setNextSeedPhraseInputFocusedIndex(targetIndex); + return; + } + + if (seedPhrase[index] !== text) { + onSeedPhraseChange((prev) => { + const newSeedPhrase = [...prev]; + newSeedPhrase[index] = text; + return newSeedPhrase; + }); + + if (text.trim() === '') { + setErrorWordIndexes((prev) => ({ + ...prev, + [index]: false, + })); + } + } + } catch (err) { + Logger.error(err as Error, 'Error handling seed phrase change'); + } + }, + [seedPhrase, onSeedPhraseChange], + ); + + const handleSeedPhraseChangeAtIndexRef = useRef( + handleSeedPhraseChangeAtIndex, + ); + + useEffect(() => { + handleSeedPhraseChangeAtIndexRef.current = handleSeedPhraseChangeAtIndex; + }, [handleSeedPhraseChangeAtIndex]); + + // Helper to validate words + const validateWords = useCallback((words: string[]) => { + const errorsMap: Record = {}; + words.forEach((word, index) => { + const trimmedWord = word.trim(); + if (trimmedWord && !checkValidSeedWord(trimmedWord)) { + errorsMap[index] = true; + } + }); + return errorsMap; + }, []); + + // Handle seed phrase change in first input + const handleSeedPhraseChange = useCallback( + (seedPhraseText: string) => { + const text = formatSeedPhraseToSingleLine(seedPhraseText); + const trimmedText = text.trim(); + const updatedTrimmedText = trimmedText + .split(SPACE_CHAR) + .filter((word) => word !== ''); + + if (SRP_LENGTHS.includes(updatedTrimmedText.length)) { + onSeedPhraseChange(updatedTrimmedText); + + // Validate complete phrases that might have invalid words + setErrorWordIndexes(validateWords(updatedTrimmedText)); + setNextSeedPhraseInputFocusedIndex(null); + seedPhraseInputRefs.current?.get(0)?.blur(); + Keyboard.dismiss(); + } else { + handleSeedPhraseChangeAtIndexRef.current?.(text, 0); + } + }, + [onSeedPhraseChange, validateWords], + ); + + // Handle focus change with validation + const handleOnFocus = useCallback((index: number) => { + setNextSeedPhraseInputFocusedIndex(index); + }, []); + + const handleOnBlur = useCallback( + (index: number) => { + const currentWord = seedPhrase[index]; + const trimmedWord = currentWord ? currentWord.trim() : ''; + if (trimmedWord) { + const checkValid = checkValidSeedWord(trimmedWord); + setErrorWordIndexes((prev) => ({ + ...prev, + [index]: !checkValid, + })); + } + }, + [seedPhrase], + ); + + const handleKeyPress = useCallback( + (e: { nativeEvent: { key: string } }, index: number) => { + if (e.nativeEvent.key === 'Backspace') { + if (seedPhrase[index] === '') { + const newData = seedPhrase.filter((_, idx) => idx !== index); + if (index > 0) { + const prevInputRef = seedPhraseInputRefs.current?.get(index - 1); + if (prevInputRef) { + prevInputRef.focus(); + } + setNextSeedPhraseInputFocusedIndex(index - 1); + } + onSeedPhraseChange([...newData]); + } + } + }, + [seedPhrase, onSeedPhraseChange], + ); + + const handleEnterKeyPress = useCallback( + (index: number) => { + handleSeedPhraseChangeAtIndexRef.current( + `${seedPhrase[index]}${SPACE_CHAR}`, + index, + ); + }, + [seedPhrase], + ); + + // Validate seed phrase and show errors + const error = useMemo(() => { + const hasWordErrors = Object.values(errorWordIndexes).some(Boolean); + if (hasWordErrors) { + return strings('import_from_seed.spellcheck_error'); + } + return ''; + }, [errorWordIndexes]); + + useEffect(() => { + onError?.(error); + }, [error, onError]); + + const handlePaste = useCallback(async () => { + const text = await Clipboard.getString(); + if (text.trim() !== '') { + handleSeedPhraseChange(text); + } + }, [handleSeedPhraseChange]); + + const handleClear = useCallback(() => { + onSeedPhraseChange(['']); + setErrorWordIndexes({}); + setNextSeedPhraseInputFocusedIndex(null); + }, [onSeedPhraseChange]); + + useEffect(() => { + if (nextSeedPhraseInputFocusedIndex === null) return; + + const refElement = seedPhraseInputRefs.current?.get( + nextSeedPhraseInputFocusedIndex, + ); + + refElement?.focus(); + }, [nextSeedPhraseInputFocusedIndex]); + + React.useImperativeHandle(ref, () => ({ + handleSeedPhraseChange, + })); + + return ( + + + + + {/* Grid Inputs on multiple words mode. hidden when first input mode. + Need to use style hidden instead of condition rendering to avoid + keyboard flicker when change input */} + {seedPhrase.map((item, index) => ( + { + const inputRefs = getSeedPhraseInputRef(); + if (itemRef) { + inputRefs.set(index, itemRef); + } else { + inputRefs.delete(index); + } + }} + startAccessory={ + !isFirstInput && ( + + {index + 1}. + + ) + } + value={getInputValue(isFirstInput, index, item, seedPhrase)} + onFocus={() => { + handleOnFocus(index); + }} + onBlur={() => { + handleOnBlur(index); + }} + onChangeText={(text) => { + isFirstInput + ? handleSeedPhraseChange(text) + : handleSeedPhraseChangeAtIndex(text, index); + }} + onSubmitEditing={() => { + handleEnterKeyPress(index); + }} + placeholder="" + placeholderTextColor={colors.text.muted} + size={TextFieldSize.Md} + style={ + isFirstInput + ? styles.hiddenInput + : [ + styles.input, + styles.seedPhraseInputItem, + (index + 1) % 3 === 0 && + styles.seedPhraseInputItemLast, + ] + } + inputStyle={ + isFirstInput ? styles.textAreaInput : styles.inputItem + } + submitBehavior="submit" + autoComplete="off" + textAlignVertical={isFirstInput ? 'top' : 'center'} + showSoftInputOnFocus + isError={errorWordIndexes[index]} + autoCapitalize="none" + testID={`${testIdPrefix}_${index}`} + keyboardType="default" + autoCorrect={false} + textContentType="oneTimeCode" + spellCheck={false} + autoFocus={index === nextSeedPhraseInputFocusedIndex} + onKeyPress={(e) => handleKeyPress(e, index)} + isDisabled={disabled} + /> + ))} + + {/* Textarea Input on first input mode hidden during multiple works */} + { + handleOnFocus(0); + }} + onBlur={() => { + handleOnBlur(0); + }} + onChangeText={(text) => { + handleSeedPhraseChange(text); + }} + placeholder={placeholderText} + placeholderTextColor={colors.text.alternative} + size={TextFieldSize.Md} + style={ + isFirstInput + ? styles.seedPhraseDefaultInput + : styles.hiddenInput + } + inputStyle={styles.textAreaInput} + submitBehavior="submit" + autoComplete="off" + textAlignVertical="top" + showSoftInputOnFocus + autoCapitalize="none" + testID={testIdPrefix} + keyboardType="default" + autoCorrect={false} + textContentType="oneTimeCode" + spellCheck={false} + autoFocus={isFirstInput} + multiline + onKeyPress={(e) => handleKeyPress(e, 0)} + isDisabled={disabled} + /> + + + + + {/* Paste/Clear Button */} + { + if (trimmedSeedPhraseLength >= 1) { + handleClear(); + } else { + handlePaste(); + } + }} + > + {trimmedSeedPhraseLength >= 1 + ? strings('import_from_seed.clear_all') + : strings('import_from_seed.paste')} + + + {/* Error Text */} + {Boolean(externalError || error) && ( + + {externalError || error} + + )} + + ); + }, +); + +export default SrpInputGrid; diff --git a/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap b/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap index 2cc17ebb3b2b..b8c4a09f9307 100644 --- a/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap +++ b/app/components/Views/ImportFromSecretRecoveryPhrase/__snapshots__/index.test.tsx.snap @@ -562,7 +562,101 @@ exports[`ImportFromSecretRecoveryPhrase Import a wallet UI render matches snapsh } } accessible={true} - focusable={true} + focusable={false} + onClick={[Function]} + onResponderGrant={[Function]} + onResponderMove={[Function]} + onResponderRelease={[Function]} + onResponderTerminate={[Function]} + onResponderTerminationRequest={[Function]} + onStartShouldSetResponder={[Function]} + style={ + { + "alignItems": "center", + "backgroundColor": "#ffffff", + "borderColor": "#b7bbc8", + "borderRadius": 8, + "borderWidth": 1, + "flexDirection": "row", + "height": 0, + "left": 0, + "opacity": 0, + "paddingHorizontal": 16, + "position": "absolute", + "top": 0, + } + } + testID="textfield" + > + + + + + wordlist.includes(text); +import SrpInputGrid from '../../UI/SrpInputGrid'; /** * View where users can set restore their account @@ -102,16 +99,8 @@ const ImportFromSecretRecoveryPhrase = ({ const { colors, themeAppearance } = useTheme(); const styles = createStyles(colors); - const seedPhraseInputRefs = useRef(null); const confirmPasswordInput = useRef(); - function getSeedPhraseInputRef() { - if (!seedPhraseInputRefs.current) { - seedPhraseInputRefs.current = new Map(); - } - return seedPhraseInputRefs.current; - } - const { toastRef } = useContext(ToastContext); const passwordSetupAttemptTraceCtxRef = useRef(null); @@ -122,14 +111,12 @@ const ImportFromSecretRecoveryPhrase = ({ const [error, setError] = useState(''); const [hideSeedPhraseInput, setHideSeedPhraseInput] = useState(true); const [seedPhrase, setSeedPhrase] = useState(['']); - const [seedPhraseInputFocusedIndex, setSeedPhraseInputFocusedIndex] = - useState(null); - const [nextSeedPhraseInputFocusedIndex, setNextSeedPhraseInputFocusedIndex] = - useState(null); const [currentStep, setCurrentStep] = useState(0); const [learnMore, setLearnMore] = useState(false); const [showPasswordIndex, setShowPasswordIndex] = useState([0, 1]); + const srpInputGridRef = useRef(null); + const { fetchAccountsWithActivity } = useAccountsWithNetworkActivitySync({ onFirstLoad: false, onTransactionComplete: false, @@ -143,6 +130,13 @@ const ImportFromSecretRecoveryPhrase = ({ return !SRP_LENGTHS.includes(updatedSeedPhraseLength); }, [seedPhrase]); + useEffect(() => { + if (error) { + setError(''); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [seedPhrase]); + const { isEnabled: isMetricsEnabled } = useMetrics(); const track = (event, properties) => { @@ -151,155 +145,6 @@ const ImportFromSecretRecoveryPhrase = ({ trackOnboarding(eventBuilder.build(), saveOnboardingEvent); }; - const [errorWordIndexes, setErrorWordIndexes] = useState({}); - - const handleClear = useCallback(() => { - setSeedPhrase(['']); - setErrorWordIndexes({}); - setError(''); - setSeedPhraseInputFocusedIndex(0); - setNextSeedPhraseInputFocusedIndex(0); - }, []); - - const handleSeedPhraseChangeAtIndex = useCallback( - (seedPhraseText, index) => { - try { - const text = formatSeedPhraseToSingleLine(seedPhraseText); - - if (text.includes(SPACE_CHAR)) { - const isEndWithSpace = text.at(-1) === SPACE_CHAR; - // handle use pasting multiple words / whole seed phrase separated by spaces - const splitArray = text - .trim() - .split(' ') - .filter((word) => word.trim() !== ''); - - // If no valid words (only spaces), don't navigate to next field - if (splitArray.length === 0) { - // User typed only spaces, stay in current field - setSeedPhrase((prev) => { - const newSeedPhrase = [...prev]; - newSeedPhrase[index] = ''; // Clear the spaces - return newSeedPhrase; - }); - return; - } - - // Build the new seed phrase array - const mergedSeedPhrase = [ - ...seedPhrase.slice(0, index), - ...splitArray, - ...seedPhrase.slice(index + 1), - ]; - - const normalizedWords = mergedSeedPhrase - .map((w) => w.trim()) - .filter((w) => w !== ''); - const maxAllowed = Math.max(...SRP_LENGTHS); - const hasReachedMax = normalizedWords.length >= maxAllowed; - const isCompleteAndValid = - SRP_LENGTHS.includes(normalizedWords.length) && - isValidMnemonic(normalizedWords.join(' ')); - - // Prepare next state, retaining a single trailing empty input only when appropriate - let nextSeedPhraseState = normalizedWords; - if ( - isEndWithSpace && - index === seedPhrase.length - 1 && - !isCompleteAndValid && - !hasReachedMax - ) { - nextSeedPhraseState = [...normalizedWords, '']; - } - - // Always update component state before handling keyboard/focus - setSeedPhrase(nextSeedPhraseState); - - if (isCompleteAndValid || hasReachedMax) { - Keyboard.dismiss(); - setSeedPhraseInputFocusedIndex(null); - setNextSeedPhraseInputFocusedIndex(null); - return; - } - - const targetIndex = Math.min( - nextSeedPhraseState.length - 1, - index + splitArray.length, - ); - setTimeout(() => { - setNextSeedPhraseInputFocusedIndex(targetIndex); - }, 0); - return; - } - - // Only update state if the value is different from what's stored - if (seedPhrase[index] !== text) { - setSeedPhrase((prev) => { - const newSeedPhrase = [...prev]; - newSeedPhrase[index] = text; - return newSeedPhrase; - }); - } - } catch (error) { - Logger.error('Error handling seed phrase change:', error); - } - }, - [seedPhrase], - ); - - const handleSeedPhraseChange = useCallback( - (seedPhraseText) => { - const text = formatSeedPhraseToSingleLine(seedPhraseText); - const trimmedText = text.trim(); - const updatedTrimmedText = trimmedText - .split(' ') - .filter((word) => word !== ''); - - if (SRP_LENGTHS.includes(updatedTrimmedText.length)) { - setSeedPhrase(updatedTrimmedText); - } else { - handleSeedPhraseChangeAtIndex(text, 0); - } - - if (updatedTrimmedText.length > 1) { - // no focus on any input - setTimeout(() => { - setSeedPhraseInputFocusedIndex(null); - setNextSeedPhraseInputFocusedIndex(null); - seedPhraseInputRefs.current.get(0)?.blur(); - Keyboard.dismiss(); - }, 100); - } - }, - [handleSeedPhraseChangeAtIndex, setSeedPhrase], - ); - - const checkForWordErrors = useCallback( - (seedPhraseArr) => { - const errorsMap = {}; - seedPhraseArr.forEach((word, index) => { - // Trim the word for validation but keep the original for cursor position - const trimmedWord = word.trim(); - if (trimmedWord && !checkValidSeedWord(trimmedWord)) { - errorsMap[index] = true; - } - }); - setErrorWordIndexes(errorsMap); - return errorsMap; - }, - [setErrorWordIndexes], - ); - - useEffect(() => { - const wordErrorMap = checkForWordErrors(seedPhrase); - const hasWordErrors = Object.values(wordErrorMap).some(Boolean); - if (hasWordErrors) { - setError(strings('import_from_seed.spellcheck_error')); - } else { - setError(''); - } - }, [seedPhrase, checkForWordErrors]); - const onQrCodePress = useCallback(() => { let shouldHideSRP = true; if (!hideSeedPhraseInput) { @@ -312,8 +157,7 @@ const ImportFromSecretRecoveryPhrase = ({ disableTabber: true, onScanSuccess: ({ seed = undefined }) => { if (seed) { - handleClear(); - handleSeedPhraseChange(seed); + srpInputGridRef.current?.handleSeedPhraseChange(seed); } else { Alert.alert( strings('import_from_seed.invalid_qr_code_title'), @@ -326,7 +170,7 @@ const ImportFromSecretRecoveryPhrase = ({ setHideSeedPhraseInput(shouldHideSRP); }, }); - }, [hideSeedPhraseInput, navigation, handleClear, handleSeedPhraseChange]); + }, [hideSeedPhraseInput, navigation]); const onBackPress = () => { if (currentStep === 0) { @@ -446,19 +290,12 @@ const ImportFromSecretRecoveryPhrase = ({ current && current.focus(); }; - const handlePaste = useCallback(async () => { - const text = await Clipboard.getString(); // Get copied text - if (text.trim() !== '') { - handleSeedPhraseChange(text); - } - }, [handleSeedPhraseChange]); - const validateSeedPhrase = () => { // Trim each word before joining to ensure proper validation const phrase = seedPhrase .map((item) => item.trim()) .filter((item) => item !== '') - .join(' '); + .join(SPACE_CHAR); const seedPhraseLength = seedPhrase.length; if (!SRP_LENGTHS.includes(seedPhraseLength)) { toastRef?.current?.showToast({ @@ -517,10 +354,10 @@ const ImportFromSecretRecoveryPhrase = ({ }; const onPressImport = async () => { - seedPhraseInputRefs.current.get(seedPhraseInputFocusedIndex)?.blur(); - // Trim each word before joining for processing - const trimmedSeedPhrase = seedPhrase.map((item) => item.trim()).join(' '); + const trimmedSeedPhrase = seedPhrase + .map((item) => item.trim()) + .join(SPACE_CHAR); const vaultSeed = await parseVaultValue(password, trimmedSeedPhrase); const parsedSeed = parseSeedPhrase(vaultSeed || trimmedSeedPhrase); @@ -660,15 +497,6 @@ const ImportFromSecretRecoveryPhrase = ({ }); }; - const getInputValue = (isFirstInput, index, item) => { - if (isFirstInput) { - return seedPhrase?.[0] || ''; - } - - // Show all words by default - return item; - }; - const learnMoreLink = () => { navigation.push('Webview', { screen: 'SimpleWebview', @@ -679,65 +507,8 @@ const ImportFromSecretRecoveryPhrase = ({ }); }; - useEffect(() => { - if (nextSeedPhraseInputFocusedIndex === null) return; - - const refElement = seedPhraseInputRefs.current.get( - nextSeedPhraseInputFocusedIndex, - ); - - refElement?.focus(); - }, [nextSeedPhraseInputFocusedIndex]); - - const handleOnFocus = useCallback( - (index) => { - const currentWord = seedPhrase[seedPhraseInputFocusedIndex]; - const trimmedWord = currentWord ? currentWord.trim() : ''; - - if (trimmedWord && !checkValidSeedWord(trimmedWord)) { - setErrorWordIndexes((prev) => ({ - ...prev, - [seedPhraseInputFocusedIndex]: true, - })); - } else { - setErrorWordIndexes((prev) => ({ - ...prev, - [seedPhraseInputFocusedIndex]: false, - })); - } - setSeedPhraseInputFocusedIndex(index); - setNextSeedPhraseInputFocusedIndex(index); - }, - [seedPhrase, seedPhraseInputFocusedIndex], - ); - - const trimmedSeedPhraseLength = useMemo( - () => seedPhrase.filter((word) => word !== '').length, - [seedPhrase], - ); - const uniqueId = useMemo(() => uuidv4(), []); - const isFirstInput = useMemo(() => seedPhrase.length <= 1, [seedPhrase]); - - const handleKeyPress = (e, index) => { - if (e.nativeEvent.key === 'Backspace') { - if (seedPhrase[index] === '') { - const newData = seedPhrase.filter((_, idx) => idx !== index); - if (index > 0) { - setNextSeedPhraseInputFocusedIndex(index - 1); - } - setTimeout(() => { - setSeedPhrase(index === 0 ? [''] : [...newData]); - }, 0); - } - } - }; - - const handleEnterKeyPress = (index) => { - handleSeedPhraseChangeAtIndex(`${seedPhrase[index]} `, index); - }; - return ( - - - - - {seedPhrase.map((item, index) => ( - { - const inputRefs = getSeedPhraseInputRef(); - if (ref) { - inputRefs.set(index, ref); - } else { - inputRefs.delete(index); - } - }} - startAccessory={ - !isFirstInput && ( - - {index + 1}. - - ) - } - value={getInputValue(isFirstInput, index, item)} - onFocus={(e) => { - handleOnFocus(index); - }} - onInputFocus={() => { - setNextSeedPhraseInputFocusedIndex(index); - }} - onChangeText={(text) => { - isFirstInput - ? handleSeedPhraseChange(text) - : handleSeedPhraseChangeAtIndex(text, index); - }} - onSubmitEditing={() => { - handleEnterKeyPress(index); - }} - placeholder={ - isFirstInput - ? strings('import_from_seed.srp_placeholder') - : '' - } - placeholderTextColor={ - isFirstInput - ? colors.text.alternative - : colors.text.muted - } - size={TextFieldSize.Md} - style={ - isFirstInput - ? styles.seedPhraseDefaultInput - : [ - styles.input, - styles.seedPhraseInputItem, - (index + 1) % 3 === 0 && - styles.seedPhraseInputItemLast, - ] - } - inputStyle={ - isFirstInput - ? styles.textAreaInput - : styles.inputItem - } - submitBehavior="submit" - autoComplete="off" - textAlignVertical={isFirstInput ? 'top' : 'center'} - showSoftInputOnFocus - isError={errorWordIndexes[index]} - autoCapitalize="none" - numberOfLines={1} - testID={ - isFirstInput - ? ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_ID - : `${ImportFromSeedSelectorsIDs.SEED_PHRASE_INPUT_ID}_${index}` - } - keyboardType="default" - autoCorrect={false} - textContentType="oneTimeCode" - spellCheck={false} - autoFocus={ - isFirstInput || - index === nextSeedPhraseInputFocusedIndex - } - multiline={isFirstInput} - onKeyPress={(e) => handleKeyPress(e, index)} - /> - ))} - - - - { - if (trimmedSeedPhraseLength >= 1) { - handleClear(); - } else { - handlePaste(); - } - }} - > - {trimmedSeedPhraseLength >= 1 - ? strings('import_from_seed.clear_all') - : strings('import_from_seed.paste')} - - {Boolean(error) && ( - - {error} - - )} - +