diff options
Diffstat (limited to 'src/view/screens/Search/Search.tsx')
-rw-r--r-- | src/view/screens/Search/Search.tsx | 315 |
1 files changed, 256 insertions, 59 deletions
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx index b522edfba..df64cc5aa 100644 --- a/src/view/screens/Search/Search.tsx +++ b/src/view/screens/Search/Search.tsx @@ -42,11 +42,17 @@ import {useSetDrawerOpen} from '#/state/shell' import {useAnalytics} from '#/lib/analytics/analytics' import {MagnifyingGlassIcon} from '#/lib/icons' import {useModerationOpts} from '#/state/queries/preferences' -import {SearchResultCard} from '#/view/shell/desktop/Search' +import { + MATCH_HANDLE, + SearchLinkCard, + SearchProfileCard, +} from '#/view/shell/desktop/Search' import {useSetMinimalShellMode, useSetDrawerSwipeDisabled} from '#/state/shell' -import {isWeb} from '#/platform/detection' +import {isNative, isWeb} from '#/platform/detection' import {listenSoftReset} from '#/state/events' import {s} from '#/lib/styles' +import AsyncStorage from '@react-native-async-storage/async-storage' +import {augmentSearchQuery} from '#/lib/strings/helpers' function Loader() { const pal = usePalette('default') @@ -83,9 +89,7 @@ function EmptyState({message, error}: {message: string; error?: string}) { }, ]}> <View style={[pal.viewLight, {padding: 18, borderRadius: 8}]}> - <Text style={[pal.text]}> - <Trans>{message}</Trans> - </Text> + <Text style={[pal.text]}>{message}</Text> {error && ( <> @@ -162,6 +166,8 @@ function SearchScreenSuggestedFollows() { // @ts-ignore web only -prf desktopFixedHeight contentContainerStyle={{paddingBottom: 1200}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag" /> ) : ( <CenteredView sideBorders style={[pal.border, s.hContentRegion]}> @@ -303,13 +309,23 @@ function SearchScreenUserResults({query}: {query: string}) { const SECTIONS_LOGGEDOUT = ['Users'] const SECTIONS_LOGGEDIN = ['Posts', 'Users'] -export function SearchScreenInner({query}: {query?: string}) { +export function SearchScreenInner({ + query, + primarySearch, +}: { + query?: string + primarySearch?: boolean +}) { const pal = usePalette('default') const setMinimalShellMode = useSetMinimalShellMode() const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() - const {hasSession} = useSession() + const {hasSession, currentAccount} = useSession() const {isDesktop} = useWebMediaQueries() + const augmentedQuery = React.useMemo(() => { + return augmentSearchQuery(query || '', {did: currentAccount?.did}) + }, [query, currentAccount]) + const onPageSelected = React.useCallback( (index: number) => { setMinimalShellMode(false) @@ -324,13 +340,15 @@ export function SearchScreenInner({query}: {query?: string}) { tabBarPosition="top" onPageSelected={onPageSelected} renderTabBar={props => ( - <CenteredView sideBorders style={pal.border}> + <CenteredView + sideBorders + style={[pal.border, pal.view, styles.tabBarContainer]}> <TabBar items={SECTIONS_LOGGEDIN} {...props} /> </CenteredView> )} initialPage={0}> <View> - <SearchScreenPostResults query={query} /> + <SearchScreenPostResults query={augmentedQuery} /> </View> <View> <SearchScreenUserResults query={query} /> @@ -365,7 +383,9 @@ export function SearchScreenInner({query}: {query?: string}) { tabBarPosition="top" onPageSelected={onPageSelected} renderTabBar={props => ( - <CenteredView sideBorders style={pal.border}> + <CenteredView + sideBorders + style={[pal.border, pal.view, styles.tabBarContainer]}> <TabBar items={SECTIONS_LOGGEDOUT} {...props} /> </CenteredView> )} @@ -413,7 +433,7 @@ export function SearchScreenInner({query}: {query?: string}) { style={pal.textLight} /> <Text type="xl" style={[pal.textLight, {paddingHorizontal: 18}]}> - {isDesktop ? ( + {isDesktop && !primarySearch ? ( <Trans>Find users with the search tool on the right</Trans> ) : ( <Trans>Find users on Bluesky</Trans> @@ -425,19 +445,7 @@ export function SearchScreenInner({query}: {query?: string}) { ) } -export function SearchScreenDesktop( - props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, -) { - const {isDesktop} = useWebMediaQueries() - - return isDesktop ? ( - <SearchScreenInner query={props.route.params?.q} /> - ) : ( - <SearchScreenMobile {...props} /> - ) -} - -export function SearchScreenMobile( +export function SearchScreen( props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, ) { const theme = useTheme() @@ -449,7 +457,7 @@ export function SearchScreenMobile( const moderationOpts = useModerationOpts() const search = useActorAutocompleteFn() const setMinimalShellMode = useSetMinimalShellMode() - const {isTablet} = useWebMediaQueries() + const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() const searchDebounceTimeout = React.useRef<NodeJS.Timeout | undefined>( undefined, @@ -462,32 +470,56 @@ export function SearchScreenMobile( const [inputIsFocused, setInputIsFocused] = React.useState(false) const [showAutocompleteResults, setShowAutocompleteResults] = React.useState(false) + const [searchHistory, setSearchHistory] = React.useState<string[]>([]) + + React.useEffect(() => { + const loadSearchHistory = async () => { + try { + const history = await AsyncStorage.getItem('searchHistory') + if (history !== null) { + setSearchHistory(JSON.parse(history)) + } + } catch (e: any) { + logger.error('Failed to load search history', e) + } + } + + loadSearchHistory() + }, []) const onPressMenu = React.useCallback(() => { track('ViewHeader:MenuButtonClicked') setDrawerOpen(true) }, [track, setDrawerOpen]) + const onPressCancelSearch = React.useCallback(() => { + scrollToTopWeb() textInput.current?.blur() setQuery('') setShowAutocompleteResults(false) if (searchDebounceTimeout.current) clearTimeout(searchDebounceTimeout.current) }, [textInput]) + const onPressClearQuery = React.useCallback(() => { + scrollToTopWeb() setQuery('') setShowAutocompleteResults(false) }, [setQuery]) + const onChangeText = React.useCallback( async (text: string) => { + scrollToTopWeb() + setQuery(text) if (text.length > 0) { setIsFetching(true) setShowAutocompleteResults(true) - if (searchDebounceTimeout.current) + if (searchDebounceTimeout.current) { clearTimeout(searchDebounceTimeout.current) + } searchDebounceTimeout.current = setTimeout(async () => { const results = await search({query: text, limit: 30}) @@ -498,8 +530,9 @@ export function SearchScreenMobile( } }, 300) } else { - if (searchDebounceTimeout.current) + if (searchDebounceTimeout.current) { clearTimeout(searchDebounceTimeout.current) + } setSearchResults([]) setIsFetching(false) setShowAutocompleteResults(false) @@ -507,14 +540,47 @@ export function SearchScreenMobile( }, [setQuery, search, setSearchResults], ) + + const updateSearchHistory = React.useCallback( + async (newQuery: string) => { + newQuery = newQuery.trim() + if (newQuery && !searchHistory.includes(newQuery)) { + let newHistory = [newQuery, ...searchHistory] + + if (newHistory.length > 5) { + newHistory = newHistory.slice(0, 5) + } + + setSearchHistory(newHistory) + try { + await AsyncStorage.setItem( + 'searchHistory', + JSON.stringify(newHistory), + ) + } catch (e: any) { + logger.error('Failed to save search history', e) + } + } + }, + [searchHistory, setSearchHistory], + ) + const onSubmit = React.useCallback(() => { + scrollToTopWeb() setShowAutocompleteResults(false) - }, [setShowAutocompleteResults]) + updateSearchHistory(query) + }, [query, setShowAutocompleteResults, updateSearchHistory]) const onSoftReset = React.useCallback(() => { + scrollToTopWeb() onPressCancelSearch() }, [onPressCancelSearch]) + const queryMaybeHandle = React.useMemo(() => { + const match = MATCH_HANDLE.exec(query) + return match && match[1] + }, [query]) + useFocusEffect( React.useCallback(() => { setMinimalShellMode(false) @@ -522,19 +588,47 @@ export function SearchScreenMobile( }, [onSoftReset, setMinimalShellMode]), ) + const handleHistoryItemClick = (item: React.SetStateAction<string>) => { + setQuery(item) + onSubmit() + } + + const handleRemoveHistoryItem = (itemToRemove: string) => { + const updatedHistory = searchHistory.filter(item => item !== itemToRemove) + setSearchHistory(updatedHistory) + AsyncStorage.setItem('searchHistory', JSON.stringify(updatedHistory)).catch( + e => { + logger.error('Failed to update search history', e) + }, + ) + } + return ( - <View style={{flex: 1}}> - <CenteredView style={[styles.header, pal.border]} sideBorders={isTablet}> - <Pressable - testID="viewHeaderBackOrMenuBtn" - onPress={onPressMenu} - hitSlop={HITSLOP_10} - style={styles.headerMenuBtn} - accessibilityRole="button" - accessibilityLabel={_(msg`Menu`)} - accessibilityHint="Access navigation links and settings"> - <FontAwesomeIcon icon="bars" size={18} color={pal.colors.textLight} /> - </Pressable> + <View style={isWeb ? null : {flex: 1}}> + <CenteredView + style={[ + styles.header, + pal.border, + pal.view, + isTabletOrDesktop && {paddingTop: 10}, + ]} + sideBorders={isTabletOrDesktop}> + {isTabletOrMobile && ( + <Pressable + testID="viewHeaderBackOrMenuBtn" + onPress={onPressMenu} + hitSlop={HITSLOP_10} + style={styles.headerMenuBtn} + accessibilityRole="button" + accessibilityLabel={_(msg`Menu`)} + accessibilityHint={_(msg`Access navigation links and settings`)}> + <FontAwesomeIcon + icon="bars" + size={18} + color={pal.colors.textLight} + /> + </Pressable> + )} <View style={[ @@ -548,7 +642,7 @@ export function SearchScreenMobile( <TextInput testID="searchTextInput" ref={textInput} - placeholder="Search" + placeholder={_(msg`Search`)} placeholderTextColor={pal.colors.textLight} selectTextOnFocus returnKeyType="search" @@ -556,7 +650,12 @@ export function SearchScreenMobile( style={[pal.text, styles.headerSearchInput]} keyboardAppearance={theme.colorScheme} onFocus={() => setInputIsFocused(true)} - onBlur={() => setInputIsFocused(false)} + onBlur={() => { + // HACK + // give 100ms to not stop click handlers in the search history + // -prf + setTimeout(() => setInputIsFocused(false), 100) + }} onChangeText={onChangeText} onSubmitEditing={onSubmit} autoFocus={false} @@ -564,6 +663,7 @@ export function SearchScreenMobile( accessibilityLabel={_(msg`Search`)} accessibilityHint="" autoCorrect={false} + autoComplete="off" autoCapitalize="none" /> {query ? ( @@ -572,7 +672,8 @@ export function SearchScreenMobile( onPress={onPressClearQuery} accessibilityRole="button" accessibilityLabel={_(msg`Clear search query`)} - accessibilityHint=""> + accessibilityHint="" + hitSlop={HITSLOP_10}> <FontAwesomeIcon icon="xmark" size={16} @@ -584,7 +685,10 @@ export function SearchScreenMobile( {query || inputIsFocused ? ( <View style={styles.headerCancelBtn}> - <Pressable onPress={onPressCancelSearch} accessibilityRole="button"> + <Pressable + onPress={onPressCancelSearch} + accessibilityRole="button" + hitSlop={HITSLOP_10}> <Text style={[pal.text]}> <Trans>Cancel</Trans> </Text> @@ -593,29 +697,83 @@ export function SearchScreenMobile( ) : undefined} </CenteredView> - {showAutocompleteResults && moderationOpts ? ( + {showAutocompleteResults ? ( <> - {isFetching ? ( + {isFetching || !moderationOpts ? ( <Loader /> ) : ( - <ScrollView style={{height: '100%'}}> - {searchResults.length ? ( - searchResults.map((item, i) => ( - <SearchResultCard - key={item.did} - profile={item} - moderation={moderateProfile(item, moderationOpts)} - style={i === 0 ? {borderTopWidth: 0} : {}} - /> - )) - ) : ( - <EmptyState message={_(msg`No results found for ${query}`)} /> - )} + <ScrollView + style={{height: '100%'}} + // @ts-ignore web only -prf + dataSet={{stableGutters: '1'}} + keyboardShouldPersistTaps="handled" + keyboardDismissMode="on-drag"> + <SearchLinkCard + label={_(msg`Search for "${query}"`)} + onPress={isNative ? onSubmit : undefined} + to={ + isNative + ? undefined + : `/search?q=${encodeURIComponent(query)}` + } + style={{borderBottomWidth: 1}} + /> + + {queryMaybeHandle ? ( + <SearchLinkCard + label={_(msg`Go to @${queryMaybeHandle}`)} + to={`/profile/${queryMaybeHandle}`} + /> + ) : null} + + {searchResults.map(item => ( + <SearchProfileCard + key={item.did} + profile={item} + moderation={moderateProfile(item, moderationOpts)} + /> + ))} <View style={{height: 200}} /> </ScrollView> )} </> + ) : !query && inputIsFocused ? ( + <CenteredView + sideBorders={isTabletOrDesktop} + // @ts-ignore web only -prf + style={{ + height: isWeb ? '100vh' : undefined, + }}> + <View style={styles.searchHistoryContainer}> + {searchHistory.length > 0 && ( + <View style={styles.searchHistoryContent}> + <Text style={[pal.text, styles.searchHistoryTitle]}> + Recent Searches + </Text> + {searchHistory.map((historyItem, index) => ( + <View key={index} style={styles.historyItemContainer}> + <Pressable + accessibilityRole="button" + onPress={() => handleHistoryItemClick(historyItem)} + style={styles.historyItem}> + <Text style={pal.text}>{historyItem}</Text> + </Pressable> + <Pressable + accessibilityRole="button" + onPress={() => handleRemoveHistoryItem(historyItem)}> + <FontAwesomeIcon + icon="xmark" + size={16} + style={pal.textLight as FontAwesomeIconStyle} + /> + </Pressable> + </View> + ))} + </View> + )} + </View> + </CenteredView> ) : ( <SearchScreenInner query={query} /> )} @@ -623,12 +781,25 @@ export function SearchScreenMobile( ) } +function scrollToTopWeb() { + if (isWeb) { + window.scrollTo(0, 0) + } +} + +const HEADER_HEIGHT = 50 + const styles = StyleSheet.create({ header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 12, paddingVertical: 4, + height: HEADER_HEIGHT, + // @ts-ignore web only + position: isWeb ? 'sticky' : '', + top: 0, + zIndex: 1, }, headerMenuBtn: { width: 30, @@ -658,4 +829,30 @@ const styles = StyleSheet.create({ headerCancelBtn: { paddingLeft: 10, }, + tabBarContainer: { + // @ts-ignore web only + position: isWeb ? 'sticky' : '', + top: isWeb ? HEADER_HEIGHT : 0, + zIndex: 1, + }, + searchHistoryContainer: { + width: '100%', + paddingHorizontal: 12, + }, + searchHistoryContent: { + padding: 10, + borderRadius: 8, + }, + searchHistoryTitle: { + fontWeight: 'bold', + }, + historyItem: { + paddingVertical: 8, + }, + historyItemContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 8, + }, }) |