diff --git a/src/CONST.ts b/src/CONST.ts index cbfe07ae5aab..9a252d773639 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -667,6 +667,7 @@ const CONST = { TOOLTIP_SENSE: 1000, TRIE_INITIALIZATION: 'trie_initialization', COMMENT_LENGTH_DEBOUNCE_TIME: 500, + SEARCH_FOR_REPORTS_DEBOUNCE_TIME: 300, }, PRIORITY_MODE: { GSD: 'gsd', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e01319cc2f66..35e9020e3717 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -26,6 +26,9 @@ const ONYXKEYS = { /** Boolean flag set whenever the sidebar has loaded */ IS_SIDEBAR_LOADED: 'isSidebarLoaded', + /** Boolean flag set whenever we are searching for reports in the server */ + IS_SEARCHING_FOR_REPORTS: 'isSearchingForReports', + /** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */ PERSISTED_REQUESTS: 'networkRequestQueue', diff --git a/src/components/OptionsList/BaseOptionsList.js b/src/components/OptionsList/BaseOptionsList.js index 23049b65f198..edea0b8d1aba 100644 --- a/src/components/OptionsList/BaseOptionsList.js +++ b/src/components/OptionsList/BaseOptionsList.js @@ -66,6 +66,7 @@ function BaseOptionsList({ isDisabled, innerRef, isRowMultilineSupported, + isLoadingNewOptions, }) { const flattenedData = useRef(); const previousSections = usePrevious(sections); @@ -245,7 +246,9 @@ function BaseOptionsList({ ) : ( <> - {headerMessage ? ( + {/* If we are loading new options we will avoid showing any header message. This is mostly because one of the header messages says there are no options. */} + {/* This is misleading because we might be in the process of loading fresh options from the server. */} + {!isLoadingNewOptions && headerMessage ? ( {headerMessage} diff --git a/src/components/OptionsList/optionsListPropTypes.js b/src/components/OptionsList/optionsListPropTypes.js index 165cec699b80..dc716453b2a8 100644 --- a/src/components/OptionsList/optionsListPropTypes.js +++ b/src/components/OptionsList/optionsListPropTypes.js @@ -87,6 +87,9 @@ const propTypes = { /** Whether to wrap large text up to 2 lines */ isRowMultilineSupported: PropTypes.bool, + + /** Whether we are loading new options */ + isLoadingNewOptions: PropTypes.bool, }; const defaultProps = { @@ -113,6 +116,7 @@ const defaultProps = { shouldPreventDefaultFocusOnSelectRow: false, showScrollIndicator: false, isRowMultilineSupported: false, + isLoadingNewOptions: false, }; export {propTypes, defaultProps}; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index e72bb7ef4b8e..3c9d401cdbdb 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -17,6 +17,7 @@ import {propTypes as optionsSelectorPropTypes, defaultProps as optionsSelectorDe import setSelection from '../../libs/setSelection'; import compose from '../../libs/compose'; import getPlatform from '../../libs/getPlatform'; +import FormHelpMessage from '../FormHelpMessage'; const propTypes = { /** padding bottom style of safe area */ @@ -392,6 +393,7 @@ class BaseOptionsSelector extends Component { blurOnSubmit={Boolean(this.state.allOptions.length)} spellCheck={false} shouldInterceptSwipe={this.props.shouldTextInputInterceptSwipe} + isLoading={this.props.isLoadingNewOptions} /> ); const optionsList = ( @@ -428,6 +430,7 @@ class BaseOptionsSelector extends Component { isLoading={!this.props.shouldShowOptions} showScrollIndicator={this.props.showScrollIndicator} isRowMultilineSupported={this.props.isRowMultilineSupported} + isLoadingNewOptions={this.props.isLoadingNewOptions} shouldPreventDefaultFocusOnSelectRow={this.props.shouldPreventDefaultFocusOnSelectRow} /> ); @@ -453,6 +456,13 @@ class BaseOptionsSelector extends Component { {this.props.children} {this.props.shouldShowTextInput && textInput} + {Boolean(this.props.textInputAlert) && ( + + )} {optionsList} diff --git a/src/components/TextInput/BaseTextInput.js b/src/components/TextInput/BaseTextInput.js index cfc042b4f370..76b7728458c4 100644 --- a/src/components/TextInput/BaseTextInput.js +++ b/src/components/TextInput/BaseTextInput.js @@ -1,6 +1,6 @@ import _ from 'underscore'; import React, {useState, useRef, useEffect, useCallback, useMemo} from 'react'; -import {Animated, View, StyleSheet} from 'react-native'; +import {Animated, View, StyleSheet, ActivityIndicator} from 'react-native'; import Str from 'expensify-common/lib/str'; import RNTextInput from '../RNTextInput'; import TextInputLabel from './TextInputLabel'; @@ -372,6 +372,13 @@ function BaseTextInput(props) { // `dataset.submitOnEnter` is used to indicate that pressing Enter on this input should call the submit callback. dataSet={{submitOnEnter: isMultiline && props.submitOnEnter}} /> + {props.isLoading && ( + + )} {Boolean(props.secureTextEntry) && ( (preferredLocale = val), }); +let priorityMode; +Onyx.connect({ + key: ONYXKEYS.NVP_PRIORITY_MODE, + callback: (nextPriorityMode) => { + // When someone switches their priority mode we need to fetch all their chats because only #focus mode works with a subset of a user's chats. This is only possible via the OpenApp command. + if (nextPriorityMode === CONST.PRIORITY_MODE.DEFAULT && priorityMode === CONST.PRIORITY_MODE.GSD) { + // eslint-disable-next-line no-use-before-define + openApp(); + } + priorityMode = nextPriorityMode; + }, +}); + let resolveIsReadyPromise; const isReadyToOpenApp = new Promise((resolve) => { resolveIsReadyPromise = resolve; @@ -207,7 +220,8 @@ function getOnyxDataForOpenOrReconnect(isOpenApp = false) { */ function openApp() { getPolicyParamsForOpenOrReconnect().then((policyParams) => { - API.read('OpenApp', policyParams, getOnyxDataForOpenOrReconnect(true)); + const params = {enablePriorityModeFilter: true, ...policyParams}; + API.read('OpenApp', params, getOnyxDataForOpenOrReconnect(true)); }); } diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 27a02b1fc75f..cf5c12511aff 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1,6 +1,7 @@ import {InteractionManager} from 'react-native'; import _ from 'underscore'; import lodashGet from 'lodash/get'; +import lodashDebounce from 'lodash/debounce'; import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Onyx from 'react-native-onyx'; import Str from 'expensify-common/lib/str'; @@ -2211,7 +2212,63 @@ function savePrivateNotesDraft(reportID, note) { Onyx.merge(`${ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT}${reportID}`, note); } +/** + * @private + * @param {string} searchInput + */ +function searchForReports(searchInput) { + // We do not try to make this request while offline because it sets a loading indicator optimistically + if (isNetworkOffline) { + Onyx.set(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, false); + return; + } + + API.read( + 'SearchForReports', + {searchInput}, + { + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + value: false, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + value: false, + }, + ], + }, + ); +} + +/** + * @private + * @param {string} searchInput + */ +const debouncedSearchInServer = lodashDebounce(searchForReports, CONST.TIMING.SEARCH_FOR_REPORTS_DEBOUNCE_TIME, {leading: false}); + +/** + * @param {string} searchInput + */ +function searchInServer(searchInput) { + if (isNetworkOffline) { + Onyx.set(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, false); + return; + } + + // Why not set this in optimistic data? It won't run until the API request happens and while the API request is debounced + // we want to show the loading state right away. Otherwise, we will see a flashing UI where the client options are sorted and + // tell the user there are no options, then we start searching, and tell them there are no options again. + Onyx.set(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, true); + debouncedSearchInServer(searchInput); +} + export { + searchInServer, addComment, addAttachment, reconnect, diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 565f36d69e54..64bff8655403 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -1,5 +1,5 @@ import _ from 'underscore'; -import React, {useState, useEffect, useMemo} from 'react'; +import React, {useState, useEffect, useMemo, useCallback} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; import {withOnyx} from 'react-native-onyx'; @@ -20,6 +20,7 @@ import compose from '../libs/compose'; import personalDetailsPropType from './personalDetailsPropType'; import reportPropTypes from './reportPropTypes'; import variables from '../styles/variables'; +import useNetwork from '../hooks/useNetwork'; const propTypes = { /** Beta features list */ @@ -34,22 +35,27 @@ const propTypes = { ...windowDimensionsPropTypes, ...withLocalizePropTypes, + + /** Whether we are searching for reports in the server */ + isSearchingForReports: PropTypes.bool, }; const defaultProps = { betas: [], personalDetails: {}, reports: {}, + isSearchingForReports: false, }; const excludedGroupEmails = _.without(CONST.EXPENSIFY_EMAILS, CONST.EMAIL.CONCIERGE); -function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) { +function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, isSearchingForReports}) { const [searchTerm, setSearchTerm] = useState(''); const [filteredRecentReports, setFilteredRecentReports] = useState([]); const [filteredPersonalDetails, setFilteredPersonalDetails] = useState([]); const [filteredUserToInvite, setFilteredUserToInvite] = useState(); const [selectedOptions, setSelectedOptions] = useState([]); + const {isOffline} = useNetwork(); const maxParticipantsReached = selectedOptions.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; const headerMessage = OptionsListUtils.getHeaderMessage( @@ -167,6 +173,13 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate}) // eslint-disable-next-line react-hooks/exhaustive-deps }, [reports, personalDetails, searchTerm]); + // When search term updates we will fetch any reports + const setSearchTermAndSearchInServer = useCallback((text = '') => { + if (text.length) { + Report.searchInServer(text); + } + setSearchTerm(text); + }, []); return ( createChat(option)} - onChangeText={setSearchTerm} + onChangeText={setSearchTermAndSearchInServer} headerMessage={headerMessage} boldStyle shouldPreventDefaultFocusOnSelectRow={!Browser.isMobile()} shouldShowOptions={isOptionsDataReady} shouldShowConfirmButton confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} + textInputAlert={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} onConfirmSelection={createGroup} textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} + isLoadingNewOptions={isSearchingForReports} /> @@ -230,5 +245,9 @@ export default compose( betas: { key: ONYXKEYS.BETAS, }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, }), )(NewChatPage); diff --git a/src/pages/SearchPage.js b/src/pages/SearchPage.js index 141f4e841853..272fb30de858 100755 --- a/src/pages/SearchPage.js +++ b/src/pages/SearchPage.js @@ -20,6 +20,8 @@ import compose from '../libs/compose'; import personalDetailsPropType from './personalDetailsPropType'; import reportPropTypes from './reportPropTypes'; import Performance from '../libs/Performance'; +import networkPropTypes from '../components/networkPropTypes'; +import {withNetwork} from '../components/OnyxProvider'; const propTypes = { /* Onyx Props */ @@ -37,12 +39,20 @@ const propTypes = { ...windowDimensionsPropTypes, ...withLocalizePropTypes, + + /** Network info */ + network: networkPropTypes, + + /** Whether we are searching for reports in the server */ + isSearchingForReports: PropTypes.bool, }; const defaultProps = { betas: [], personalDetails: {}, reports: {}, + network: {}, + isSearchingForReports: false, }; class SearchPage extends Component { @@ -75,6 +85,10 @@ class SearchPage extends Component { } onChangeText(searchValue = '') { + if (searchValue.length) { + Report.searchInServer(searchValue); + } + this.setState({searchValue}, this.debouncedUpdateOptions); } @@ -187,9 +201,13 @@ class SearchPage extends Component { showTitleTooltip shouldShowOptions={didScreenTransitionEnd && isOptionsDataReady} textInputLabel={this.props.translate('optionsSelector.nameEmailOrPhoneNumber')} + textInputAlert={ + this.props.network.isOffline ? `${this.props.translate('common.youAppearToBeOffline')} ${this.props.translate('search.resultsAreLimited')}` : '' + } onLayout={this.searchRendered} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} autoFocus + isLoadingNewOptions={this.props.isSearchingForReports} /> @@ -205,6 +223,7 @@ SearchPage.defaultProps = defaultProps; export default compose( withLocalize, withWindowDimensions, + withNetwork(), withOnyx({ reports: { key: ONYXKEYS.COLLECTION.REPORT, @@ -215,5 +234,9 @@ export default compose( betas: { key: ONYXKEYS.BETAS, }, + isSearchingForReports: { + key: ONYXKEYS.IS_SEARCHING_FOR_REPORTS, + initWithStoredValues: false, + }, }), )(SearchPage);