mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import { 2 memo, 3 useCallback, 4 useLayoutEffect, 5 useMemo, 6 useRef, 7 useState, 8} from 'react' 9import { 10 type StyleProp, 11 type TextInput, 12 View, 13 type ViewStyle, 14} from 'react-native' 15import {msg, Trans} from '@lingui/macro' 16import {useLingui} from '@lingui/react' 17import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' 18import {useQueryClient} from '@tanstack/react-query' 19 20import {HITSLOP_20} from '#/lib/constants' 21import {HITSLOP_10} from '#/lib/constants' 22import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 23import {MagnifyingGlassIcon} from '#/lib/icons' 24import {type NavigationProp} from '#/lib/routes/types' 25import {isWeb} from '#/platform/detection' 26import {listenSoftReset} from '#/state/events' 27import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 28import { 29 unstableCacheProfileView, 30 useProfilesQuery, 31} from '#/state/queries/profile' 32import {useSession} from '#/state/session' 33import {useSetMinimalShellMode} from '#/state/shell' 34import { 35 makeSearchQuery, 36 type Params, 37 parseSearchQuery, 38} from '#/screens/Search/utils' 39import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' 40import {Button, ButtonText} from '#/components/Button' 41import {SearchInput} from '#/components/forms/SearchInput' 42import * as Layout from '#/components/Layout' 43import {Text} from '#/components/Typography' 44import {account, useStorage} from '#/storage' 45import type * as bsky from '#/types/bsky' 46import {AutocompleteResults} from './components/AutocompleteResults' 47import {SearchHistory} from './components/SearchHistory' 48import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' 49import {Explore} from './Explore' 50import {SearchResults} from './SearchResults' 51 52export function SearchScreenShell({ 53 queryParam, 54 testID, 55 fixedParams, 56 navButton = 'menu', 57 inputPlaceholder, 58 isExplore, 59}: { 60 queryParam: string 61 testID: string 62 fixedParams?: Params 63 navButton?: 'back' | 'menu' 64 inputPlaceholder?: string 65 isExplore?: boolean 66}) { 67 const t = useTheme() 68 const {gtMobile} = useBreakpoints() 69 const navigation = useNavigation<NavigationProp>() 70 const route = useRoute() 71 const textInput = useRef<TextInput>(null) 72 const {_} = useLingui() 73 const setMinimalShellMode = useSetMinimalShellMode() 74 const {currentAccount} = useSession() 75 const queryClient = useQueryClient() 76 77 // Query terms 78 const [searchText, setSearchText] = useState<string>(queryParam) 79 const {data: autocompleteData, isFetching: isAutocompleteFetching} = 80 useActorAutocompleteQuery(searchText, true) 81 82 const [showAutocomplete, setShowAutocomplete] = useState(false) 83 84 const [termHistory = [], setTermHistory] = useStorage(account, [ 85 currentAccount?.did ?? 'pwi', 86 'searchTermHistory', 87 ] as const) 88 const [accountHistory = [], setAccountHistory] = useStorage(account, [ 89 currentAccount?.did ?? 'pwi', 90 'searchAccountHistory', 91 ]) 92 93 const {data: accountHistoryProfiles} = useProfilesQuery({ 94 handles: accountHistory, 95 maintainData: true, 96 }) 97 98 const updateSearchHistory = useCallback( 99 async (item: string) => { 100 if (!item) return 101 const newSearchHistory = [ 102 item, 103 ...termHistory.filter(search => search !== item), 104 ].slice(0, 6) 105 setTermHistory(newSearchHistory) 106 }, 107 [termHistory, setTermHistory], 108 ) 109 110 const updateProfileHistory = useCallback( 111 async (item: bsky.profile.AnyProfileView) => { 112 const newAccountHistory = [ 113 item.did, 114 ...accountHistory.filter(p => p !== item.did), 115 ].slice(0, 10) 116 setAccountHistory(newAccountHistory) 117 }, 118 [accountHistory, setAccountHistory], 119 ) 120 121 const deleteSearchHistoryItem = useCallback( 122 async (item: string) => { 123 setTermHistory(termHistory.filter(search => search !== item)) 124 }, 125 [termHistory, setTermHistory], 126 ) 127 const deleteProfileHistoryItem = useCallback( 128 async (item: bsky.profile.AnyProfileView) => { 129 setAccountHistory(accountHistory.filter(p => p !== item.did)) 130 }, 131 [accountHistory, setAccountHistory], 132 ) 133 134 const {params, query, queryWithParams} = useQueryManager({ 135 initialQuery: queryParam, 136 fixedParams, 137 }) 138 const showFilters = Boolean(queryWithParams && !showAutocomplete) 139 140 // web only - measure header height for sticky positioning 141 const [headerHeight, setHeaderHeight] = useState(0) 142 const headerRef = useRef(null) 143 useLayoutEffect(() => { 144 if (isWeb) { 145 if (!headerRef.current) return 146 const measurement = (headerRef.current as Element).getBoundingClientRect() 147 setHeaderHeight(measurement.height) 148 } 149 }, []) 150 151 useFocusEffect( 152 useNonReactiveCallback(() => { 153 if (isWeb) { 154 setSearchText(queryParam) 155 } 156 }), 157 ) 158 159 const onPressClearQuery = useCallback(() => { 160 scrollToTopWeb() 161 setSearchText('') 162 textInput.current?.focus() 163 }, []) 164 165 const onChangeText = useCallback(async (text: string) => { 166 scrollToTopWeb() 167 setSearchText(text) 168 }, []) 169 170 const navigateToItem = useCallback( 171 (item: string) => { 172 scrollToTopWeb() 173 setShowAutocomplete(false) 174 updateSearchHistory(item) 175 176 if (isWeb) { 177 // @ts-expect-error route is not typesafe 178 navigation.push(route.name, {...route.params, q: item}) 179 } else { 180 textInput.current?.blur() 181 navigation.setParams({q: item}) 182 } 183 }, 184 [updateSearchHistory, navigation, route], 185 ) 186 187 const onPressCancelSearch = useCallback(() => { 188 scrollToTopWeb() 189 textInput.current?.blur() 190 setShowAutocomplete(false) 191 if (isWeb) { 192 // Empty params resets the URL to be /search rather than /search?q= 193 194 const {q: _q, ...parameters} = (route.params ?? {}) as { 195 [key: string]: string 196 } 197 // @ts-expect-error route is not typesafe 198 navigation.replace(route.name, parameters) 199 } else { 200 setSearchText('') 201 navigation.setParams({q: ''}) 202 } 203 }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) 204 205 const onSubmit = useCallback(() => { 206 navigateToItem(searchText) 207 }, [navigateToItem, searchText]) 208 209 const onAutocompleteResultPress = useCallback(() => { 210 if (isWeb) { 211 setShowAutocomplete(false) 212 } else { 213 textInput.current?.blur() 214 } 215 }, []) 216 217 const handleHistoryItemClick = useCallback( 218 (item: string) => { 219 setSearchText(item) 220 navigateToItem(item) 221 }, 222 [navigateToItem], 223 ) 224 225 const handleProfileClick = useCallback( 226 (profile: bsky.profile.AnyProfileView) => { 227 unstableCacheProfileView(queryClient, profile) 228 // Slight delay to avoid updating during push nav animation. 229 setTimeout(() => { 230 updateProfileHistory(profile) 231 }, 400) 232 }, 233 [updateProfileHistory, queryClient], 234 ) 235 236 const onSoftReset = useCallback(() => { 237 if (isWeb) { 238 // Empty params resets the URL to be /search rather than /search?q= 239 240 const {q: _q, ...parameters} = (route.params ?? {}) as { 241 [key: string]: string 242 } 243 // @ts-expect-error route is not typesafe 244 navigation.replace(route.name, parameters) 245 } else { 246 setSearchText('') 247 navigation.setParams({q: ''}) 248 textInput.current?.focus() 249 } 250 }, [navigation, route]) 251 252 useFocusEffect( 253 useCallback(() => { 254 setMinimalShellMode(false) 255 return listenSoftReset(onSoftReset) 256 }, [onSoftReset, setMinimalShellMode]), 257 ) 258 259 const onSearchInputFocus = useCallback(() => { 260 if (isWeb) { 261 // Prevent a jump on iPad by ensuring that 262 // the initial focused render has no result list. 263 requestAnimationFrame(() => { 264 setShowAutocomplete(true) 265 }) 266 } else { 267 setShowAutocomplete(true) 268 } 269 }, [setShowAutocomplete]) 270 271 const focusSearchInput = useCallback(() => { 272 textInput.current?.focus() 273 }, []) 274 275 const showHeader = !gtMobile || navButton !== 'menu' 276 277 return ( 278 <Layout.Screen testID={testID}> 279 <View 280 ref={headerRef} 281 onLayout={evt => { 282 if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height) 283 }} 284 style={[ 285 a.relative, 286 a.z_10, 287 web({ 288 position: 'sticky', 289 top: 0, 290 }), 291 ]}> 292 <Layout.Center style={t.atoms.bg}> 293 {showHeader && ( 294 <View 295 // HACK: shift up search input. we can't remove the top padding 296 // on the search input because it messes up the layout animation 297 // if we add it only when the header is hidden 298 style={{marginBottom: tokens.space.xs * -1}}> 299 <Layout.Header.Outer noBottomBorder> 300 {navButton === 'menu' ? ( 301 <Layout.Header.MenuButton /> 302 ) : ( 303 <Layout.Header.BackButton /> 304 )} 305 <Layout.Header.Content align="left"> 306 <Layout.Header.TitleText> 307 {isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>} 308 </Layout.Header.TitleText> 309 </Layout.Header.Content> 310 {showFilters ? ( 311 <SearchLanguageDropdown 312 value={params.lang} 313 onChange={params.setLang} 314 /> 315 ) : ( 316 <Layout.Header.Slot /> 317 )} 318 </Layout.Header.Outer> 319 </View> 320 )} 321 <View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}> 322 <View style={[a.gap_sm]}> 323 <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}> 324 <View style={[a.flex_1]}> 325 <SearchInput 326 ref={textInput} 327 value={searchText} 328 onFocus={onSearchInputFocus} 329 onChangeText={onChangeText} 330 onClearText={onPressClearQuery} 331 onSubmitEditing={onSubmit} 332 placeholder={ 333 inputPlaceholder ?? 334 _(msg`Search for posts, users, or feeds`) 335 } 336 hitSlop={{...HITSLOP_20, top: 0}} 337 /> 338 </View> 339 {showAutocomplete && ( 340 <Button 341 label={_(msg`Cancel search`)} 342 size="large" 343 variant="ghost" 344 color="secondary" 345 style={[a.px_sm]} 346 onPress={onPressCancelSearch} 347 hitSlop={HITSLOP_10}> 348 <ButtonText> 349 <Trans>Cancel</Trans> 350 </ButtonText> 351 </Button> 352 )} 353 </View> 354 355 {showFilters && !showHeader && ( 356 <View 357 style={[ 358 a.flex_row, 359 a.align_center, 360 a.justify_between, 361 a.gap_sm, 362 ]}> 363 <SearchLanguageDropdown 364 value={params.lang} 365 onChange={params.setLang} 366 /> 367 </View> 368 )} 369 </View> 370 </View> 371 </Layout.Center> 372 </View> 373 374 <View 375 style={{ 376 display: showAutocomplete && !fixedParams ? 'flex' : 'none', 377 flex: 1, 378 }}> 379 {searchText.length > 0 ? ( 380 <AutocompleteResults 381 isAutocompleteFetching={isAutocompleteFetching} 382 autocompleteData={autocompleteData} 383 searchText={searchText} 384 onSubmit={onSubmit} 385 onResultPress={onAutocompleteResultPress} 386 onProfileClick={handleProfileClick} 387 /> 388 ) : ( 389 <SearchHistory 390 searchHistory={termHistory} 391 selectedProfiles={accountHistoryProfiles?.profiles || []} 392 onItemClick={handleHistoryItemClick} 393 onProfileClick={handleProfileClick} 394 onRemoveItemClick={deleteSearchHistoryItem} 395 onRemoveProfileClick={deleteProfileHistoryItem} 396 /> 397 )} 398 </View> 399 <View 400 style={{ 401 display: showAutocomplete ? 'none' : 'flex', 402 flex: 1, 403 }}> 404 <SearchScreenInner 405 query={query} 406 queryWithParams={queryWithParams} 407 headerHeight={headerHeight} 408 focusSearchInput={focusSearchInput} 409 /> 410 </View> 411 </Layout.Screen> 412 ) 413} 414 415let SearchScreenInner = ({ 416 query, 417 queryWithParams, 418 headerHeight, 419 focusSearchInput, 420}: { 421 query: string 422 queryWithParams: string 423 headerHeight: number 424 focusSearchInput: () => void 425}): React.ReactNode => { 426 const t = useTheme() 427 const setMinimalShellMode = useSetMinimalShellMode() 428 const {hasSession} = useSession() 429 const {gtTablet} = useBreakpoints() 430 const [activeTab, setActiveTab] = useState(0) 431 const {_} = useLingui() 432 433 const onPageSelected = useCallback( 434 (index: number) => { 435 setMinimalShellMode(false) 436 setActiveTab(index) 437 }, 438 [setMinimalShellMode], 439 ) 440 441 return queryWithParams ? ( 442 <SearchResults 443 query={query} 444 queryWithParams={queryWithParams} 445 activeTab={activeTab} 446 headerHeight={headerHeight} 447 onPageSelected={onPageSelected} 448 /> 449 ) : hasSession ? ( 450 <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} /> 451 ) : ( 452 <Layout.Center> 453 <View style={a.flex_1}> 454 {gtTablet && ( 455 <View 456 style={[ 457 a.border_b, 458 t.atoms.border_contrast_low, 459 a.px_lg, 460 a.pt_sm, 461 a.pb_lg, 462 ]}> 463 <Text style={[a.text_2xl, a.font_bold]}> 464 <Trans>Search</Trans> 465 </Text> 466 </View> 467 )} 468 469 <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}> 470 <MagnifyingGlassIcon 471 strokeWidth={3} 472 size={60} 473 style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>} 474 /> 475 <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 476 <Trans>Find posts, users, and feeds on Bluesky</Trans> 477 </Text> 478 </View> 479 </View> 480 </Layout.Center> 481 ) 482} 483SearchScreenInner = memo(SearchScreenInner) 484 485function useQueryManager({ 486 initialQuery, 487 fixedParams, 488}: { 489 initialQuery: string 490 fixedParams?: Params 491}) { 492 const {query, params: initialParams} = useMemo(() => { 493 return parseSearchQuery(initialQuery || '') 494 }, [initialQuery]) 495 const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery) 496 const [lang, setLang] = useState(initialParams.lang || '') 497 498 if (initialQuery !== prevInitialQuery) { 499 // handle new queryParam change (from manual search entry) 500 setPrevInitialQuery(initialQuery) 501 setLang(initialParams.lang || '') 502 } 503 504 const params = useMemo( 505 () => ({ 506 // default stuff 507 ...initialParams, 508 // managed stuff 509 lang, 510 ...fixedParams, 511 }), 512 [lang, initialParams, fixedParams], 513 ) 514 const handlers = useMemo( 515 () => ({ 516 setLang, 517 }), 518 [setLang], 519 ) 520 521 return useMemo(() => { 522 return { 523 query, 524 queryWithParams: makeSearchQuery(query, params), 525 params: { 526 ...params, 527 ...handlers, 528 }, 529 } 530 }, [query, params, handlers]) 531} 532 533function scrollToTopWeb() { 534 if (isWeb) { 535 window.scrollTo(0, 0) 536 } 537}