You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Mobile: Show loading indicator while loading search results (#11104)
This commit is contained in:
		| @@ -712,13 +712,14 @@ packages/app-mobile/components/screens/Note.test.js | ||||
| packages/app-mobile/components/screens/Note.js | ||||
| packages/app-mobile/components/screens/NoteTagsDialog.js | ||||
| packages/app-mobile/components/screens/Notes.js | ||||
| packages/app-mobile/components/screens/SearchScreen/SearchResults.js | ||||
| packages/app-mobile/components/screens/SearchScreen/index.js | ||||
| packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js | ||||
| packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js | ||||
| packages/app-mobile/components/screens/ShareManager/index.test.js | ||||
| packages/app-mobile/components/screens/ShareManager/index.js | ||||
| packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js | ||||
| packages/app-mobile/components/screens/encryption-config.js | ||||
| packages/app-mobile/components/screens/search.js | ||||
| packages/app-mobile/components/screens/status.js | ||||
| packages/app-mobile/components/side-menu-content.js | ||||
| packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js | ||||
| @@ -967,6 +968,8 @@ packages/lib/hooks/useElementSize.js | ||||
| packages/lib/hooks/useEventListener.js | ||||
| packages/lib/hooks/usePlugin.js | ||||
| packages/lib/hooks/usePrevious.js | ||||
| packages/lib/hooks/useQueuedAsyncEffect.test.js | ||||
| packages/lib/hooks/useQueuedAsyncEffect.js | ||||
| packages/lib/htmlUtils.test.js | ||||
| packages/lib/htmlUtils.js | ||||
| packages/lib/htmlUtils2.test.js | ||||
|   | ||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -689,13 +689,14 @@ packages/app-mobile/components/screens/Note.test.js | ||||
| packages/app-mobile/components/screens/Note.js | ||||
| packages/app-mobile/components/screens/NoteTagsDialog.js | ||||
| packages/app-mobile/components/screens/Notes.js | ||||
| packages/app-mobile/components/screens/SearchScreen/SearchResults.js | ||||
| packages/app-mobile/components/screens/SearchScreen/index.js | ||||
| packages/app-mobile/components/screens/ShareManager/AcceptedShareItem.js | ||||
| packages/app-mobile/components/screens/ShareManager/IncomingShareItem.js | ||||
| packages/app-mobile/components/screens/ShareManager/index.test.js | ||||
| packages/app-mobile/components/screens/ShareManager/index.js | ||||
| packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js | ||||
| packages/app-mobile/components/screens/encryption-config.js | ||||
| packages/app-mobile/components/screens/search.js | ||||
| packages/app-mobile/components/screens/status.js | ||||
| packages/app-mobile/components/side-menu-content.js | ||||
| packages/app-mobile/components/voiceTyping/VoiceTypingDialog.js | ||||
| @@ -944,6 +945,8 @@ packages/lib/hooks/useElementSize.js | ||||
| packages/lib/hooks/useEventListener.js | ||||
| packages/lib/hooks/usePlugin.js | ||||
| packages/lib/hooks/usePrevious.js | ||||
| packages/lib/hooks/useQueuedAsyncEffect.test.js | ||||
| packages/lib/hooks/useQueuedAsyncEffect.js | ||||
| packages/lib/htmlUtils.test.js | ||||
| packages/lib/htmlUtils.js | ||||
| packages/lib/htmlUtils2.test.js | ||||
|   | ||||
| @@ -10,7 +10,6 @@ import Note from '@joplin/lib/models/Note'; | ||||
| import Folder from '@joplin/lib/models/Folder'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| import { OnValueChangedListener } from '../Dropdown'; | ||||
| const { dialogs } = require('../../utils/dialogs.js'); | ||||
| const DialogBox = require('react-native-dialogbox').default; | ||||
| import { FolderEntity } from '@joplin/lib/services/database/types'; | ||||
| import { State } from '@joplin/lib/reducer'; | ||||
| @@ -26,6 +25,7 @@ import WarningBanner from './WarningBanner'; | ||||
| import WebBetaButton from './WebBetaButton'; | ||||
|  | ||||
| import Menu, { MenuOptionType } from './Menu'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
| export { MenuOptionType }; | ||||
|  | ||||
| // Rather than applying a padding to the whole bar, it is applied to each | ||||
| @@ -48,8 +48,6 @@ interface ScreenHeaderProps { | ||||
| 	selectedFolderId: string; | ||||
| 	notesParentType: string; | ||||
| 	noteSelectionEnabled: boolean; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	parentComponent: any; | ||||
| 	showUndoButton: boolean; | ||||
| 	undoButtonDisabled?: boolean; | ||||
| 	showRedoButton: boolean; | ||||
| @@ -544,7 +542,7 @@ class ScreenHeaderComponent extends PureComponent<ScreenHeaderProps, ScreenHeade | ||||
|  | ||||
| 							const folder = await Folder.load(folderId); | ||||
|  | ||||
| 							const ok = noteIds.length > 1 ? await dialogs.confirm(this.props.parentComponent, _('Move %d notes to notebook "%s"?', noteIds.length, folder.title)) : true; | ||||
| 							const ok = noteIds.length > 1 ? await shim.showConfirmationDialog(_('Move %d notes to notebook "%s"?', noteIds.length, folder.title)) : true; | ||||
| 							if (!ok) return; | ||||
|  | ||||
| 							this.props.dispatch({ type: 'NOTE_SELECTION_END' }); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import * as React from 'react'; | ||||
| import { connect } from 'react-redux'; | ||||
| import NotesScreen from './screens/Notes'; | ||||
| import SearchScreen from './screens/search'; | ||||
| import SearchScreen from './screens/SearchScreen'; | ||||
| import { Component } from 'react'; | ||||
| import { KeyboardAvoidingView, Keyboard, Platform, View, KeyboardEvent, Dimensions, EmitterSubscription } from 'react-native'; | ||||
| import { AppState } from '../utils/types'; | ||||
|   | ||||
| @@ -11,10 +11,7 @@ class BaseScreenComponent<Props, State> extends React.Component<Props, State> { | ||||
| 		const theme = themeStyle(themeId); | ||||
| 		if (rootStyles_[themeId]) return rootStyles_[themeId]; | ||||
| 		rootStyles_[themeId] = StyleSheet.create({ | ||||
| 			root: { | ||||
| 				flex: 1, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 			}, | ||||
| 			root: theme.rootStyle, | ||||
| 		}); | ||||
| 		return rootStyles_[themeId]; | ||||
| 	} | ||||
|   | ||||
| @@ -29,6 +29,7 @@ export type ThemeStyle = BaseTheme & typeof baseStyle & { | ||||
| 	urlText: TextStyle; | ||||
| 	headerStyle: TextStyle; | ||||
| 	headerWrapperStyle: ViewStyle; | ||||
| 	rootStyle: ViewStyle; | ||||
| 	keyboardAppearance: 'light'|'dark'; | ||||
| }; | ||||
|  | ||||
| @@ -81,6 +82,11 @@ function extraStyles(theme: BaseTheme) { | ||||
| 		backgroundColor: theme.headerBackgroundColor, | ||||
| 	}; | ||||
|  | ||||
| 	const rootStyle: ViewStyle = { | ||||
| 		flex: 1, | ||||
| 		backgroundColor: theme.backgroundColor, | ||||
| 	}; | ||||
|  | ||||
| 	return { | ||||
| 		marginRight: baseStyle.margin, | ||||
| 		marginLeft: baseStyle.margin, | ||||
| @@ -94,6 +100,7 @@ function extraStyles(theme: BaseTheme) { | ||||
| 		urlText, | ||||
| 		headerStyle, | ||||
| 		headerWrapperStyle, | ||||
| 		rootStyle, | ||||
|  | ||||
| 		keyboardAppearance: theme.appearance, | ||||
| 		color5: theme.color5 ?? theme.backgroundColor4, | ||||
|   | ||||
| @@ -259,7 +259,6 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> { | ||||
| 		if (!buttonFolderId) buttonFolderId = this.props.activeFolderId; | ||||
|  | ||||
| 		const addFolderNoteButtons = !!buttonFolderId; | ||||
| 		const thisComp = this; | ||||
|  | ||||
| 		const makeActionButtonComp = () => { | ||||
| 			if ((this.props.notesParentType === 'Folder' && itemIsInTrash(parent)) || !Folder.atLeastOneRealFolderExists(this.props.folders)) return null; | ||||
| @@ -301,7 +300,7 @@ class NotesScreenComponent extends BaseScreenComponent<Props, State> { | ||||
|  | ||||
| 				inert={accessibilityHidden} | ||||
| 			> | ||||
| 				<ScreenHeader title={iconString + title} showBackButton={false} parentComponent={thisComp} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} /> | ||||
| 				<ScreenHeader title={iconString + title} showBackButton={false} sortButton_press={this.sortButton_press} folderPickerOptions={this.folderPickerOptions()} showSearchButton={true} showSideMenuButton={true} /> | ||||
| 				<NoteList /> | ||||
| 				{actionButtonComp} | ||||
| 				<DialogBox | ||||
|   | ||||
| @@ -0,0 +1,121 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| import { FlatList, View } from 'react-native'; | ||||
| import NoteItem from '../../NoteItem'; | ||||
| import { useEffect, useRef, useState } from 'react'; | ||||
| import useQueuedAsyncEffect from '@joplin/lib/hooks/useQueuedAsyncEffect'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import SearchEngineUtils from '@joplin/lib/services/search/SearchEngineUtils'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import SearchEngine from '@joplin/lib/services/search/SearchEngine'; | ||||
| import { ProgressBar } from 'react-native-paper'; | ||||
| import shim from '@joplin/lib/shim'; | ||||
|  | ||||
| interface Props { | ||||
| 	query: string; | ||||
| 	onHighlightedWordsChange: (highlightedWords: string[])=> void; | ||||
|  | ||||
| 	ftsEnabled: number; | ||||
| } | ||||
|  | ||||
| const useResults = (props: Props) => { | ||||
| 	const [notes, setNotes] = useState<NoteEntity[]>([]); | ||||
| 	const [isProcessing, setIsProcessing] = useState(false); | ||||
| 	const query = props.query; | ||||
| 	const ftsEnabled = props.ftsEnabled; | ||||
|  | ||||
| 	useQueuedAsyncEffect(async (event) => { | ||||
| 		let notes: NoteEntity[] = []; | ||||
| 		setIsProcessing(true); | ||||
| 		try { | ||||
| 			if (query) { | ||||
| 				if (ftsEnabled) { | ||||
| 					const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true }); | ||||
| 					notes = r.notes; | ||||
| 				} else { | ||||
| 					const p = query.split(' '); | ||||
| 					const temp = []; | ||||
| 					for (let i = 0; i < p.length; i++) { | ||||
| 						const t = p[i].trim(); | ||||
| 						if (!t) continue; | ||||
| 						temp.push(t); | ||||
| 					} | ||||
|  | ||||
| 					notes = await Note.previews(null, { | ||||
| 						anywherePattern: `*${temp.join('*')}*`, | ||||
| 					}); | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			if (event.cancelled) return; | ||||
|  | ||||
| 			const parsedQuery = await SearchEngine.instance().parseQuery(query); | ||||
| 			const highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery); | ||||
|  | ||||
| 			props.onHighlightedWordsChange(highlightedWords); | ||||
| 			setNotes(notes); | ||||
| 		} finally { | ||||
| 			setIsProcessing(false); | ||||
| 		} | ||||
| 	}, [query, ftsEnabled], { interval: 200 }); | ||||
|  | ||||
| 	return { | ||||
| 		notes, | ||||
| 		isPending: isProcessing, | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| const useIsLongRunning = (isPending: boolean) => { | ||||
| 	const [isLongRunning, setIsLongRunning] = useState(false); | ||||
| 	const isPendingRef = useRef(isPending); | ||||
| 	isPendingRef.current = isPending; | ||||
|  | ||||
| 	type TimeoutType = ReturnType<typeof shim.setTimeout>; | ||||
| 	const timeoutRef = useRef<TimeoutType|null>(null); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		if (timeoutRef.current !== null) { | ||||
| 			shim.clearTimeout(timeoutRef.current); | ||||
| 			timeoutRef.current = null; | ||||
| 		} | ||||
|  | ||||
| 		if (isPending) { | ||||
| 			const longRunningTimeout = 1000; | ||||
| 			timeoutRef.current = shim.setTimeout(() => { | ||||
| 				timeoutRef.current = null; | ||||
| 				setIsLongRunning(isPendingRef.current); | ||||
| 			}, longRunningTimeout); | ||||
| 		} else { | ||||
| 			setIsLongRunning(false); | ||||
| 		} | ||||
| 	}, [isPending]); | ||||
|  | ||||
| 	return isLongRunning; | ||||
| }; | ||||
|  | ||||
| const containerStyle = { flex: 1 }; | ||||
|  | ||||
| const SearchResults: React.FC<Props> = props => { | ||||
| 	const { notes, isPending } = useResults(props); | ||||
| 	// Don't show the progress bar immediately, only show if the search | ||||
| 	// is taking some time. | ||||
| 	const longRunning = useIsLongRunning(isPending); | ||||
|  | ||||
| 	// To have the correct height on web, the progress bar needs to be wrapped: | ||||
| 	const progressBar = <View> | ||||
| 		<ProgressBar indeterminate={true} visible={longRunning}/> | ||||
| 	</View>; | ||||
|  | ||||
| 	return ( | ||||
| 		<View style={containerStyle}> | ||||
| 			{progressBar} | ||||
| 			<FlatList | ||||
| 				data={notes} | ||||
| 				keyExtractor={(item) => item.id} | ||||
| 				renderItem={event => <NoteItem note={event.item} />} | ||||
| 			/> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| export default SearchResults; | ||||
							
								
								
									
										132
									
								
								packages/app-mobile/components/screens/SearchScreen/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								packages/app-mobile/components/screens/SearchScreen/index.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| import { StyleSheet, View, TextInput } from 'react-native'; | ||||
| import { connect } from 'react-redux'; | ||||
| import ScreenHeader from '../../ScreenHeader'; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import { ThemeStyle, themeStyle } from '../../global-style'; | ||||
| import { AppState } from '../../../utils/types'; | ||||
| import { Dispatch } from 'redux'; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | ||||
| import IconButton from '../../IconButton'; | ||||
| import SearchResults from './SearchResults'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	query: string; | ||||
| 	visible: boolean; | ||||
| 	dispatch: Dispatch; | ||||
|  | ||||
| 	noteSelectionEnabled: boolean; | ||||
| 	ftsEnabled: number; | ||||
| } | ||||
|  | ||||
| const useStyles = (theme: ThemeStyle) => { | ||||
| 	return useMemo(() => { | ||||
| 		return StyleSheet.create({ | ||||
| 			body: { | ||||
| 				flex: 1, | ||||
| 			}, | ||||
| 			searchContainer: { | ||||
| 				flexDirection: 'row', | ||||
| 				alignItems: 'center', | ||||
| 				borderWidth: 1, | ||||
| 				borderColor: theme.dividerColor, | ||||
| 			}, | ||||
| 			searchTextInput: { | ||||
| 				...theme.lineInput, | ||||
| 				paddingLeft: theme.marginLeft, | ||||
| 				flex: 1, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 				color: theme.color, | ||||
| 			}, | ||||
| 			clearIcon: { | ||||
| 				...theme.icon, | ||||
| 				color: theme.colorFaded, | ||||
| 				paddingRight: theme.marginRight, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 			}, | ||||
| 		}); | ||||
| 	}, [theme]); | ||||
| }; | ||||
|  | ||||
| const SearchScreenComponent: React.FC<Props> = props => { | ||||
| 	const theme = themeStyle(props.themeId); | ||||
| 	const styles = useStyles(theme); | ||||
|  | ||||
| 	const [query, setQuery] = useState(props.query); | ||||
|  | ||||
| 	const globalQueryRef = useRef(props.query); | ||||
| 	globalQueryRef.current = props.query; | ||||
| 	useEffect(() => { | ||||
| 		if (globalQueryRef.current !== query) { | ||||
| 			props.dispatch({ | ||||
| 				type: 'SEARCH_QUERY', | ||||
| 				query, | ||||
| 			}); | ||||
| 		} | ||||
| 	}, [props.dispatch, query]); | ||||
|  | ||||
| 	const clearButton_press = useCallback(() => { | ||||
| 		setQuery(''); | ||||
| 	}, []); | ||||
|  | ||||
| 	const onHighlightedWordsChange = useCallback((words: string[]) => { | ||||
| 		props.dispatch({ | ||||
| 			type: 'SET_HIGHLIGHTED', | ||||
| 			words, | ||||
| 		}); | ||||
| 	}, [props.dispatch]); | ||||
|  | ||||
| 	return ( | ||||
| 		<View style={theme.rootStyle}> | ||||
| 			<ScreenHeader | ||||
| 				title={_('Search')} | ||||
| 				folderPickerOptions={{ | ||||
| 					enabled: props.noteSelectionEnabled, | ||||
| 					mustSelect: true, | ||||
| 				}} | ||||
| 				showSideMenuButton={false} | ||||
| 				showSearchButton={false} | ||||
| 			/> | ||||
| 			<View style={styles.body}> | ||||
| 				<View style={styles.searchContainer}> | ||||
| 					<TextInput | ||||
| 						style={styles.searchTextInput} | ||||
| 						autoFocus={props.visible} | ||||
| 						underlineColorAndroid="#ffffff00" | ||||
| 						onChangeText={setQuery} | ||||
| 						value={query} | ||||
| 						selectionColor={theme.textSelectionColor} | ||||
| 						keyboardAppearance={theme.keyboardAppearance} | ||||
| 					/> | ||||
| 					<IconButton | ||||
| 						themeId={props.themeId} | ||||
| 						iconStyle={styles.clearIcon} | ||||
| 						iconName='ionicon close-circle' | ||||
| 						onPress={clearButton_press} | ||||
| 						description={_('Clear')} | ||||
| 					/> | ||||
| 				</View> | ||||
|  | ||||
| 				<SearchResults | ||||
| 					query={query} | ||||
| 					ftsEnabled={props.ftsEnabled} | ||||
| 					onHighlightedWordsChange={onHighlightedWordsChange} | ||||
| 				/> | ||||
| 			</View> | ||||
| 		</View> | ||||
| 	); | ||||
| }; | ||||
|  | ||||
| const SearchScreen = connect((state: AppState) => { | ||||
| 	return { | ||||
| 		query: state.searchQuery, | ||||
| 		themeId: state.settings.theme, | ||||
| 		settings: state.settings, | ||||
| 		noteSelectionEnabled: state.noteSelectionEnabled, | ||||
| 		ftsEnabled: state.settings['db.ftsEnabled'], | ||||
| 	}; | ||||
| })(SearchScreenComponent); | ||||
|  | ||||
| export default SearchScreen; | ||||
| @@ -56,7 +56,7 @@ function UpgradeSyncTargetScreen(props: any) { | ||||
|  | ||||
| 	return ( | ||||
| 		<ScrollView style={{ flex: 1, flexDirection: 'column', backgroundColor: theme.backgroundColor }}> | ||||
| 			<ScreenHeader title={_('Sync Target Upgrade')} parentComponent={this} showShouldUpgradeSyncTargetMessage={false} showSearchButton={false} showBackButton={upgradeResult.done}/> | ||||
| 			<ScreenHeader title={_('Sync Target Upgrade')} showShouldUpgradeSyncTargetMessage={false} showSearchButton={false} showBackButton={upgradeResult.done}/> | ||||
| 			<View style={{ padding: 15, flex: 1 }}> | ||||
| 				{renderInProgress()} | ||||
| 				{renderDone()} | ||||
|   | ||||
| @@ -1,237 +0,0 @@ | ||||
| import * as React from 'react'; | ||||
|  | ||||
| import { StyleSheet, View, TextInput, FlatList, TouchableHighlight } from 'react-native'; | ||||
| import { connect } from 'react-redux'; | ||||
| import ScreenHeader from '../ScreenHeader'; | ||||
| const Icon = require('react-native-vector-icons/Ionicons').default; | ||||
| import { _ } from '@joplin/lib/locale'; | ||||
| import Note from '@joplin/lib/models/Note'; | ||||
| import NoteItem from '../NoteItem'; | ||||
| import { BaseScreenComponent } from '../base-screen'; | ||||
| import { themeStyle } from '../global-style'; | ||||
| const DialogBox = require('react-native-dialogbox').default; | ||||
| import SearchEngineUtils from '@joplin/lib/services/search/SearchEngineUtils'; | ||||
| import SearchEngine from '@joplin/lib/services/search/SearchEngine'; | ||||
| import { AppState } from '../../utils/types'; | ||||
| import { NoteEntity } from '@joplin/lib/services/database/types'; | ||||
| import AsyncActionQueue from '@joplin/lib/AsyncActionQueue'; | ||||
| import { Dispatch } from 'redux'; | ||||
|  | ||||
| interface Props { | ||||
| 	themeId: number; | ||||
| 	query: string; | ||||
| 	visible: boolean; | ||||
| 	dispatch: Dispatch; | ||||
|  | ||||
| 	noteSelectionEnabled: boolean; | ||||
| 	ftsEnabled: number; | ||||
| } | ||||
|  | ||||
| interface State { | ||||
| 	query: string; | ||||
| 	notes: NoteEntity[]; | ||||
| } | ||||
|  | ||||
| class SearchScreenComponent extends BaseScreenComponent<Props, State> { | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactor of old code from before rule was applied. | ||||
| 	public dialogbox: any; | ||||
|  | ||||
| 	private isMounted_ = false; | ||||
| 	// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 	private styles_: Record<string, any> = {}; | ||||
| 	private searchActionQueue_ = new AsyncActionQueue(200); | ||||
|  | ||||
| 	public static navigationOptions() { | ||||
| 		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 		return { header: null } as any; | ||||
| 	} | ||||
|  | ||||
| 	public constructor(props: Props) { | ||||
| 		super(props); | ||||
| 		this.state = { | ||||
| 			query: '', | ||||
| 			notes: [], | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
| 	public styles() { | ||||
| 		const theme = themeStyle(this.props.themeId); | ||||
|  | ||||
| 		if (this.styles_[this.props.themeId]) return this.styles_[this.props.themeId]; | ||||
| 		this.styles_ = {}; | ||||
|  | ||||
| 		const styleSheet = StyleSheet.create({ | ||||
| 			body: { | ||||
| 				flex: 1, | ||||
| 			}, | ||||
| 			searchContainer: { | ||||
| 				flexDirection: 'row', | ||||
| 				alignItems: 'center', | ||||
| 				borderWidth: 1, | ||||
| 				borderColor: theme.dividerColor, | ||||
| 			}, | ||||
| 			searchTextInput: { | ||||
| 				...theme.lineInput, | ||||
| 				paddingLeft: theme.marginLeft, | ||||
| 				flex: 1, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 				color: theme.color, | ||||
| 			}, | ||||
| 			clearIcon: { | ||||
| 				...theme.icon, | ||||
| 				color: theme.colorFaded, | ||||
| 				paddingRight: theme.marginRight, | ||||
| 				backgroundColor: theme.backgroundColor, | ||||
| 			}, | ||||
| 		}); | ||||
| 		this.styles_[this.props.themeId] = styleSheet; | ||||
| 		return styleSheet; | ||||
| 	} | ||||
|  | ||||
| 	public componentDidMount() { | ||||
| 		this.setState({ query: this.props.query }); | ||||
| 		void this.refreshSearch(this.props.query); | ||||
| 		this.isMounted_ = true; | ||||
| 	} | ||||
|  | ||||
| 	public componentWillUnmount() { | ||||
| 		this.isMounted_ = false; | ||||
| 	} | ||||
|  | ||||
| 	private clearButton_press() { | ||||
| 		this.props.dispatch({ | ||||
| 			type: 'SEARCH_QUERY', | ||||
| 			query: '', | ||||
| 		}); | ||||
|  | ||||
| 		this.setState({ query: '' }); | ||||
| 		void this.refreshSearch(''); | ||||
| 	} | ||||
|  | ||||
| 	public async refreshSearch(query: string = null) { | ||||
| 		if (!this.props.visible) return; | ||||
|  | ||||
| 		let notes: NoteEntity[] = []; | ||||
|  | ||||
| 		if (query) { | ||||
| 			if (this.props.ftsEnabled) { | ||||
| 				const r = await SearchEngineUtils.notesForQuery(query, true, { appendWildCards: true }); | ||||
| 				notes = r.notes; | ||||
| 			} else { | ||||
| 				const p = query.split(' '); | ||||
| 				const temp = []; | ||||
| 				for (let i = 0; i < p.length; i++) { | ||||
| 					const t = p[i].trim(); | ||||
| 					if (!t) continue; | ||||
| 					temp.push(t); | ||||
| 				} | ||||
|  | ||||
| 				notes = await Note.previews(null, { | ||||
| 					anywherePattern: `*${temp.join('*')}*`, | ||||
| 				}); | ||||
| 			} | ||||
| 		} | ||||
|  | ||||
| 		if (!this.isMounted_) return; | ||||
|  | ||||
| 		const parsedQuery = await SearchEngine.instance().parseQuery(query); | ||||
| 		const highlightedWords = SearchEngine.instance().allParsedQueryTerms(parsedQuery); | ||||
|  | ||||
| 		this.props.dispatch({ | ||||
| 			type: 'SET_HIGHLIGHTED', | ||||
| 			words: highlightedWords, | ||||
| 		}); | ||||
|  | ||||
| 		this.setState({ notes: notes }); | ||||
| 	} | ||||
|  | ||||
| 	public scheduleSearch() { | ||||
| 		this.searchActionQueue_.push(() => this.refreshSearch(this.state.query)); | ||||
| 	} | ||||
|  | ||||
| 	public onComponentWillUnmount() { | ||||
| 		void this.searchActionQueue_.reset(); | ||||
| 	} | ||||
|  | ||||
| 	private searchTextInput_changeText(text: string) { | ||||
| 		this.setState({ query: text }); | ||||
|  | ||||
| 		this.props.dispatch({ | ||||
| 			type: 'SEARCH_QUERY', | ||||
| 			query: text, | ||||
| 		}); | ||||
|  | ||||
| 		this.scheduleSearch(); | ||||
| 	} | ||||
|  | ||||
| 	public render() { | ||||
| 		if (!this.isMounted_) return null; | ||||
|  | ||||
| 		const theme = themeStyle(this.props.themeId); | ||||
|  | ||||
| 		const rootStyle = { | ||||
| 			flex: 1, | ||||
| 			backgroundColor: theme.backgroundColor, | ||||
| 		}; | ||||
|  | ||||
| 		if (!this.props.visible) { | ||||
| 			rootStyle.flex = 0.001; // This is a bit of a hack but it seems to work fine - it makes the component invisible but without unmounting it | ||||
| 		} | ||||
|  | ||||
| 		const thisComponent = this; | ||||
|  | ||||
| 		return ( | ||||
| 			<View style={rootStyle}> | ||||
| 				<ScreenHeader | ||||
| 					title={_('Search')} | ||||
| 					parentComponent={thisComponent} | ||||
| 					folderPickerOptions={{ | ||||
| 						enabled: this.props.noteSelectionEnabled, | ||||
| 						mustSelect: true, | ||||
| 					}} | ||||
| 					showSideMenuButton={false} | ||||
| 					showSearchButton={false} | ||||
| 				/> | ||||
| 				<View style={this.styles().body}> | ||||
| 					<View style={this.styles().searchContainer}> | ||||
| 						<TextInput | ||||
| 							style={this.styles().searchTextInput} | ||||
| 							autoFocus={this.props.visible} | ||||
| 							underlineColorAndroid="#ffffff00" | ||||
| 							onChangeText={text => this.searchTextInput_changeText(text)} | ||||
| 							value={this.state.query} | ||||
| 							selectionColor={theme.textSelectionColor} | ||||
| 							keyboardAppearance={theme.keyboardAppearance} | ||||
| 						/> | ||||
| 						<TouchableHighlight | ||||
| 							onPress={() => this.clearButton_press()} | ||||
| 							accessibilityLabel={_('Clear')} | ||||
| 						> | ||||
| 							<Icon name="close-circle" style={this.styles().clearIcon} /> | ||||
| 						</TouchableHighlight> | ||||
| 					</View> | ||||
|  | ||||
| 					<FlatList data={this.state.notes} keyExtractor={(item) => item.id} renderItem={event => <NoteItem note={event.item} />} /> | ||||
| 				</View> | ||||
| 				<DialogBox | ||||
| 					// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied | ||||
| 					ref={(dialogbox: any) => { | ||||
| 						this.dialogbox = dialogbox; | ||||
| 					}} | ||||
| 				/> | ||||
| 			</View> | ||||
| 		); | ||||
| 	} | ||||
| } | ||||
|  | ||||
| const SearchScreen = connect((state: AppState) => { | ||||
| 	return { | ||||
| 		query: state.searchQuery, | ||||
| 		themeId: state.settings.theme, | ||||
| 		settings: state.settings, | ||||
| 		noteSelectionEnabled: state.noteSelectionEnabled, | ||||
| 		ftsEnabled: state.settings['db.ftsEnabled'], | ||||
| 	}; | ||||
| })(SearchScreenComponent); | ||||
|  | ||||
| export default SearchScreen; | ||||
| @@ -61,7 +61,7 @@ import ConfigScreen from './components/screens/ConfigScreen/ConfigScreen'; | ||||
| const { FolderScreen } = require('./components/screens/folder.js'); | ||||
| import LogScreen from './components/screens/LogScreen'; | ||||
| import StatusScreen from './components/screens/status'; | ||||
| import SearchScreen from './components/screens/search'; | ||||
| import SearchScreen from './components/screens/SearchScreen'; | ||||
| const { OneDriveLoginScreen } = require('./components/screens/onedrive-login.js'); | ||||
| import EncryptionConfigScreen from './components/screens/encryption-config'; | ||||
| const { DropboxLoginScreen } = require('./components/screens/dropbox-login.js'); | ||||
|   | ||||
							
								
								
									
										35
									
								
								packages/lib/hooks/useQueuedAsyncEffect.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								packages/lib/hooks/useQueuedAsyncEffect.test.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import type * as React from 'react'; | ||||
| import { renderHook } from '@testing-library/react-hooks'; | ||||
| import useQueuedAsyncEffect from './useQueuedAsyncEffect'; | ||||
| import { runWithFakeTimers } from '../testing/test-utils'; | ||||
|  | ||||
| describe('useQueuedAsyncEffect', () => { | ||||
| 	test('should debounce effect updates', async () => { | ||||
| 		const effectFunction = jest.fn(async () => { }); | ||||
| 		const useTestHook = (dependencies: React.DependencyList) => { | ||||
| 			return useQueuedAsyncEffect(effectFunction, dependencies); | ||||
| 		}; | ||||
|  | ||||
| 		await runWithFakeTimers(async () => { | ||||
| 			const result = renderHook(useTestHook, { initialProps: ['test'] }); | ||||
|  | ||||
| 			// Should pause to allow debouncing. | ||||
| 			expect(effectFunction).not.toHaveBeenCalled(); | ||||
|  | ||||
| 			await jest.advanceTimersByTimeAsync(12500); | ||||
| 			expect(effectFunction).toHaveBeenCalledTimes(1); | ||||
|  | ||||
| 			await jest.advanceTimersByTimeAsync(1000); | ||||
|  | ||||
| 			// Changing twice quickly: Should only update once | ||||
| 			result.rerender(['changed']); | ||||
| 			expect(effectFunction).toHaveBeenCalledTimes(1); | ||||
| 			result.rerender(['changed again']); | ||||
| 			await jest.advanceTimersByTimeAsync(500); | ||||
| 			expect(effectFunction).toHaveBeenCalledTimes(2); | ||||
|  | ||||
| 			await jest.advanceTimersByTimeAsync(500); | ||||
| 			expect(effectFunction).toHaveBeenCalledTimes(2); | ||||
| 		}); | ||||
| 	}); | ||||
| }); | ||||
							
								
								
									
										36
									
								
								packages/lib/hooks/useQueuedAsyncEffect.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								packages/lib/hooks/useQueuedAsyncEffect.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,36 @@ | ||||
| import type * as React from 'react'; | ||||
| import shim from '../shim'; | ||||
| import AsyncActionQueue from '../AsyncActionQueue'; | ||||
| const { useEffect, useState } = shim.react(); | ||||
|  | ||||
| interface AsyncEffectEvent { | ||||
| 	cancelled: boolean; | ||||
| } | ||||
|  | ||||
| export type EffectFunction = (event: AsyncEffectEvent)=> void|Promise<void>; | ||||
|  | ||||
| export interface Options { | ||||
| 	interval?: number; | ||||
| } | ||||
|  | ||||
| export default ( | ||||
| 	effect: EffectFunction, | ||||
| 	dependencies: React.DependencyList, | ||||
| 	{ interval = undefined }: Options = {}, | ||||
| ) => { | ||||
| 	const [queue] = useState(() => new AsyncActionQueue(interval)); | ||||
| 	useEffect(() => { | ||||
| 		const event: AsyncEffectEvent = { cancelled: false }; | ||||
| 		queue.push(() => effect(event)); | ||||
| 		return () => { | ||||
| 			event.cancelled = true; | ||||
| 		}; | ||||
| 		// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- This is a custom hook | ||||
| 	}, dependencies); | ||||
|  | ||||
| 	useEffect(() => { | ||||
| 		return () => { | ||||
| 			void queue.reset(); | ||||
| 		}; | ||||
| 	}, [queue]); | ||||
| }; | ||||
| @@ -1092,16 +1092,32 @@ export const mockMobilePlatform = (platform: string) => { | ||||
| 	}; | ||||
| }; | ||||
|  | ||||
| export const runWithFakeTimers = (callback: ()=> Promise<void>) => { | ||||
| export const runWithFakeTimers = async (callback: ()=> Promise<void>) => { | ||||
| 	if (typeof jest === 'undefined') { | ||||
| 		throw new Error('Fake timers are only supported in jest.'); | ||||
| 	} | ||||
|  | ||||
| 	jest.useFakeTimers(); | ||||
|  | ||||
| 	// The shim.setTimeout and similar functions need to be changed to | ||||
| 	// use fake timers. | ||||
| 	const originalSetTimeout = shim.setTimeout; | ||||
| 	const originalSetInterval = shim.setInterval; | ||||
| 	const originalClearTimeout = shim.clearTimeout; | ||||
| 	const originalClearInterval = shim.clearInterval; | ||||
| 	shim.setTimeout = setTimeout; | ||||
| 	shim.setInterval = setInterval; | ||||
| 	shim.clearInterval = clearInterval; | ||||
| 	shim.clearTimeout = clearTimeout; | ||||
|  | ||||
| 	try { | ||||
| 		return callback(); | ||||
| 		return await callback(); | ||||
| 	} finally { | ||||
| 		jest.runOnlyPendingTimers(); | ||||
| 		shim.setTimeout = originalSetTimeout; | ||||
| 		shim.setInterval = originalSetInterval; | ||||
| 		shim.clearTimeout = originalClearTimeout; | ||||
| 		shim.clearInterval = originalClearInterval; | ||||
| 		jest.useRealTimers(); | ||||
| 	} | ||||
| }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user