From 54250820933bb1747ba744bc827c31876e65968f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 5 Sep 2024 13:27:56 +0200 Subject: [PATCH 001/449] suffix tree impl --- src/libs/SuffixUkkonenTree.ts | 234 ++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/libs/SuffixUkkonenTree.ts diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts new file mode 100644 index 000000000000..7599ab2a25f3 --- /dev/null +++ b/src/libs/SuffixUkkonenTree.ts @@ -0,0 +1,234 @@ +import enEmojis from '@assets/emojis/en'; +import {DATA} from './test'; + +const CHAR_CODE_A = 'a'.charCodeAt(0); +const ALPHABET_SIZE = 28; +const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; + +function stringToArray(input: string) { + const res: number[] = []; + for (let i = 0; i < input.length; i++) { + const charCode = input.charCodeAt(i) - CHAR_CODE_A; + if (charCode >= 0 && charCode < ALPHABET_SIZE) { + res.push(charCode); + } + } + return res; +} + +function makeTree(a: number[]) { + const N = 1000000; + const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; + const l = Array(N).fill(0) as number[]; + const r = Array(N).fill(0) as number[]; + const p = Array(N).fill(0) as number[]; + const s = Array(N).fill(0) as number[]; + + let tv = 0; + let tp = 0; + let ts = 2; + let la = 0; + + function initializeTree() { + r.fill(a.length - 1); + s[0] = 1; + l[0] = -1; + r[0] = -1; + l[1] = -1; + r[1] = -1; + t[1].fill(0); + } + + function processCharacter(c: number) { + while (true) { + if (r[tv] < tp) { + if (t[tv][c] === -1) { + createNewLeaf(c); + continue; + } + tv = t[tv][c]; + tp = l[tv]; + } + if (tp === -1 || c === a[tp]) { + tp++; + } else { + splitEdge(c); + continue; + } + break; + } + if (c === DELIMITER_CHAR_CODE) { + resetTreeTraversal(); + } + } + + function createNewLeaf(c: number) { + t[tv][c] = ts; + l[ts] = la; + p[ts++] = tv; + tv = s[tv]; + tp = r[tv] + 1; + } + + function splitEdge(c: number) { + l[ts] = l[tv]; + r[ts] = tp - 1; + p[ts] = p[tv]; + t[ts][a[tp]] = tv; + t[ts][c] = ts + 1; + l[ts + 1] = la; + p[ts + 1] = ts; + l[tv] = tp; + p[tv] = ts; + t[p[ts]][a[l[ts]]] = ts; + ts += 2; + handleDescent(ts); + } + + function handleDescent(ts: number) { + tv = s[p[ts - 2]]; + tp = l[ts - 2]; + while (tp <= r[ts - 2]) { + tv = t[tv][a[tp]]; + tp += r[tv] - l[tv] + 1; + } + if (tp === r[ts - 2] + 1) { + s[ts - 2] = tv; + } else { + s[ts - 2] = ts; + } + tp = r[tv] - (tp - r[ts - 2]) + 2; + } + + function resetTreeTraversal() { + tv = 0; + tp = 0; + } + + function build() { + initializeTree(); + for (la = 0; la < a.length; ++la) { + const c = a[la]; + processCharacter(c); + } + } + + function findSubstring(sString: string) { + const s = stringToArray(sString); + const occurrences: number[] = []; + const st: Array<[number, number]> = [[0, 0]]; + + while (st.length > 0) { + const [node, depth] = st.pop()!; + + let isLeaf = true; + const leftRange = l[node]; + const rightRange = r[node]; + const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; + + let matches = true; + for (let i = 0; i < rangeLen && depth + i < s.length; i++) { + if (s[depth + i] !== a[leftRange + i]) { + matches = false; + break; + } + } + + if (!matches) { + continue; + } + + for (let i = ALPHABET_SIZE - 1; i >= 0; --i) { + if (t[node][i] !== -1) { + isLeaf = false; + st.push([t[node][i], depth + rangeLen]); + } + } + + if (isLeaf && depth + rangeLen >= s.length) { + occurrences.push(a.length - (depth + rangeLen)); + } + } + + return occurrences; + } + + function findSubstringRecursive(s: string) { + const occurrences: number[] = []; + + function dfs(node: number, depth: number) { + const leftRange = l[node]; + const rightRange = r[node]; + const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; + + for (let i = 0; i < rangeLen && depth + i < s.length; i++) { + if (s.charCodeAt(depth + i) - CHAR_CODE_A !== a[leftRange + i]) { + return; + } + } + + let isLeaf = true; + for (let i = 0; i < ALPHABET_SIZE; ++i) { + if (t[node][i] !== -1) { + isLeaf = false; + dfs(t[node][i], depth + rangeLen); + } + } + + if (isLeaf && depth >= s.length) { + occurrences.push(a.length - (depth + rangeLen)); + } + } + + dfs(0, 0); + return occurrences; + } + + return { + build, + findSubstring, + findSubstringRecursive, + }; +} + +function performanceProfile(input: string, search = 'sasha') { + const {build, findSubstring, findSubstringRecursive} = makeTree(stringToArray(input)); + + const buildStart = performance.now(); + build(); + const buildEnd = performance.now(); + console.log('Building time:', buildEnd - buildStart, 'ms'); + + const searchStart = performance.now(); + const results = findSubstring(search); + const searchEnd = performance.now(); + console.log('Search time:', searchEnd - searchStart, 'ms'); + console.log(results); + + const recursiveStart = performance.now(); + const resultsRecursive = findSubstringRecursive(search); + const recursiveEnd = performance.now(); + console.log('Recursive search time:', recursiveEnd - recursiveStart, 'ms'); + console.log(resultsRecursive); + + return { + buildTime: buildEnd - buildStart, + searchTime: searchEnd - searchStart, + recursiveSearchTime: recursiveEnd - recursiveStart, + }; +} + +function testEmojis() { + let searchString = ''; + Object.values(enEmojis).forEach(({keywords}) => { + searchString += `${keywords.join('')}{`; + }); + return performanceProfile(searchString, 'smile'); +} + +console.log('Read string of length', DATA.length); +function runTest() { + return performanceProfile(DATA); +} + +export {makeTree, stringToArray, runTest, testEmojis}; From 54a7b6017a602e05a983284e912c6228196b9644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 5 Sep 2024 13:37:46 +0200 Subject: [PATCH 002/449] add some helpful comments --- src/libs/SuffixUkkonenTree.ts | 91 ++++++++++++----------------------- 1 file changed, 31 insertions(+), 60 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 7599ab2a25f3..3bf8d2ed66a9 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -1,10 +1,20 @@ import enEmojis from '@assets/emojis/en'; -import {DATA} from './test'; const CHAR_CODE_A = 'a'.charCodeAt(0); const ALPHABET_SIZE = 28; const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; +// TODO: +// make makeTree faster +// how to deal with unicode characters such as spanish ones? + +/** + * Converts a string to an array of numbers representing the characters of the string. + * The numbers are offset by the character code of 'a' (97). + * - This is so that the numbers from a-z are in the range 0-25. + * - 26 is for the delimiter character "{", + * - 27 is for the end character "|". + */ function stringToArray(input: string) { const res: number[] = []; for (let i = 0; i < input.length; i++) { @@ -16,13 +26,22 @@ function stringToArray(input: string) { return res; } +/** + * Makes a tree from an input string, which has been converted by {@link stringToArray}. + * **Important:** As we only support an alphabet of 26 characters, the input string should only contain characters from a-z. + * Thus, all input data must be cleaned before being passed to this function. + * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). + */ function makeTree(a: number[]) { const N = 1000000; + const start = performance.now(); const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; const l = Array(N).fill(0) as number[]; const r = Array(N).fill(0) as number[]; const p = Array(N).fill(0) as number[]; const s = Array(N).fill(0) as number[]; + const end = performance.now(); + console.log('Allocating memory took:', end - start, 'ms'); let tv = 0; let tp = 0; @@ -113,47 +132,10 @@ function makeTree(a: number[]) { } } - function findSubstring(sString: string) { - const s = stringToArray(sString); - const occurrences: number[] = []; - const st: Array<[number, number]> = [[0, 0]]; - - while (st.length > 0) { - const [node, depth] = st.pop()!; - - let isLeaf = true; - const leftRange = l[node]; - const rightRange = r[node]; - const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; - - let matches = true; - for (let i = 0; i < rangeLen && depth + i < s.length; i++) { - if (s[depth + i] !== a[leftRange + i]) { - matches = false; - break; - } - } - - if (!matches) { - continue; - } - - for (let i = ALPHABET_SIZE - 1; i >= 0; --i) { - if (t[node][i] !== -1) { - isLeaf = false; - st.push([t[node][i], depth + rangeLen]); - } - } - - if (isLeaf && depth + rangeLen >= s.length) { - occurrences.push(a.length - (depth + rangeLen)); - } - } - - return occurrences; - } - - function findSubstringRecursive(s: string) { + /** + * Returns all occurrences of the given (sub)string in the input string. + */ + function findSubstring(searchString: string) { const occurrences: number[] = []; function dfs(node: number, depth: number) { @@ -161,8 +143,8 @@ function makeTree(a: number[]) { const rightRange = r[node]; const rangeLen = node === 0 ? 0 : rightRange - leftRange + 1; - for (let i = 0; i < rangeLen && depth + i < s.length; i++) { - if (s.charCodeAt(depth + i) - CHAR_CODE_A !== a[leftRange + i]) { + for (let i = 0; i < rangeLen && depth + i < searchString.length; i++) { + if (searchString.charCodeAt(depth + i) - CHAR_CODE_A !== a[leftRange + i]) { return; } } @@ -175,7 +157,7 @@ function makeTree(a: number[]) { } } - if (isLeaf && depth >= s.length) { + if (isLeaf && depth >= searchString.length) { occurrences.push(a.length - (depth + rangeLen)); } } @@ -187,12 +169,12 @@ function makeTree(a: number[]) { return { build, findSubstring, - findSubstringRecursive, }; } function performanceProfile(input: string, search = 'sasha') { - const {build, findSubstring, findSubstringRecursive} = makeTree(stringToArray(input)); + // TODO: For emojis we could precalculate the stringToArray or even the makeTree function during build time using a babel plugin + const {build, findSubstring} = makeTree(stringToArray(input)); const buildStart = performance.now(); build(); @@ -205,19 +187,13 @@ function performanceProfile(input: string, search = 'sasha') { console.log('Search time:', searchEnd - searchStart, 'ms'); console.log(results); - const recursiveStart = performance.now(); - const resultsRecursive = findSubstringRecursive(search); - const recursiveEnd = performance.now(); - console.log('Recursive search time:', recursiveEnd - recursiveStart, 'ms'); - console.log(resultsRecursive); - return { buildTime: buildEnd - buildStart, - searchTime: searchEnd - searchStart, - recursiveSearchTime: recursiveEnd - recursiveStart, + recursiveSearchTime: searchEnd - searchStart, }; } +// Demo function testing the performance for emojis function testEmojis() { let searchString = ''; Object.values(enEmojis).forEach(({keywords}) => { @@ -226,9 +202,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -console.log('Read string of length', DATA.length); -function runTest() { - return performanceProfile(DATA); -} - export {makeTree, stringToArray, runTest, testEmojis}; From 8622670fd891e59e266602ae3605868d3d5da997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hanno=20J=2E=20G=C3=B6decke?= Date: Thu, 5 Sep 2024 16:09:28 +0200 Subject: [PATCH 003/449] example implementation usage of Suffixtree --- src/libs/SuffixUkkonenTree.ts | 16 +++- src/pages/ChatFinderPage/index.tsx | 117 ++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 3bf8d2ed66a9..217588fae5fa 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -7,10 +7,11 @@ const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2; // TODO: // make makeTree faster // how to deal with unicode characters such as spanish ones? +// i think we need to support numbers as well /** * Converts a string to an array of numbers representing the characters of the string. - * The numbers are offset by the character code of 'a' (97). + * The numbers are offset by the character code of 'a' (97). * - This is so that the numbers from a-z are in the range 0-25. * - 26 is for the delimiter character "{", * - 27 is for the end character "|". @@ -33,7 +34,7 @@ function stringToArray(input: string) { * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ function makeTree(a: number[]) { - const N = 1000000; + const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; const l = Array(N).fill(0) as number[]; @@ -134,6 +135,15 @@ function makeTree(a: number[]) { /** * Returns all occurrences of the given (sub)string in the input string. + * + * You can think of the tree that we create as a big string that looks like this: + * + * "banana{pancake{apple|" + * The delimiter character '{' is used to separate the different strings. + * The end character '|' is used to indicate the end of our search string. + * + * This function will return the index(es) of found occurrences within this big string. + * So, when searching for "an", it would return [1, 4, 11]. */ function findSubstring(searchString: string) { const occurrences: number[] = []; @@ -202,4 +212,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -export {makeTree, stringToArray, runTest, testEmojis}; +export {makeTree, stringToArray, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index aabf881a8bed..cbdf5ec739c1 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -18,6 +18,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; +import {makeTree, stringToArray} from '@libs/SuffixUkkonenTree'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -51,6 +52,8 @@ const setPerformanceTimersEnd = () => { const ChatFinderPageFooterInstance = ; +const aToZRegex = /[^a-z]/gi; + function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPageProps) { const [isScreenTransitionEnd, setIsScreenTransitionEnd] = useState(false); const {translate} = useLocalize(); @@ -94,6 +97,112 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa return {...optionList, headerMessage: header}; }, [areOptionsInitialized, betas, isScreenTransitionEnd, options]); + /** + * Builds a suffix tree and returns a function to search in it. + * + * // TODO: + * - The results we get from tree.findSubstring are the indexes of the occurrence in the original string + * I implemented a manual mapping function here, we probably want to put that inside the tree implementation + * (including the implementation detail of the delimiter character) + */ + const findInSearchTree = useMemo(() => { + // The character that separates the different options in the search string + const delimiterChar = '{'; + + const searchIndexListRecentReports: Array = []; + const searchIndexListPersonalDetails: Array = []; + + let start = performance.now(); + let searchString = searchOptions.personalDetails + .map((option) => { + // TODO: there are probably more fields we'd like to add to the search string + let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + // Remove all none a-z chars: + searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); + + if (searchStringForTree.length > 0) { + // We need to push an array that has the same length as the length of the string we insert for this option: + const indexes = Array.from({length: searchStringForTree.length}, () => option); + // Note: we add undefined for the delimiter character + searchIndexListPersonalDetails.push(...indexes, undefined); + } else { + return undefined; + } + + return searchStringForTree; + }) + .filter(Boolean) + .join(delimiterChar); + searchString += searchOptions.recentReports + .map((option) => { + let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + searchStringForTree += option.reportID ?? ''; + searchStringForTree += option.name ?? ''; + // Remove all none a-z chars: + searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); + + if (searchStringForTree.length > 0) { + // We need to push an array that has the same length as the length of the string we insert for this option: + const indexes = Array.from({length: searchStringForTree.length}, () => option); + searchIndexListRecentReports.push(...indexes, undefined); + } else { + return undefined; + } + + return searchStringForTree; + }) + // TODO: this can probably improved by a reduce + .filter(Boolean) + .join(delimiterChar); + searchString += '|'; // End Character + console.log(searchIndexListPersonalDetails.slice(0, 20)); + console.log(searchString.substring(0, 20)); + console.log('building search strings', performance.now() - start); + + // TODO: stringToArray is probably also an implementation detail we want to hide from the developer + start = performance.now(); + const numbers = stringToArray(searchString); + console.log('stringToArray', performance.now() - start); + start = performance.now(); + const tree = makeTree(numbers); + console.log('makeTree', performance.now() - start); + start = performance.now(); + tree.build(); + console.log('build', performance.now() - start); + + function search(searchInput: string) { + start = performance.now(); + const result = tree.findSubstring(searchInput); + console.log('FindSubstring index result for searchInput', searchInput, result); + // Map the results to the original options + const mappedResults = { + personalDetails: [] as OptionData[], + recentReports: [] as OptionData[], + }; + result.forEach((index) => { + // const textInSearchString = searchString.substring(index, searchString.indexOf(delimiterChar, index)); + // console.log('textInSearchString', textInSearchString); + + if (index < searchIndexListPersonalDetails.length) { + const option = searchIndexListPersonalDetails[index]; + if (option) { + mappedResults.personalDetails.push(option); + } + } else { + const option = searchIndexListRecentReports[index - searchIndexListPersonalDetails.length]; + if (option) { + mappedResults.recentReports.push(option); + } + } + }); + + console.log('search', performance.now() - start); + return mappedResults; + } + + return search; + }, [searchOptions.personalDetails, searchOptions.recentReports]); + const filteredOptions = useMemo(() => { if (debouncedSearchValue.trim() === '') { return { @@ -105,17 +214,17 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedSearchValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); + const newOptions = findInSearchTree(debouncedSearchValue.toLowerCase().replace(aToZRegex, '')); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); - const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length + Number(!!newOptions.userToInvite) > 0, false, debouncedSearchValue); + const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length > 0, false, debouncedSearchValue); return { recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: newOptions.userToInvite, + userToInvite: undefined, // newOptions.userToInvite, headerMessage: header, }; - }, [debouncedSearchValue, searchOptions]); + }, [debouncedSearchValue, findInSearchTree]); const {recentReports, personalDetails: localPersonalDetails, userToInvite, headerMessage} = debouncedSearchValue.trim() !== '' ? filteredOptions : searchOptions; From 01162fee73c8147556d65e4b5990f0a25d855d7e Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Wed, 11 Sep 2024 17:39:36 +0200 Subject: [PATCH 004/449] fix: resolved one TODO --- src/libs/SuffixUkkonenTree.ts | 14 ++++++++------ src/pages/ChatFinderPage/index.tsx | 8 ++------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 217588fae5fa..52a7ebb2b7d9 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -28,15 +28,16 @@ function stringToArray(input: string) { } /** - * Makes a tree from an input string, which has been converted by {@link stringToArray}. + * Makes a tree from an input string * **Important:** As we only support an alphabet of 26 characters, the input string should only contain characters from a-z. * Thus, all input data must be cleaned before being passed to this function. * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ -function makeTree(a: number[]) { +function makeTree(searchString: string) { + const a = stringToArray(searchString); const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); - const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1)) as number[][]; + const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1) as number[]); const l = Array(N).fill(0) as number[]; const r = Array(N).fill(0) as number[]; const p = Array(N).fill(0) as number[]; @@ -183,8 +184,9 @@ function makeTree(a: number[]) { } function performanceProfile(input: string, search = 'sasha') { - // TODO: For emojis we could precalculate the stringToArray or even the makeTree function during build time using a babel plugin - const {build, findSubstring} = makeTree(stringToArray(input)); + // TODO: For emojis we could precalculate the makeTree function during build time using a babel plugin + // maybe babel plugin that just precalculates the result of function execution (so that it can be generic purpose plugin) + const {build, findSubstring} = makeTree(input); const buildStart = performance.now(); build(); @@ -212,4 +214,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -export {makeTree, stringToArray, testEmojis}; +export {makeTree, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index cbdf5ec739c1..f7860d4cc1e3 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -18,7 +18,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; -import {makeTree, stringToArray} from '@libs/SuffixUkkonenTree'; +import {makeTree} from '@libs/SuffixUkkonenTree'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -159,12 +159,8 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa console.log(searchString.substring(0, 20)); console.log('building search strings', performance.now() - start); - // TODO: stringToArray is probably also an implementation detail we want to hide from the developer start = performance.now(); - const numbers = stringToArray(searchString); - console.log('stringToArray', performance.now() - start); - start = performance.now(); - const tree = makeTree(numbers); + const tree = makeTree(searchString); console.log('makeTree', performance.now() - start); start = performance.now(); tree.build(); From 09e8aa7362424a49e1e8889bb97bc15ba6648dc5 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 12 Sep 2024 12:42:19 +0200 Subject: [PATCH 005/449] fix: reduce code duplication --- src/libs/SuffixUkkonenTree.ts | 36 +++++++++++++++++-- src/pages/ChatFinderPage/index.tsx | 56 +++++++----------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 52a7ebb2b7d9..2a0d0d309a48 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -27,14 +27,44 @@ function stringToArray(input: string) { return res; } +const aToZRegex = /[^a-z]/gi; +// The character that separates the different options in the search string +const delimiterChar = '{'; + +function prepareData({data, transform}: {data: T[]; transform: (data: T) => string}): [string, Array] { + const searchIndexList: Array = []; + const str = data + .map((option) => { + let searchStringForTree = transform(option); + // Remove all none a-z chars: + searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); + + if (searchStringForTree.length > 0) { + // We need to push an array that has the same length as the length of the string we insert for this option: + const indexes = Array.from({length: searchStringForTree.length}, () => option); + // Note: we add undefined for the delimiter character + searchIndexList.push(...indexes, undefined); + } else { + return undefined; + } + + return searchStringForTree; + }) + // TODO: this can probably improved by a reduce + .filter(Boolean) + .join(delimiterChar); + + return [str, searchIndexList]; +} + /** * Makes a tree from an input string * **Important:** As we only support an alphabet of 26 characters, the input string should only contain characters from a-z. * Thus, all input data must be cleaned before being passed to this function. * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ -function makeTree(searchString: string) { - const a = stringToArray(searchString); +function makeTree(stringToSearch: string) { + const a = stringToArray(stringToSearch); const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); const t = Array.from({length: N}, () => Array(ALPHABET_SIZE).fill(-1) as number[]); @@ -214,4 +244,4 @@ function testEmojis() { return performanceProfile(searchString, 'smile'); } -export {makeTree, testEmojis}; +export {makeTree, prepareData, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index f7860d4cc1e3..97feafa892b7 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -18,7 +18,7 @@ import type {RootStackParamList} from '@libs/Navigation/types'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import Performance from '@libs/Performance'; import type {OptionData} from '@libs/ReportUtils'; -import {makeTree} from '@libs/SuffixUkkonenTree'; +import {makeTree, prepareData} from '@libs/SuffixUkkonenTree'; import * as Report from '@userActions/Report'; import Timing from '@userActions/Timing'; import CONST from '@src/CONST'; @@ -106,55 +106,25 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa * (including the implementation detail of the delimiter character) */ const findInSearchTree = useMemo(() => { - // The character that separates the different options in the search string - const delimiterChar = '{'; - - const searchIndexListRecentReports: Array = []; - const searchIndexListPersonalDetails: Array = []; - let start = performance.now(); - let searchString = searchOptions.personalDetails - .map((option) => { + const [personalDetailsSearchString, searchIndexListPersonalDetails] = prepareData({ + data: searchOptions.personalDetails, + transform: (option) => { // TODO: there are probably more fields we'd like to add to the search string - let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); - // Remove all none a-z chars: - searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); - - if (searchStringForTree.length > 0) { - // We need to push an array that has the same length as the length of the string we insert for this option: - const indexes = Array.from({length: searchStringForTree.length}, () => option); - // Note: we add undefined for the delimiter character - searchIndexListPersonalDetails.push(...indexes, undefined); - } else { - return undefined; - } - - return searchStringForTree; - }) - .filter(Boolean) - .join(delimiterChar); - searchString += searchOptions.recentReports - .map((option) => { + return (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + }, + }); + const [recentReportsSearchString, searchIndexListRecentReports] = prepareData({ + data: searchOptions.recentReports, + transform: (option) => { let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); searchStringForTree += option.reportID ?? ''; searchStringForTree += option.name ?? ''; - // Remove all none a-z chars: - searchStringForTree = searchStringForTree.toLowerCase().replace(aToZRegex, ''); - - if (searchStringForTree.length > 0) { - // We need to push an array that has the same length as the length of the string we insert for this option: - const indexes = Array.from({length: searchStringForTree.length}, () => option); - searchIndexListRecentReports.push(...indexes, undefined); - } else { - return undefined; - } return searchStringForTree; - }) - // TODO: this can probably improved by a reduce - .filter(Boolean) - .join(delimiterChar); - searchString += '|'; // End Character + }, + }); + const searchString = `${personalDetailsSearchString}${recentReportsSearchString}|`; // End Character console.log(searchIndexListPersonalDetails.slice(0, 20)); console.log(searchString.substring(0, 20)); console.log('building search strings', performance.now() - start); From fa81e13878d8d35b44b374e7a454f02cfc3cdc37 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 12 Sep 2024 13:01:13 +0200 Subject: [PATCH 006/449] refactor: O(2) -> O(1) --- src/libs/SuffixUkkonenTree.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 2a0d0d309a48..fc11e194b8e0 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -50,9 +50,18 @@ function prepareData({data, transform}: {data: T[]; transform: (data: T) => s return searchStringForTree; }) - // TODO: this can probably improved by a reduce - .filter(Boolean) - .join(delimiterChar); + // slightly faster alternative to `.filter(Boolean).join(delimiterChar)` + .reduce((acc: string, curr) => { + if (!curr) { + return acc; + } + + if (acc === '') { + return curr; + } + + return `${acc}${delimiterChar}${curr}`; + }, ''); return [str, searchIndexList]; } From e33142fd2e137cc2bcdb1b82a4cea17a74c08387 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 12 Sep 2024 15:38:59 +0200 Subject: [PATCH 007/449] refactor: minus one TODO --- src/libs/SuffixUkkonenTree.ts | 72 +++++++++++++++++++++++++---- src/pages/ChatFinderPage/index.tsx | 74 +++++++++--------------------- 2 files changed, 85 insertions(+), 61 deletions(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index fc11e194b8e0..6d56c59a4ab4 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -31,7 +31,12 @@ const aToZRegex = /[^a-z]/gi; // The character that separates the different options in the search string const delimiterChar = '{'; -function prepareData({data, transform}: {data: T[]; transform: (data: T) => string}): [string, Array] { +type PrepareDataParams = { + data: T[]; + transform: (data: T) => string; +}; + +function prepareData({data, transform}: PrepareDataParams): [string, Array] { const searchIndexList: Array = []; const str = data .map((option) => { @@ -72,7 +77,19 @@ function prepareData({data, transform}: {data: T[]; transform: (data: T) => s * Thus, all input data must be cleaned before being passed to this function. * If you then use this tree for search you should clean your search input as well (so that a search query of "testuser@myEmail.com" becomes "testusermyemailcom"). */ -function makeTree(stringToSearch: string) { +function makeTree(compose: Array>) { + const start1 = performance.now(); + const strings = []; + const indexes: Array> = []; + + for (const {data, transform} of compose) { + const [str, searchIndexList] = prepareData({data, transform}); + strings.push(str); + indexes.push(searchIndexList); + } + const stringToSearch = `${strings.join('')}|`; // End Character + console.log('building search strings', performance.now() - start1); + const a = stringToArray(stringToSearch); const N = 25000; // TODO: i reduced this number from 1_000_000 down to this, for faster performance - however its possible that it needs to be bigger for larger search strings const start = performance.now(); @@ -216,16 +233,48 @@ function makeTree(stringToSearch: string) { return occurrences; } + function findInSearchTree(searchInput: string) { + const now = performance.now(); + const result = findSubstring(searchInput); + console.log('FindSubstring index result for searchInput', searchInput, result); + // Map the results to the original options + + const mappedResults: T[][] = Array.from({length: compose.length}, () => []); + console.log({result}); + result.forEach((index) => { + // const textInSearchString = searchString.substring(index, searchString.indexOf(delimiterChar, index)); + // console.log('textInSearchString', textInSearchString); + + // TODO: check with Hanno whether we restore the data correctly + let offset = 0; + for (let i = 0; i < indexes.length; i++) { + const relativeIndex = index - offset; + if (relativeIndex < indexes[i].length && relativeIndex >= 0) { + const option = indexes[i][relativeIndex]; + if (option) { + mappedResults[i].push(option); + } + } else { + offset += indexes[i].length; + } + } + }); + + console.log('search', performance.now() - now); + return mappedResults; + } + return { build, findSubstring, + findInSearchTree, }; } -function performanceProfile(input: string, search = 'sasha') { +function performanceProfile(input: PrepareDataParams, search = 'sasha') { // TODO: For emojis we could precalculate the makeTree function during build time using a babel plugin // maybe babel plugin that just precalculates the result of function execution (so that it can be generic purpose plugin) - const {build, findSubstring} = makeTree(input); + const {build, findSubstring} = makeTree([input]); const buildStart = performance.now(); build(); @@ -246,11 +295,16 @@ function performanceProfile(input: string, search = 'sasha') { // Demo function testing the performance for emojis function testEmojis() { - let searchString = ''; - Object.values(enEmojis).forEach(({keywords}) => { - searchString += `${keywords.join('')}{`; - }); - return performanceProfile(searchString, 'smile'); + const data = Object.values(enEmojis); + return performanceProfile( + { + data, + transform: ({keywords}) => { + return `${keywords.join('')}{`; + }, + }, + 'smile', + ); } export {makeTree, prepareData, testEmojis}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 97feafa892b7..2afe1fd96e3d 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -99,38 +99,28 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa /** * Builds a suffix tree and returns a function to search in it. - * - * // TODO: - * - The results we get from tree.findSubstring are the indexes of the occurrence in the original string - * I implemented a manual mapping function here, we probably want to put that inside the tree implementation - * (including the implementation detail of the delimiter character) */ const findInSearchTree = useMemo(() => { let start = performance.now(); - const [personalDetailsSearchString, searchIndexListPersonalDetails] = prepareData({ - data: searchOptions.personalDetails, - transform: (option) => { - // TODO: there are probably more fields we'd like to add to the search string - return (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + const tree = makeTree([ + { + data: searchOptions.personalDetails, + transform: (option) => { + // TODO: there are probably more fields we'd like to add to the search string + return (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + }, }, - }); - const [recentReportsSearchString, searchIndexListRecentReports] = prepareData({ - data: searchOptions.recentReports, - transform: (option) => { - let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); - searchStringForTree += option.reportID ?? ''; - searchStringForTree += option.name ?? ''; - - return searchStringForTree; + { + data: searchOptions.recentReports, + transform: (option) => { + let searchStringForTree = (option.login ?? '') + (option.login !== option.displayName ? option.displayName ?? '' : ''); + searchStringForTree += option.reportID ?? ''; + searchStringForTree += option.name ?? ''; + + return searchStringForTree; + }, }, - }); - const searchString = `${personalDetailsSearchString}${recentReportsSearchString}|`; // End Character - console.log(searchIndexListPersonalDetails.slice(0, 20)); - console.log(searchString.substring(0, 20)); - console.log('building search strings', performance.now() - start); - - start = performance.now(); - const tree = makeTree(searchString); + ]); console.log('makeTree', performance.now() - start); start = performance.now(); tree.build(); @@ -138,32 +128,12 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa function search(searchInput: string) { start = performance.now(); - const result = tree.findSubstring(searchInput); - console.log('FindSubstring index result for searchInput', searchInput, result); - // Map the results to the original options - const mappedResults = { - personalDetails: [] as OptionData[], - recentReports: [] as OptionData[], - }; - result.forEach((index) => { - // const textInSearchString = searchString.substring(index, searchString.indexOf(delimiterChar, index)); - // console.log('textInSearchString', textInSearchString); - - if (index < searchIndexListPersonalDetails.length) { - const option = searchIndexListPersonalDetails[index]; - if (option) { - mappedResults.personalDetails.push(option); - } - } else { - const option = searchIndexListRecentReports[index - searchIndexListPersonalDetails.length]; - if (option) { - mappedResults.recentReports.push(option); - } - } - }); + const [personalDetails, recentReports] = tree.findInSearchTree(searchInput); - console.log('search', performance.now() - start); - return mappedResults; + return { + personalDetails, + recentReports, + }; } return search; From 30424a9e0fa91e8dc7a8e4c3ffb30363a5d5f4d9 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 13 Sep 2024 13:56:43 +0200 Subject: [PATCH 008/449] fix: bring back userToInvite --- src/libs/OptionsListUtils.ts | 37 ++++++++++++++++++++++-------- src/pages/ChatFinderPage/index.tsx | 12 ++++++++-- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f191c1d06532..9b850c382400 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -2382,6 +2382,31 @@ function getPersonalDetailSearchTerms(item: Partial) { function getCurrentUserSearchTerms(item: ReportUtils.OptionData) { return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? '']; } + +type PickUserToInviteParams = { + canInviteUser: boolean; + recentReports: ReportUtils.OptionData[]; + personalDetails: ReportUtils.OptionData[]; + searchValue: string; + config?: FilterOptionsConfig; + optionsToExclude: Option[]; +}; + +const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => { + let userToInvite = null; + if (canInviteUser) { + if (recentReports.length === 0 && personalDetails.length === 0) { + userToInvite = getUserToInviteOption({ + searchValue, + selectedOptions: config?.selectedOptions, + optionsToExclude, + }); + } + } + + return userToInvite; +}; + /** * Filters options based on the search input value */ @@ -2457,16 +2482,7 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt recentReports = orderOptions(recentReports, searchValue); } - let userToInvite = null; - if (canInviteUser) { - if (recentReports.length === 0 && personalDetails.length === 0) { - userToInvite = getUserToInviteOption({ - searchValue, - selectedOptions: config?.selectedOptions, - optionsToExclude, - }); - } - } + const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}); if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) { recentReports.splice(maxRecentReportsToShow); @@ -2549,6 +2565,7 @@ export { getEmptyOptions, shouldUseBoldText, getAlternateText, + pickUserToInvite, }; export type {MemberForList, CategorySection, CategoryTreeSection, Options, OptionList, SearchOption, PayeePersonalDetails, Category, Tax, TaxRatesOption, Option, OptionTree}; diff --git a/src/pages/ChatFinderPage/index.tsx b/src/pages/ChatFinderPage/index.tsx index 2afe1fd96e3d..facde356ff12 100644 --- a/src/pages/ChatFinderPage/index.tsx +++ b/src/pages/ChatFinderPage/index.tsx @@ -150,17 +150,25 @@ function ChatFinderPage({betas, isSearchingForReports, navigation}: ChatFinderPa } Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS); + const newOptions1 = OptionsListUtils.filterOptions(searchOptions, debouncedSearchValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true}); const newOptions = findInSearchTree(debouncedSearchValue.toLowerCase().replace(aToZRegex, '')); + const userToInvite = OptionsListUtils.pickUserToInvite({ + canInviteUser: true, + recentReports: newOptions.recentReports, + personalDetails: newOptions.personalDetails, + searchValue: debouncedSearchValue, + optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}], + }); Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS); const header = OptionsListUtils.getHeaderMessage(newOptions.recentReports.length > 0, false, debouncedSearchValue); return { recentReports: newOptions.recentReports, personalDetails: newOptions.personalDetails, - userToInvite: undefined, // newOptions.userToInvite, + userToInvite, headerMessage: header, }; - }, [debouncedSearchValue, findInSearchTree]); + }, [debouncedSearchValue, searchOptions, findInSearchTree]); const {recentReports, personalDetails: localPersonalDetails, userToInvite, headerMessage} = debouncedSearchValue.trim() !== '' ? filteredOptions : searchOptions; From 1d11ed2d534a13b3f17dbeb5ed836489ffef6c25 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Fri, 13 Sep 2024 15:59:43 +0200 Subject: [PATCH 009/449] fix: make Marc discoverable again (when we search in second array, then we will always get - so we add +1 bias) --- src/libs/SuffixUkkonenTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/SuffixUkkonenTree.ts b/src/libs/SuffixUkkonenTree.ts index 6d56c59a4ab4..7a7bb1bd4a3c 100644 --- a/src/libs/SuffixUkkonenTree.ts +++ b/src/libs/SuffixUkkonenTree.ts @@ -248,7 +248,7 @@ function makeTree(compose: Array>) { // TODO: check with Hanno whether we restore the data correctly let offset = 0; for (let i = 0; i < indexes.length; i++) { - const relativeIndex = index - offset; + const relativeIndex = index - offset + 1; if (relativeIndex < indexes[i].length && relativeIndex >= 0) { const option = indexes[i][relativeIndex]; if (option) { From 061efbf7b5e1798a79b3eee056a3fc5cb7e28672 Mon Sep 17 00:00:00 2001 From: SIMalik Date: Sat, 14 Sep 2024 14:20:29 +0500 Subject: [PATCH 010/449] Issue resolved: LHN - RBR appears on the wrong workspace chat for an error occurring on another workspace #47874 --- src/libs/OptionsListUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index f191c1d06532..451727858654 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -471,7 +471,7 @@ function uniqFast(items: string[]): string[] { function getAllReportErrors(report: OnyxEntry, reportActions: OnyxEntry): OnyxCommon.Errors { const reportErrors = report?.errors ?? {}; const reportErrorFields = report?.errorFields ?? {}; - const reportActionsArray = Object.values(reportActions ?? {}); + const reportActionsArray = Object.values(reportActions ?? {}).filter(action => !ReportActionUtils.isDeletedAction(action)); const reportActionErrors: OnyxCommon.ErrorFields = {}; for (const action of reportActionsArray) { From 14ffa198bab8d0d095a53eb04faab5ac0e3e0309 Mon Sep 17 00:00:00 2001 From: Getabalew Date: Thu, 19 Sep 2024 11:37:23 +0300 Subject: [PATCH 011/449] refactor: reuse ValidateCodeActionModal --- src/ROUTES.ts | 1 - src/SCREENS.ts | 1 - .../ValidateCodeForm/BaseValidateCodeForm.tsx | 15 +- .../ValidateCodeActionModal/index.tsx | 20 +- .../ValidateCodeActionModal/type.ts | 8 + .../ModalStackNavigators/index.tsx | 1 - .../CENTRAL_PANE_TO_RHP_MAPPING.ts | 1 - src/libs/Navigation/linkingConfig/config.ts | 3 - src/libs/actions/Delegate.ts | 9 +- .../Contacts/ContactMethodDetailsPage.tsx | 185 ++++++++-------- .../Profile/Contacts/ContactMethodsPage.tsx | 7 +- .../Profile/Contacts/NewContactMethodPage.tsx | 1 + .../Contacts/ValidateContactActionPage.tsx | 72 ------ .../AddDelegate/ConfirmDelegatePage.tsx | 6 +- .../AddDelegate/DelegateMagicCodePage.tsx | 58 +++-- .../ValidateCodeForm/BaseValidateCodeForm.tsx | 208 ------------------ .../ValidateCodeForm/index.android.tsx | 14 -- .../AddDelegate/ValidateCodeForm/index.tsx | 14 -- .../settings/Wallet/ExpensifyCardPage.tsx | 2 + 19 files changed, 163 insertions(+), 463 deletions(-) delete mode 100644 src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx delete mode 100644 src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx delete mode 100644 src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.android.tsx delete mode 100644 src/pages/settings/Security/AddDelegate/ValidateCodeForm/index.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 27504998c49c..c9116f337f9e 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -217,7 +217,6 @@ const ROUTES = { route: 'settings/profile/contact-methods/:contactMethod/details', getRoute: (contactMethod: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, backTo), }, - SETINGS_CONTACT_METHOD_VALIDATE_ACTION: 'settings/profile/contact-methods/validate-action', SETTINGS_NEW_CONTACT_METHOD: { route: 'settings/profile/contact-methods/new', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/profile/contact-methods/new', backTo), diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8168afba89ab..66cc2b420f44 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -74,7 +74,6 @@ const SCREENS = { DISPLAY_NAME: 'Settings_Display_Name', CONTACT_METHODS: 'Settings_ContactMethods', CONTACT_METHOD_DETAILS: 'Settings_ContactMethodDetails', - CONTACT_METHOD_VALIDATE_ACTION: 'Settings_ValidateContactMethodAction', NEW_CONTACT_METHOD: 'Settings_NewContactMethod', STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After', STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date', diff --git a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx index 247c0c606901..f6df07278ad8 100644 --- a/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -64,6 +64,8 @@ type ValidateCodeFormProps = { /** Function to clear error of the form */ clearError: () => void; + + sendValidateCode: () => void; }; type BaseValidateCodeFormProps = BaseValidateCodeFormOnyxProps & ValidateCodeFormProps; @@ -78,6 +80,7 @@ function BaseValidateCodeForm({ validateError, handleSubmitForm, clearError, + sendValidateCode, }: BaseValidateCodeFormProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -128,14 +131,6 @@ function BaseValidateCodeForm({ }, []), ); - useEffect(() => { - if (!validateError) { - return; - } - clearError(); - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - }, [clearError, validateError]); - useEffect(() => { if (!hasMagicCodeBeenSent) { return; @@ -147,7 +142,7 @@ function BaseValidateCodeForm({ * Request a validate code / magic code be sent to verify this contact method */ const resendValidateCode = () => { - User.requestValidateCodeAction(); + sendValidateCode(); inputValidateCodeRef.current?.clear(); }; @@ -196,7 +191,7 @@ function BaseValidateCodeForm({ errorText={formError?.validateCode ? translate(formError?.validateCode) : ErrorUtils.getLatestErrorMessage(account ?? {})} hasError={!isEmptyObject(validateError)} onFulfill={validateAndSubmitForm} - autoFocus={false} + autoFocus /> (null); @@ -30,7 +42,8 @@ function ValidateCodeActionModal({isVisible, title, description, onClose, valida return; } firstRenderRef.current = false; - User.requestValidateCodeAction(); + + sendValidateCode(); }, [isVisible]); return ( @@ -61,10 +74,13 @@ function ValidateCodeActionModal({isVisible, title, description, onClose, valida validatePendingAction={validatePendingAction} validateError={validateError} handleSubmitForm={handleSubmitForm} + sendValidateCode={sendValidateCode} clearError={clearError} ref={validateCodeFormRef} + hasMagicCodeBeenSent={hasMagicCodeBeenSent} /> + {footer} ); diff --git a/src/components/ValidateCodeActionModal/type.ts b/src/components/ValidateCodeActionModal/type.ts index 3cbfe62513d1..821f54ff0302 100644 --- a/src/components/ValidateCodeActionModal/type.ts +++ b/src/components/ValidateCodeActionModal/type.ts @@ -1,3 +1,4 @@ +import React from 'react'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; type ValidateCodeActionModalProps = { @@ -24,6 +25,13 @@ type ValidateCodeActionModalProps = { /** Function to clear error of the form */ clearError: () => void; + + footer?: React.JSX.Element; + + sendValidateCode: () => void; + + /** If the magic code has been resent previously */ + hasMagicCodeBeenSent?: boolean; }; // eslint-disable-next-line import/prefer-default-export diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b41b58530a6b..b24c6b3ea4f6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -190,7 +190,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodsPage').default, [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default, - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION]: () => require('../../../../pages/settings/Profile/Contacts/ValidateContactActionPage').default, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../../pages/settings/Profile/Contacts/NewContactMethodPage').default, [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 609162bedd13..3dc91a1bb530 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -6,7 +6,6 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = SCREENS.SETTINGS.PROFILE.DISPLAY_NAME, SCREENS.SETTINGS.PROFILE.CONTACT_METHODS, SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS, - SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION, SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD, SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER, SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 2ca2db10a1a7..abfa9625926f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -249,9 +249,6 @@ const config: LinkingOptions['config'] = { [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: { path: ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.route, }, - [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_VALIDATE_ACTION]: { - path: ROUTES.SETINGS_CONTACT_METHOD_VALIDATE_ACTION, - }, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: { path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route, exact: true, diff --git a/src/libs/actions/Delegate.ts b/src/libs/actions/Delegate.ts index 50d2ee7fc194..54165c4afa62 100644 --- a/src/libs/actions/Delegate.ts +++ b/src/libs/actions/Delegate.ts @@ -161,10 +161,6 @@ function clearDelegatorErrors() { Onyx.merge(ONYXKEYS.ACCOUNT, {delegatedAccess: {delegators: delegatedAccess.delegators.map((delegator) => ({...delegator, errorFields: undefined}))}}); } -function requestValidationCode() { - API.write(WRITE_COMMANDS.RESEND_VALIDATE_CODE, null); -} - function addDelegate(email: string, role: DelegateRole, validateCode: string) { const existingDelegate = delegatedAccess?.delegates?.find((delegate) => delegate.email === email); @@ -206,6 +202,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { delegatedAccess: { delegates: optimisticDelegateData(), }, + isLoading: true, }, }, ]; @@ -250,6 +247,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { delegatedAccess: { delegates: successDelegateData(), }, + isLoading: false, }, }, ]; @@ -292,6 +290,7 @@ function addDelegate(email: string, role: DelegateRole, validateCode: string) { delegatedAccess: { delegates: failureDelegateData(), }, + isLoading: false, }, }, ]; @@ -325,4 +324,4 @@ function removePendingDelegate(email: string) { }); } -export {connect, disconnect, clearDelegatorErrors, addDelegate, requestValidationCode, clearAddDelegateErrors, removePendingDelegate}; +export {connect, disconnect, clearDelegatorErrors, addDelegate, clearAddDelegateErrors, removePendingDelegate}; diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx index 9fcc28f51912..e4751ebb0293 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.tsx @@ -15,6 +15,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; @@ -25,6 +26,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; +import {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -35,12 +37,17 @@ import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeFo type ContactMethodDetailsPageProps = StackScreenProps; +type ValidateCodeFormError = { + validateCode?: TranslationPaths; +}; + function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { const [loginList, loginListResult] = useOnyx(ONYXKEYS.LOGIN_LIST); const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION); const [myDomainSecurityGroups, myDomainSecurityGroupsResult] = useOnyx(ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS); const [securityGroups, securityGroupsResult] = useOnyx(ONYXKEYS.COLLECTION.SECURITY_GROUP); const [isLoadingReportData, isLoadingReportDataResult] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {initialValue: true}); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true); const isLoadingOnyxValues = isLoadingOnyxValue(loginListResult, sessionResult, myDomainSecurityGroupsResult, securityGroupsResult, isLoadingReportDataResult); @@ -75,6 +82,7 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { }, [route.params.contactMethod]); const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); const isDefaultContactMethod = useMemo(() => session?.email === loginData?.partnerUserID, [session?.email, loginData?.partnerUserID]); + const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); /** * Attempt to set this contact method as user's "Default contact method" @@ -145,6 +153,10 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); }, [prevValidatedDate, loginData?.validatedDate, isDefaultContactMethod, backTo]); + useEffect(() => { + setIsValidateCodeActionModalVisible(!loginData?.validatedDate && !loginData?.errorFields?.addedLogin); + }, [loginData?.validatedDate, loginData?.errorFields?.addedLogin]); + if (isLoadingOnyxValues || (isLoadingReportData && isEmptyObject(loginList))) { return ; } @@ -168,100 +180,97 @@ function ContactMethodDetailsPage({route}: ContactMethodDetailsPageProps) { const isFailedAddContactMethod = !!loginData.errorFields?.addedLogin; const isFailedRemovedContactMethod = !!loginData.errorFields?.deletedLogin; + const MenuItems = () => ( + <> + {canChangeDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, 'defaultLogin')} + > + + + ) : null} + {isDefaultContactMethod ? ( + User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} + > + {translate('contacts.yourDefaultContactMethod')} + + ) : ( + User.clearContactMethodErrors(contactMethod, 'deletedLogin')} + > + toggleDeleteModal(true)} + /> + + )} + + ); + return ( - validateCodeFormRef.current?.focus?.()} - testID={ContactMethodDetailsPage.displayName} - > - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo))} + + toggleDeleteModal(false)} + onModalHide={() => { + InteractionManager.runAfterInteractions(() => { + validateCodeFormRef.current?.focusLastSelected?.(); + }); + }} + prompt={translate('contacts.removeAreYouSure')} + confirmText={translate('common.yesContinue')} + cancelText={translate('common.cancel')} + isVisible={isDeleteModalOpen && !isDefaultContactMethod} + danger /> - - toggleDeleteModal(false)} - onModalHide={() => { - InteractionManager.runAfterInteractions(() => { - validateCodeFormRef.current?.focusLastSelected?.(); - }); + + {isFailedAddContactMethod && ( + { + User.clearContactMethod(contactMethod); + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); }} - prompt={translate('contacts.removeAreYouSure')} - confirmText={translate('common.yesContinue')} - cancelText={translate('common.cancel')} - isVisible={isDeleteModalOpen && !isDefaultContactMethod} - danger + canDismissError /> + )} - {isFailedAddContactMethod && ( - { - User.clearContactMethod(contactMethod); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); - }} - canDismissError - /> - )} - - {!loginData.validatedDate && !isFailedAddContactMethod && ( - - + User.validateSecondaryLogin(loginList, contactMethod, validateCode)} + validateError={!isEmptyObject(validateLoginError) ? validateLoginError : ErrorUtils.getLatestErrorField(loginData, 'validateCodeSent')} + clearError={() => User.clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')} + onClose={() => { + Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.getRoute(backTo)); + setIsValidateCodeActionModalVisible(false); + }} + sendValidateCode={() => User.requestContactMethodValidateCode(contactMethod)} + description={translate('contacts.enterMagicCode', {contactMethod})} + footer={} + /> - - - )} - {canChangeDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, 'defaultLogin')} - > - - - ) : null} - {isDefaultContactMethod ? ( - User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} - > - {translate('contacts.yourDefaultContactMethod')} - - ) : ( - User.clearContactMethodErrors(contactMethod, 'deletedLogin')} - > - toggleDeleteModal(true)} - /> - - )} - - + {!isValidateCodeActionModalVisible && } + ); } diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx index 3f23b3a802be..cbe44ea648ca 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx @@ -81,12 +81,7 @@ function ContactMethodsPage({loginList, session, route}: ContactMethodsPageProps { - if (!login?.validatedDate && !login?.validateCodeSent) { - User.requestContactMethodValidateCode(loginName); - } - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(partnerUserID, navigateBackTo)); - }} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(partnerUserID, navigateBackTo))} brickRoadIndicator={indicator} shouldShowBasicTitle shouldShowRightIcon diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index 4ea878e82987..6824b5988a62 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -153,6 +153,7 @@ function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { onClose={() => setIsValidateCodeActionModalVisible(false)} isVisible={isValidateCodeActionModalVisible} title={contactMethod} + sendValidateCode={() => User.requestValidateCodeAction()} description={translate('contacts.enterMagicCode', {contactMethod})} /> diff --git a/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx b/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx deleted file mode 100644 index 157588a67397..000000000000 --- a/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import React, {useEffect, useRef} from 'react'; -import {View} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import DotIndicatorMessage from '@components/DotIndicatorMessage'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as User from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import ValidateCodeForm from './ValidateCodeForm'; -import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; - -function ValidateContactActionPage() { - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const themeStyles = useThemeStyles(); - const {translate} = useLocalize(); - const validateCodeFormRef = useRef(null); - const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); - - const [pendingContactAction] = useOnyx(ONYXKEYS.PENDING_CONTACT_ACTION); - const loginData = loginList?.[pendingContactAction?.contactMethod ?? '']; - - useEffect(() => { - if (!loginData || !!loginData.pendingFields?.addedLogin) { - return; - } - - // Navigate to methods page on successful magic code verification - Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHODS.route); - }, [loginData, loginData?.pendingFields, loginList]); - - const onBackButtonPress = () => { - User.clearUnvalidatedNewContactMethodAction(); - Navigation.goBack(ROUTES.SETTINGS_CONTACT_METHODS.route); - }; - - return ( - - - - - - - - ); -} - -ValidateContactActionPage.displayName = 'ValidateContactActionPage'; - -export default ValidateContactActionPage; diff --git a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx index 8c8292b1f320..f54c43b73726 100644 --- a/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx +++ b/src/pages/settings/Security/AddDelegate/ConfirmDelegatePage.tsx @@ -10,7 +10,6 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {requestValidationCode} from '@libs/actions/Delegate'; import {formatPhoneNumber} from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; @@ -43,10 +42,7 @@ function ConfirmDelegatePage({route}: ConfirmDelegatePageProps) { text={translate('delegate.addCopilot')} style={styles.mt6} pressOnEnter - onPress={() => { - requestValidationCode(); - Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(login, role)); - }} + onPress={() => Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(login, role))} /> ); diff --git a/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx b/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx index 9497507f041a..603fa1e5aa02 100644 --- a/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx +++ b/src/pages/settings/Security/AddDelegate/DelegateMagicCodePage.tsx @@ -1,33 +1,31 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useState} from 'react'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; +import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; +import * as User from '@libs/actions/User'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as Delegate from '@userActions/Delegate'; import type CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import ValidateCodeForm from './ValidateCodeForm'; -import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; type DelegateMagicCodePageProps = StackScreenProps; function DelegateMagicCodePage({route}: DelegateMagicCodePageProps) { const {translate} = useLocalize(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(true); + const login = route.params.login; const role = route.params.role as ValueOf; - const styles = useThemeStyles(); - const validateCodeFormRef = useRef(null); - const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === login); + const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate'); useEffect(() => { if (!currentDelegate || !!currentDelegate.pendingFields?.email || !!currentDelegate.errorFields?.addDelegate) { @@ -39,32 +37,28 @@ function DelegateMagicCodePage({route}: DelegateMagicCodePageProps) { }, [login, currentDelegate, role]); const onBackButtonPress = () => { + setIsValidateCodeActionModalVisible(false); Navigation.goBack(ROUTES.SETTINGS_DELEGATE_CONFIRM.getRoute(login, role)); }; + const clearError = () => { + if (!validateLoginError) { + return; + } + Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate'); + }; + return ( - - {({safeAreaPaddingBottomStyle}) => ( - <> - - {translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} - - - )} - + User.requestValidateCodeAction()} + handleSubmitForm={(validateCode) => Delegate.addDelegate(login, role, validateCode)} + description={translate('delegate.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})} + /> ); } diff --git a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx deleted file mode 100644 index c9816862ad35..000000000000 --- a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import {useFocusEffect} from '@react-navigation/native'; -import type {ForwardedRef} from 'react'; -import React, {useCallback, useImperativeHandle, useRef, useState} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; -import {useOnyx} from 'react-native-onyx'; -import Button from '@components/Button'; -import FixedFooter from '@components/FixedFooter'; -import MagicCodeInput from '@components/MagicCodeInput'; -import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput'; -import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import Text from '@components/Text'; -import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as Delegate from '@userActions/Delegate'; -import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {DelegateRole} from '@src/types/onyx/Account'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; - -type ValidateCodeFormHandle = { - focus: () => void; - focusLastSelected: () => void; -}; - -type ValidateCodeFormError = { - validateCode?: TranslationPaths; -}; - -type BaseValidateCodeFormProps = { - /** Specifies autocomplete hints for the system, so it can provide autofill */ - autoComplete?: AutoCompleteVariant; - - /** Forwarded inner ref */ - innerRef?: ForwardedRef; - - /** The email of the delegate */ - delegate: string; - - /** The role of the delegate */ - role: DelegateRole; - - /** Any additional styles to apply */ - wrapperStyle?: StyleProp; -}; - -function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => {}, delegate, role, wrapperStyle}: BaseValidateCodeFormProps) { - const {translate} = useLocalize(); - const {isOffline} = useNetwork(); - const theme = useTheme(); - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const [formError, setFormError] = useState({}); - const [validateCode, setValidateCode] = useState(''); - const inputValidateCodeRef = useRef(null); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const login = account?.primaryLogin; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- nullish coalescing doesn't achieve the same result in this case - const focusTimeoutRef = useRef(null); - - const currentDelegate = account?.delegatedAccess?.delegates?.find((d) => d.email === delegate); - const validateLoginError = ErrorUtils.getLatestErrorField(currentDelegate, 'addDelegate'); - - const shouldDisableResendValidateCode = !!isOffline || currentDelegate?.isLoading; - - useImperativeHandle(innerRef, () => ({ - focus() { - inputValidateCodeRef.current?.focus(); - }, - focusLastSelected() { - if (!inputValidateCodeRef.current) { - return; - } - if (focusTimeoutRef.current) { - clearTimeout(focusTimeoutRef.current); - } - focusTimeoutRef.current = setTimeout(() => { - inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); - }, - })); - - useFocusEffect( - useCallback(() => { - if (!inputValidateCodeRef.current) { - return; - } - if (focusTimeoutRef.current) { - clearTimeout(focusTimeoutRef.current); - } - focusTimeoutRef.current = setTimeout(() => { - inputValidateCodeRef.current?.focusLastSelected(); - }, CONST.ANIMATED_TRANSITION); - return () => { - if (!focusTimeoutRef.current) { - return; - } - clearTimeout(focusTimeoutRef.current); - }; - }, []), - ); - - /** - * Request a validate code / magic code be sent to verify this contact method - */ - const resendValidateCode = () => { - if (!login) { - return; - } - Delegate.requestValidationCode(); - - inputValidateCodeRef.current?.clear(); - }; - - /** - * Handle text input and clear formError upon text change - */ - const onTextInput = useCallback( - (text: string) => { - setValidateCode(text); - setFormError({}); - if (validateLoginError) { - Delegate.clearAddDelegateErrors(currentDelegate?.email ?? '', 'addDelegate'); - } - }, - [currentDelegate?.email, validateLoginError], - ); - - /** - * Check that all the form fields are valid, then trigger the submit callback - */ - const validateAndSubmitForm = useCallback(() => { - if (!validateCode.trim()) { - setFormError({validateCode: 'validateCodeForm.error.pleaseFillMagicCode'}); - return; - } - - if (!ValidationUtils.isValidValidateCode(validateCode)) { - setFormError({validateCode: 'validateCodeForm.error.incorrectMagicCode'}); - return; - } - - setFormError({}); - - Delegate.addDelegate(delegate, role, validateCode); - }, [delegate, role, validateCode]); - - return ( - - - - - - - {translate('validateCodeForm.magicCodeNotReceived')} - - - - - - -