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