Bluesky app fork with some witchin' additions 💫

Add language filtering UI to search (#5459)

* Use new TextField for search bar

* Add lang dropdown

* Dialog

* Revert "Dialog"

This reverts commit 257573cd9c2a70d29df4ef5bdd503eea4ae411fe.

* Extract util, test, cleanup

* Fix formatting

* Pass through other params

* Fix sticky header

* Fix stale data, hide/show

* Improve query parsing

* Replace memo

* Couple tweaks

* Revert cancel change

* Remove unused placeholder

authored by

Eric Bailey and committed by
GitHub
b1ca2503 6bc001a3

+427 -149
+3
src/alf/themes.ts
··· 305 } as const 306 307 const light: Theme = { 308 name: 'light', 309 palette: lightPalette, 310 atoms: { ··· 390 } 391 392 const dark: Theme = { 393 name: 'dark', 394 palette: darkPalette, 395 atoms: { ··· 479 480 const dim: Theme = { 481 ...dark, 482 name: 'dim', 483 palette: dimPalette, 484 atoms: {
··· 305 } as const 306 307 const light: Theme = { 308 + scheme: 'light', 309 name: 'light', 310 palette: lightPalette, 311 atoms: { ··· 391 } 392 393 const dark: Theme = { 394 + scheme: 'dark', 395 name: 'dark', 396 palette: darkPalette, 397 atoms: { ··· 481 482 const dim: Theme = { 483 ...dark, 484 + scheme: 'dark', 485 name: 'dim', 486 palette: dimPalette, 487 atoms: {
+1
src/alf/types.ts
··· 156 } 157 } 158 export type Theme = { 159 name: ThemeName 160 palette: Palette 161 atoms: ThemedAtoms
··· 156 } 157 } 158 export type Theme = { 159 + scheme: 'light' | 'dark' // for library support 160 name: ThemeName 161 palette: Palette 162 atoms: ThemedAtoms
+16 -7
src/components/forms/TextField.tsx
··· 135 placeholder, 136 value, 137 onChangeText, 138 isInvalid, 139 inputRef, 140 style, ··· 173 ref={refs} 174 value={value} 175 onChangeText={onChangeText} 176 - onFocus={ctx.onFocus} 177 - onBlur={ctx.onBlur} 178 placeholder={placeholder || label} 179 placeholderTextColor={t.palette.contrast_500} 180 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} ··· 188 a.px_xs, 189 { 190 // paddingVertical doesn't work w/multiline - esb 191 - paddingTop: 14, 192 - paddingBottom: 14, 193 lineHeight: a.text_md.fontSize * 1.1875, 194 textAlignVertical: rest.multiline ? 'top' : undefined, 195 minHeight: rest.multiline ? 80 : undefined, ··· 197 }, 198 // fix for autofill styles covering border 199 web({ 200 - paddingTop: 12, 201 - paddingBottom: 12, 202 marginTop: 2, 203 marginBottom: 2, 204 }), 205 android({ 206 - paddingBottom: 16, 207 }), 208 style, 209 ]}
··· 135 placeholder, 136 value, 137 onChangeText, 138 + onFocus, 139 + onBlur, 140 isInvalid, 141 inputRef, 142 style, ··· 175 ref={refs} 176 value={value} 177 onChangeText={onChangeText} 178 + onFocus={e => { 179 + ctx.onFocus() 180 + onFocus?.(e) 181 + }} 182 + onBlur={e => { 183 + ctx.onBlur() 184 + onBlur?.(e) 185 + }} 186 placeholder={placeholder || label} 187 placeholderTextColor={t.palette.contrast_500} 188 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} ··· 196 a.px_xs, 197 { 198 // paddingVertical doesn't work w/multiline - esb 199 + paddingTop: 12, 200 + paddingBottom: 13, 201 lineHeight: a.text_md.fontSize * 1.1875, 202 textAlignVertical: rest.multiline ? 'top' : undefined, 203 minHeight: rest.multiline ? 80 : undefined, ··· 205 }, 206 // fix for autofill styles covering border 207 web({ 208 + paddingTop: 10, 209 + paddingBottom: 11, 210 marginTop: 2, 211 marginBottom: 2, 212 }), 213 android({ 214 + paddingTop: 8, 215 + paddingBottom: 8, 216 }), 217 style, 218 ]}
+43
src/screens/Search/__tests__/utils.test.ts
···
··· 1 + import {describe, expect, it} from '@jest/globals' 2 + 3 + import {parseSearchQuery} from '#/screens/Search/utils' 4 + 5 + describe(`parseSearchQuery`, () => { 6 + const tests = [ 7 + { 8 + input: `bluesky`, 9 + output: {query: `bluesky`, params: {}}, 10 + }, 11 + { 12 + input: `bluesky from:esb.lol`, 13 + output: {query: `bluesky`, params: {from: `esb.lol`}}, 14 + }, 15 + { 16 + input: `bluesky "from:esb.lol"`, 17 + output: {query: `bluesky "from:esb.lol"`, params: {}}, 18 + }, 19 + { 20 + input: `bluesky mentions:@esb.lol`, 21 + output: {query: `bluesky`, params: {mentions: `@esb.lol`}}, 22 + }, 23 + { 24 + input: `bluesky since:2021-01-01:00:00:00`, 25 + output: {query: `bluesky`, params: {since: `2021-01-01:00:00:00`}}, 26 + }, 27 + { 28 + input: `bluesky lang:"en"`, 29 + output: {query: `bluesky`, params: {lang: `en`}}, 30 + }, 31 + { 32 + input: `bluesky "literal" lang:en "from:invalid"`, 33 + output: {query: `bluesky "literal" "from:invalid"`, params: {lang: `en`}}, 34 + }, 35 + ] 36 + 37 + it.each(tests)( 38 + `$input -> $output.query $output.params`, 39 + ({input, output}) => { 40 + expect(parseSearchQuery(input)).toEqual(output) 41 + }, 42 + ) 43 + })
+43
src/screens/Search/utils.ts
···
··· 1 + export type Params = Record<string, string> 2 + 3 + export function parseSearchQuery(rawQuery: string) { 4 + let base = rawQuery 5 + const rawLiterals = rawQuery.match(/[^:\w\d]".+?"/gi) || [] 6 + 7 + // remove literals from base 8 + for (const literal of rawLiterals) { 9 + base = base.replace(literal.trim(), '') 10 + } 11 + 12 + // find remaining params in base 13 + const rawParams = base.match(/[a-z]+:[a-z-\.@\d:"]+/gi) || [] 14 + 15 + for (const param of rawParams) { 16 + base = base.replace(param, '') 17 + } 18 + 19 + base = base.trim() 20 + 21 + const params = rawParams.reduce((params, param) => { 22 + const [name, ...value] = param.split(/:/) 23 + params[name] = value.join(':').replace(/"/g, '') // dates can contain additional colons 24 + return params 25 + }, {} as Params) 26 + const literals = rawLiterals.map(l => String(l).trim()) 27 + 28 + return { 29 + query: [base, literals.join(' ')].filter(Boolean).join(' '), 30 + params, 31 + } 32 + } 33 + 34 + export function makeSearchQuery(query: string, params: Params) { 35 + return [ 36 + query, 37 + Object.entries(params) 38 + .map(([name, value]) => `${name}:${value}`) 39 + .join(' '), 40 + ] 41 + .filter(Boolean) 42 + .join(' ') 43 + }
+320 -141
src/view/screens/Search/Search.tsx
··· 11 View, 12 } from 'react-native' 13 import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' 14 import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' 15 import { 16 FontAwesomeIcon, ··· 21 import AsyncStorage from '@react-native-async-storage/async-storage' 22 import {useFocusEffect, useNavigation} from '@react-navigation/native' 23 24 import {useAnalytics} from '#/lib/analytics/analytics' 25 import {createHitslop} from '#/lib/constants' 26 import {HITSLOP_10} from '#/lib/constants' ··· 35 SearchTabNavigatorParams, 36 } from '#/lib/routes/types' 37 import {augmentSearchQuery} from '#/lib/strings/helpers' 38 - import {useTheme} from '#/lib/ThemeContext' 39 import {logger} from '#/logger' 40 import {isNative, isWeb} from '#/platform/detection' 41 import {listenSoftReset} from '#/state/events' 42 import {useModerationOpts} from '#/state/preferences/moderation-opts' 43 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 44 import {useActorSearch} from '#/state/queries/actor-search' ··· 57 import {CenteredView, ScrollView} from '#/view/com/util/Views' 58 import {Explore} from '#/view/screens/Search/Explore' 59 import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 60 - import {atoms as a, useTheme as useThemeNew} from '#/alf' 61 import * as FeedCard from '#/components/FeedCard' 62 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' 63 64 function Loader() { 65 const pal = usePalette('default') ··· 251 const {_} = useLingui() 252 253 const {data: results, isFetched} = useActorSearch({ 254 - query: query, 255 enabled: active, 256 }) 257 ··· 324 } 325 SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) 326 327 - let SearchScreenInner = ({query}: {query?: string}): React.ReactNode => { 328 const pal = usePalette('default') 329 const setMinimalShellMode = useSetMinimalShellMode() 330 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() ··· 349 title: _(msg`Top`), 350 component: ( 351 <SearchScreenPostResults 352 - query={query} 353 sort="top" 354 active={activeTab === 0} 355 /> ··· 359 title: _(msg`Latest`), 360 component: ( 361 <SearchScreenPostResults 362 - query={query} 363 sort="latest" 364 active={activeTab === 1} 365 /> ··· 378 ), 379 }, 380 ] 381 - }, [_, query, activeTab]) 382 383 return query ? ( 384 <Pager ··· 386 renderTabBar={props => ( 387 <CenteredView 388 sideBorders 389 - style={[pal.border, pal.view, styles.tabBarContainer]}> 390 <TabBar items={sections.map(section => section.title)} {...props} /> 391 </CenteredView> 392 )} ··· 448 export function SearchScreen( 449 props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 450 ) { 451 const navigation = useNavigation<NavigationProp>() 452 const textInput = React.useRef<TextInput>(null) 453 const {_} = useLingui() 454 - const pal = usePalette('default') 455 const {track} = useAnalytics() 456 const setDrawerOpen = useSetDrawerOpen() 457 const setMinimalShellMode = useSetMinimalShellMode() 458 - const {isTabletOrDesktop, isTabletOrMobile} = useWebMediaQueries() 459 460 // Query terms 461 const queryParam = props.route?.params?.q ?? '' ··· 469 AppBskyActorDefs.ProfileViewBasic[] 470 >([]) 471 472 useFocusEffect( 473 useNonReactiveCallback(() => { 474 if (isWeb) { ··· 506 setSearchText('') 507 textInput.current?.focus() 508 }, []) 509 - 510 - const onPressCancelSearch = React.useCallback(() => { 511 - scrollToTopWeb() 512 - textInput.current?.blur() 513 - setShowAutocomplete(false) 514 - setSearchText(queryParam) 515 - }, [queryParam]) 516 517 const onChangeText = React.useCallback(async (text: string) => { 518 scrollToTopWeb() ··· 586 [updateSearchHistory, navigation], 587 ) 588 589 const onSubmit = React.useCallback(() => { 590 navigateToItem(searchText) 591 }, [navigateToItem, searchText]) ··· 624 setSearchText('') 625 navigation.setParams({q: ''}) 626 } 627 }, [navigation]) 628 629 useFocusEffect( ··· 663 [selectedProfiles], 664 ) 665 666 return ( 667 <View style={isWeb ? null : {flex: 1}}> 668 <CenteredView 669 style={[ 670 - styles.header, 671 - pal.border, 672 - pal.view, 673 - isTabletOrDesktop && {paddingTop: 10}, 674 ]} 675 - sideBorders={isTabletOrDesktop}> 676 - {isTabletOrMobile && ( 677 - <Pressable 678 - testID="viewHeaderBackOrMenuBtn" 679 - onPress={onPressMenu} 680 - hitSlop={HITSLOP_10} 681 - style={styles.headerMenuBtn} 682 - accessibilityRole="button" 683 - accessibilityLabel={_(msg`Menu`)} 684 - accessibilityHint={_(msg`Access navigation links and settings`)}> 685 - <Menu size="lg" fill={pal.colors.textLight} /> 686 - </Pressable> 687 - )} 688 - <SearchInputBox 689 - textInput={textInput} 690 - searchText={searchText} 691 - showAutocomplete={showAutocomplete} 692 - setShowAutocomplete={setShowAutocomplete} 693 - onChangeText={onChangeText} 694 - onSubmit={onSubmit} 695 - onPressClearQuery={onPressClearQuery} 696 - /> 697 - {showAutocomplete && ( 698 - <View style={[styles.headerCancelBtn]}> 699 - <Pressable 700 onPress={onPressCancelSearch} 701 - accessibilityRole="button" 702 hitSlop={HITSLOP_10}> 703 - <Text style={pal.text}> 704 <Trans>Cancel</Trans> 705 - </Text> 706 - </Pressable> 707 </View> 708 )} 709 </CenteredView> 710 <View 711 style={{ 712 display: showAutocomplete ? 'flex' : 'none', ··· 737 display: showAutocomplete ? 'none' : 'flex', 738 flex: 1, 739 }}> 740 - <SearchScreenInner query={queryParam} /> 741 </View> 742 </View> 743 ) ··· 747 textInput, 748 searchText, 749 showAutocomplete, 750 - setShowAutocomplete, 751 onChangeText, 752 onSubmit, 753 onPressClearQuery, ··· 755 textInput: React.RefObject<TextInput> 756 searchText: string 757 showAutocomplete: boolean 758 - setShowAutocomplete: (show: boolean) => void 759 onChangeText: (text: string) => void 760 onSubmit: () => void 761 onPressClearQuery: () => void 762 }): React.ReactNode => { 763 - const pal = usePalette('default') 764 const {_} = useLingui() 765 - const theme = useTheme() 766 return ( 767 - <Pressable 768 - // This only exists only for extra hitslop so don't expose it to the a11y tree. 769 - accessible={false} 770 - focusable={false} 771 - // @ts-ignore web-only 772 - tabIndex={-1} 773 - style={[ 774 - {backgroundColor: pal.colors.backgroundLight}, 775 - styles.headerSearchContainer, 776 - // @ts-expect-error web only 777 - isWeb && { 778 - cursor: 'default', 779 - }, 780 - ]} 781 - onPress={() => { 782 - textInput.current?.focus() 783 - }}> 784 - <MagnifyingGlassIcon 785 - style={[pal.icon, styles.headerSearchIcon]} 786 - size={20} 787 - /> 788 - <TextInput 789 - testID="searchTextInput" 790 - ref={textInput} 791 - placeholder={_(msg`Search`)} 792 - placeholderTextColor={pal.colors.textLight} 793 - returnKeyType="search" 794 - value={searchText} 795 - style={[pal.text, styles.headerSearchInput]} 796 - keyboardAppearance={theme.colorScheme} 797 - selectTextOnFocus={isNative} 798 - onFocus={() => { 799 - if (isWeb) { 800 - // Prevent a jump on iPad by ensuring that 801 - // the initial focused render has no result list. 802 - requestAnimationFrame(() => { 803 - setShowAutocomplete(true) 804 - }) 805 - } else { 806 - setShowAutocomplete(true) 807 - } 808 - }} 809 - onChangeText={onChangeText} 810 - onSubmitEditing={onSubmit} 811 - autoFocus={false} 812 - accessibilityRole="search" 813 - accessibilityLabel={_(msg`Search`)} 814 - accessibilityHint="" 815 - autoCorrect={false} 816 - autoComplete="off" 817 - autoCapitalize="none" 818 - /> 819 {showAutocomplete && searchText.length > 0 && ( 820 - <Pressable 821 - testID="searchTextInputClearBtn" 822 - onPress={onPressClearQuery} 823 - accessibilityRole="button" 824 - accessibilityLabel={_(msg`Clear search query`)} 825 - accessibilityHint="" 826 - hitSlop={HITSLOP_10}> 827 - <FontAwesomeIcon 828 - icon="xmark" 829 - size={16} 830 - style={pal.textLight as FontAwesomeIconStyle} 831 - /> 832 - </Pressable> 833 )} 834 - </Pressable> 835 ) 836 } 837 SearchInputBox = React.memo(SearchInputBox) ··· 1029 } 1030 } 1031 1032 - const HEADER_HEIGHT = 46 1033 - 1034 const styles = StyleSheet.create({ 1035 - header: { 1036 - flexDirection: 'row', 1037 - alignItems: 'center', 1038 - paddingHorizontal: 12, 1039 - paddingLeft: 13, 1040 - paddingVertical: 4, 1041 - height: HEADER_HEIGHT, 1042 - // @ts-ignore web only 1043 - position: isWeb ? 'sticky' : '', 1044 - top: 0, 1045 - zIndex: 1, 1046 - }, 1047 headerMenuBtn: { 1048 width: 30, 1049 height: 30, ··· 1074 alignSelf: 'center', 1075 zIndex: -1, 1076 elevation: -1, // For Android 1077 - }, 1078 - tabBarContainer: { 1079 - // @ts-ignore web only 1080 - position: isWeb ? 'sticky' : '', 1081 - top: isWeb ? HEADER_HEIGHT : 0, 1082 - zIndex: 1, 1083 }, 1084 searchHistoryContainer: { 1085 width: '100%',
··· 11 View, 12 } from 'react-native' 13 import {ScrollView as RNGHScrollView} from 'react-native-gesture-handler' 14 + import RNPickerSelect from 'react-native-picker-select' 15 import {AppBskyActorDefs, AppBskyFeedDefs, moderateProfile} from '@atproto/api' 16 import { 17 FontAwesomeIcon, ··· 22 import AsyncStorage from '@react-native-async-storage/async-storage' 23 import {useFocusEffect, useNavigation} from '@react-navigation/native' 24 25 + import {LANGUAGES} from '#/lib/../locale/languages' 26 import {useAnalytics} from '#/lib/analytics/analytics' 27 import {createHitslop} from '#/lib/constants' 28 import {HITSLOP_10} from '#/lib/constants' ··· 37 SearchTabNavigatorParams, 38 } from '#/lib/routes/types' 39 import {augmentSearchQuery} from '#/lib/strings/helpers' 40 import {logger} from '#/logger' 41 import {isNative, isWeb} from '#/platform/detection' 42 import {listenSoftReset} from '#/state/events' 43 + import {useLanguagePrefs} from '#/state/preferences/languages' 44 import {useModerationOpts} from '#/state/preferences/moderation-opts' 45 import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 46 import {useActorSearch} from '#/state/queries/actor-search' ··· 59 import {CenteredView, ScrollView} from '#/view/com/util/Views' 60 import {Explore} from '#/view/screens/Search/Explore' 61 import {SearchLinkCard, SearchProfileCard} from '#/view/shell/desktop/Search' 62 + import {makeSearchQuery, parseSearchQuery} from '#/screens/Search/utils' 63 + import {atoms as a, useBreakpoints, useTheme as useThemeNew, web} from '#/alf' 64 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 65 import * as FeedCard from '#/components/FeedCard' 66 + import * as TextField from '#/components/forms/TextField' 67 + import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' 68 + import {MagnifyingGlass2_Stroke2_Corner0_Rounded as MagnifyingGlass} from '#/components/icons/MagnifyingGlass2' 69 import {Menu_Stroke2_Corner0_Rounded as Menu} from '#/components/icons/Menu' 70 + import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2' 71 + import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 72 73 function Loader() { 74 const pal = usePalette('default') ··· 260 const {_} = useLingui() 261 262 const {data: results, isFetched} = useActorSearch({ 263 + query, 264 enabled: active, 265 }) 266 ··· 333 } 334 SearchScreenFeedsResults = React.memo(SearchScreenFeedsResults) 335 336 + function SearchLanguageDropdown({ 337 + value, 338 + onChange, 339 + }: { 340 + value: string 341 + onChange(value: string): void 342 + }) { 343 + const t = useThemeNew() 344 + const {contentLanguages} = useLanguagePrefs() 345 + 346 + const items = React.useMemo(() => { 347 + return LANGUAGES.filter(l => Boolean(l.code2)) 348 + .map(l => ({ 349 + label: l.name, 350 + inputLabel: l.name, 351 + value: l.code2, 352 + key: l.code2 + l.code3, 353 + })) 354 + .sort(a => (contentLanguages.includes(a.value) ? -1 : 1)) 355 + }, [contentLanguages]) 356 + 357 + const style = { 358 + backgroundColor: t.atoms.bg_contrast_25.backgroundColor, 359 + color: t.atoms.text.color, 360 + fontSize: a.text_xs.fontSize, 361 + fontFamily: 'inherit', 362 + fontWeight: a.font_bold.fontWeight, 363 + paddingHorizontal: 14, 364 + paddingRight: 32, 365 + paddingVertical: 8, 366 + borderRadius: a.rounded_full.borderRadius, 367 + borderWidth: a.border.borderWidth, 368 + borderColor: t.atoms.border_contrast_low.borderColor, 369 + } 370 + 371 + return ( 372 + <RNPickerSelect 373 + value={value} 374 + onValueChange={onChange} 375 + items={items} 376 + Icon={() => ( 377 + <ChevronDown fill={t.atoms.text_contrast_low.color} size="sm" /> 378 + )} 379 + useNativeAndroidPickerStyle={false} 380 + style={{ 381 + iconContainer: { 382 + pointerEvents: 'none', 383 + right: a.px_sm.paddingRight, 384 + top: 0, 385 + bottom: 0, 386 + display: 'flex', 387 + justifyContent: 'center', 388 + }, 389 + inputAndroid: { 390 + ...style, 391 + paddingVertical: 2, 392 + }, 393 + inputIOS: { 394 + ...style, 395 + }, 396 + inputWeb: web({ 397 + ...style, 398 + cursor: 'pointer', 399 + // @ts-ignore web only 400 + '-moz-appearance': 'none', 401 + '-webkit-appearance': 'none', 402 + appearance: 'none', 403 + outline: 0, 404 + borderWidth: 0, 405 + overflow: 'hidden', 406 + whiteSpace: 'nowrap', 407 + textOverflow: 'ellipsis', 408 + }), 409 + }} 410 + /> 411 + ) 412 + } 413 + 414 + function useQueryManager({initialQuery}: {initialQuery: string}) { 415 + const {contentLanguages} = useLanguagePrefs() 416 + const {query, params: initialParams} = React.useMemo(() => { 417 + return parseSearchQuery(initialQuery || '') 418 + }, [initialQuery]) 419 + const prevInitialQuery = React.useRef(initialQuery) 420 + const [lang, setLang] = React.useState( 421 + initialParams.lang || contentLanguages[0], 422 + ) 423 + 424 + if (initialQuery !== prevInitialQuery.current) { 425 + // handle new queryParam change (from manual search entry) 426 + prevInitialQuery.current = initialQuery 427 + setLang(initialParams.lang || contentLanguages[0]) 428 + } 429 + 430 + const params = React.useMemo( 431 + () => ({ 432 + // default stuff 433 + ...initialParams, 434 + // managed stuff 435 + lang, 436 + }), 437 + [lang, initialParams], 438 + ) 439 + const handlers = React.useMemo( 440 + () => ({ 441 + setLang, 442 + }), 443 + [setLang], 444 + ) 445 + 446 + return React.useMemo(() => { 447 + return { 448 + query, 449 + queryWithParams: makeSearchQuery(query, params), 450 + params: { 451 + ...params, 452 + ...handlers, 453 + }, 454 + } 455 + }, [query, params, handlers]) 456 + } 457 + 458 + let SearchScreenInner = ({ 459 + query, 460 + queryWithParams, 461 + headerHeight, 462 + }: { 463 + query: string 464 + queryWithParams: string 465 + headerHeight: number 466 + }): React.ReactNode => { 467 const pal = usePalette('default') 468 const setMinimalShellMode = useSetMinimalShellMode() 469 const setDrawerSwipeDisabled = useSetDrawerSwipeDisabled() ··· 488 title: _(msg`Top`), 489 component: ( 490 <SearchScreenPostResults 491 + query={queryWithParams} 492 sort="top" 493 active={activeTab === 0} 494 /> ··· 498 title: _(msg`Latest`), 499 component: ( 500 <SearchScreenPostResults 501 + query={queryWithParams} 502 sort="latest" 503 active={activeTab === 1} 504 /> ··· 517 ), 518 }, 519 ] 520 + }, [_, query, queryWithParams, activeTab]) 521 522 return query ? ( 523 <Pager ··· 525 renderTabBar={props => ( 526 <CenteredView 527 sideBorders 528 + style={[ 529 + pal.border, 530 + pal.view, 531 + web({ 532 + position: isWeb ? 'sticky' : '', 533 + zIndex: 1, 534 + }), 535 + {top: isWeb ? headerHeight : undefined}, 536 + ]}> 537 <TabBar items={sections.map(section => section.title)} {...props} /> 538 </CenteredView> 539 )} ··· 595 export function SearchScreen( 596 props: NativeStackScreenProps<SearchTabNavigatorParams, 'Search'>, 597 ) { 598 + const t = useThemeNew() 599 + const {gtMobile} = useBreakpoints() 600 const navigation = useNavigation<NavigationProp>() 601 const textInput = React.useRef<TextInput>(null) 602 const {_} = useLingui() 603 const {track} = useAnalytics() 604 const setDrawerOpen = useSetDrawerOpen() 605 const setMinimalShellMode = useSetMinimalShellMode() 606 607 // Query terms 608 const queryParam = props.route?.params?.q ?? '' ··· 616 AppBskyActorDefs.ProfileViewBasic[] 617 >([]) 618 619 + const {params, query, queryWithParams} = useQueryManager({ 620 + initialQuery: queryParam, 621 + }) 622 + const showFiltersButton = Boolean(query && !showAutocomplete) 623 + const [showFilters, setShowFilters] = React.useState(false) 624 + /* 625 + * Arbitrary sizing, so guess and check, used for sticky header alignment and 626 + * sizing. 627 + */ 628 + const headerHeight = 56 + (showFilters ? 40 : 0) 629 + 630 useFocusEffect( 631 useNonReactiveCallback(() => { 632 if (isWeb) { ··· 664 setSearchText('') 665 textInput.current?.focus() 666 }, []) 667 668 const onChangeText = React.useCallback(async (text: string) => { 669 scrollToTopWeb() ··· 737 [updateSearchHistory, navigation], 738 ) 739 740 + const onPressCancelSearch = React.useCallback(() => { 741 + scrollToTopWeb() 742 + textInput.current?.blur() 743 + setShowAutocomplete(false) 744 + setSearchText(queryParam) 745 + }, [setShowAutocomplete, setSearchText, queryParam]) 746 + 747 const onSubmit = React.useCallback(() => { 748 navigateToItem(searchText) 749 }, [navigateToItem, searchText]) ··· 782 setSearchText('') 783 navigation.setParams({q: ''}) 784 } 785 + setShowFilters(false) 786 }, [navigation]) 787 788 useFocusEffect( ··· 822 [selectedProfiles], 823 ) 824 825 + const onSearchInputFocus = React.useCallback(() => { 826 + if (isWeb) { 827 + // Prevent a jump on iPad by ensuring that 828 + // the initial focused render has no result list. 829 + requestAnimationFrame(() => { 830 + setShowAutocomplete(true) 831 + }) 832 + } else { 833 + setShowAutocomplete(true) 834 + } 835 + setShowFilters(false) 836 + }, [setShowAutocomplete]) 837 + 838 return ( 839 <View style={isWeb ? null : {flex: 1}}> 840 <CenteredView 841 style={[ 842 + a.p_md, 843 + a.pb_0, 844 + a.gap_sm, 845 + t.atoms.bg, 846 + web({ 847 + height: headerHeight, 848 + position: 'sticky', 849 + top: 0, 850 + zIndex: 1, 851 + }), 852 ]} 853 + sideBorders={gtMobile}> 854 + <View style={[a.flex_row, a.gap_sm]}> 855 + {!gtMobile && ( 856 + <Button 857 + testID="viewHeaderBackOrMenuBtn" 858 + onPress={onPressMenu} 859 + hitSlop={HITSLOP_10} 860 + label={_(msg`Menu`)} 861 + accessibilityHint={_(msg`Access navigation links and settings`)} 862 + size="large" 863 + variant="solid" 864 + color="secondary" 865 + shape="square"> 866 + <ButtonIcon icon={Menu} size="lg" /> 867 + </Button> 868 + )} 869 + <SearchInputBox 870 + textInput={textInput} 871 + searchText={searchText} 872 + showAutocomplete={showAutocomplete} 873 + onFocus={onSearchInputFocus} 874 + onChangeText={onChangeText} 875 + onSubmit={onSubmit} 876 + onPressClearQuery={onPressClearQuery} 877 + /> 878 + {showFiltersButton && ( 879 + <Button 880 + onPress={() => setShowFilters(!showFilters)} 881 + hitSlop={HITSLOP_10} 882 + label={_(msg`Show advanced filters`)} 883 + size="large" 884 + variant="solid" 885 + color="secondary" 886 + shape="square"> 887 + <Gear 888 + size="md" 889 + fill={ 890 + showFilters 891 + ? t.palette.primary_500 892 + : t.atoms.text_contrast_low.color 893 + } 894 + /> 895 + </Button> 896 + )} 897 + {showAutocomplete && ( 898 + <Button 899 + label={_(msg`Cancel search`)} 900 + size="large" 901 + variant="ghost" 902 + color="secondary" 903 + style={[a.px_sm]} 904 onPress={onPressCancelSearch} 905 hitSlop={HITSLOP_10}> 906 + <ButtonText> 907 <Trans>Cancel</Trans> 908 + </ButtonText> 909 + </Button> 910 + )} 911 + </View> 912 + 913 + {showFilters && ( 914 + <View 915 + style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}> 916 + <View style={[{width: 140}]}> 917 + <SearchLanguageDropdown 918 + value={params.lang} 919 + onChange={params.setLang} 920 + /> 921 + </View> 922 </View> 923 )} 924 </CenteredView> 925 + 926 <View 927 style={{ 928 display: showAutocomplete ? 'flex' : 'none', ··· 953 display: showAutocomplete ? 'none' : 'flex', 954 flex: 1, 955 }}> 956 + <SearchScreenInner 957 + query={query} 958 + queryWithParams={queryWithParams} 959 + headerHeight={headerHeight} 960 + /> 961 </View> 962 </View> 963 ) ··· 967 textInput, 968 searchText, 969 showAutocomplete, 970 + onFocus, 971 onChangeText, 972 onSubmit, 973 onPressClearQuery, ··· 975 textInput: React.RefObject<TextInput> 976 searchText: string 977 showAutocomplete: boolean 978 + onFocus: () => void 979 onChangeText: (text: string) => void 980 onSubmit: () => void 981 onPressClearQuery: () => void 982 }): React.ReactNode => { 983 const {_} = useLingui() 984 + const t = useThemeNew() 985 + 986 return ( 987 + <View style={[a.flex_1, a.relative]}> 988 + <TextField.Root> 989 + <TextField.Icon icon={MagnifyingGlass} /> 990 + <TextField.Input 991 + inputRef={textInput} 992 + label={_(msg`Search`)} 993 + value={searchText} 994 + placeholder={_(msg`Search`)} 995 + returnKeyType="search" 996 + onChangeText={onChangeText} 997 + onSubmitEditing={onSubmit} 998 + onFocus={onFocus} 999 + keyboardAppearance={t.scheme} 1000 + selectTextOnFocus={isNative} 1001 + autoFocus={false} 1002 + accessibilityRole="search" 1003 + autoCorrect={false} 1004 + autoComplete="off" 1005 + autoCapitalize="none" 1006 + /> 1007 + </TextField.Root> 1008 + 1009 {showAutocomplete && searchText.length > 0 && ( 1010 + <View 1011 + style={[ 1012 + a.absolute, 1013 + a.z_10, 1014 + a.my_auto, 1015 + a.inset_0, 1016 + a.justify_center, 1017 + a.pr_sm, 1018 + {left: 'auto'}, 1019 + ]}> 1020 + <Button 1021 + testID="searchTextInputClearBtn" 1022 + onPress={onPressClearQuery} 1023 + label={_(msg`Clear search query`)} 1024 + hitSlop={HITSLOP_10} 1025 + size="tiny" 1026 + shape="round" 1027 + variant="ghost" 1028 + color="secondary"> 1029 + <ButtonIcon icon={X} size="sm" /> 1030 + </Button> 1031 + </View> 1032 )} 1033 + </View> 1034 ) 1035 } 1036 SearchInputBox = React.memo(SearchInputBox) ··· 1228 } 1229 } 1230 1231 const styles = StyleSheet.create({ 1232 headerMenuBtn: { 1233 width: 30, 1234 height: 30, ··· 1259 alignSelf: 'center', 1260 zIndex: -1, 1261 elevation: -1, // For Android 1262 }, 1263 searchHistoryContainer: { 1264 width: '100%',
+1 -1
src/view/screens/Storybook/Forms.tsx
··· 32 label="Text field" 33 /> 34 35 - <View style={[a.flex_row, a.gap_sm]}> 36 <View 37 style={[ 38 {
··· 32 label="Text field" 33 /> 34 35 + <View style={[a.flex_row, a.align_start, a.gap_sm]}> 36 <View 37 style={[ 38 {