Bluesky app fork with some witchin' additions 馃挮
at main 587 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 {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 {listenSoftReset} from '#/state/events' 26import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 27import { 28 unstableCacheProfileView, 29 useProfilesQuery, 30} from '#/state/queries/profile' 31import {useSession} from '#/state/session' 32import {useSetMinimalShellMode} from '#/state/shell' 33import { 34 makeSearchQuery, 35 type Params, 36 parseSearchQuery, 37} from '#/screens/Search/utils' 38import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf' 39import {Button, ButtonText} from '#/components/Button' 40import {SearchInput} from '#/components/forms/SearchInput' 41import * as Layout from '#/components/Layout' 42import {Text} from '#/components/Typography' 43import {IS_WEB} from '#/env' 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 (IS_WEB) { 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 (IS_WEB) { 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 (IS_WEB) { 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 (IS_WEB) { 192 // Empty params resets the URL to be /search rather than /search?q= 193 // Also clear the tab parameter 194 const { 195 q: _q, 196 tab: _tab, 197 ...parameters 198 } = (route.params ?? {}) as { 199 [key: string]: string 200 } 201 // @ts-expect-error route is not typesafe 202 navigation.replace(route.name, parameters) 203 } else { 204 setSearchText('') 205 navigation.setParams({q: '', tab: undefined}) 206 } 207 }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name]) 208 209 const onSubmit = useCallback(() => { 210 navigateToItem(searchText) 211 }, [navigateToItem, searchText]) 212 213 const onAutocompleteResultPress = useCallback(() => { 214 if (IS_WEB) { 215 setShowAutocomplete(false) 216 } else { 217 textInput.current?.blur() 218 } 219 }, []) 220 221 const handleHistoryItemClick = useCallback( 222 (item: string) => { 223 setSearchText(item) 224 navigateToItem(item) 225 }, 226 [navigateToItem], 227 ) 228 229 const handleProfileClick = useCallback( 230 (profile: bsky.profile.AnyProfileView) => { 231 unstableCacheProfileView(queryClient, profile) 232 // Slight delay to avoid updating during push nav animation. 233 setTimeout(() => { 234 updateProfileHistory(profile) 235 }, 400) 236 }, 237 [updateProfileHistory, queryClient], 238 ) 239 240 const onSoftReset = useCallback(() => { 241 if (IS_WEB) { 242 // Empty params resets the URL to be /search rather than /search?q= 243 // Also clear the tab parameter when soft resetting 244 const { 245 q: _q, 246 tab: _tab, 247 ...parameters 248 } = (route.params ?? {}) as { 249 [key: string]: string 250 } 251 // @ts-expect-error route is not typesafe 252 navigation.replace(route.name, parameters) 253 } else { 254 setSearchText('') 255 navigation.setParams({q: '', tab: undefined}) 256 textInput.current?.focus() 257 } 258 }, [navigation, route]) 259 260 useFocusEffect( 261 useCallback(() => { 262 setMinimalShellMode(false) 263 return listenSoftReset(onSoftReset) 264 }, [onSoftReset, setMinimalShellMode]), 265 ) 266 267 const onSearchInputFocus = useCallback(() => { 268 if (IS_WEB) { 269 // Prevent a jump on iPad by ensuring that 270 // the initial focused render has no result list. 271 requestAnimationFrame(() => { 272 setShowAutocomplete(true) 273 }) 274 } else { 275 setShowAutocomplete(true) 276 } 277 }, [setShowAutocomplete]) 278 279 const focusSearchInput = useCallback( 280 (tab?: 'user' | 'profile' | 'feed') => { 281 textInput.current?.focus() 282 283 // If a tab is specified, set the tab parameter 284 if (tab) { 285 if (IS_WEB) { 286 navigation.setParams({...route.params, tab}) 287 } else { 288 navigation.setParams({tab}) 289 } 290 } 291 }, 292 [navigation, route], 293 ) 294 295 const showHeader = !gtMobile || navButton !== 'menu' 296 297 return ( 298 <Layout.Screen testID={testID}> 299 <View 300 ref={headerRef} 301 onLayout={evt => { 302 if (IS_WEB) setHeaderHeight(evt.nativeEvent.layout.height) 303 }} 304 style={[ 305 a.relative, 306 a.z_10, 307 web({ 308 position: 'sticky', 309 top: 0, 310 }), 311 ]}> 312 <Layout.Center style={t.atoms.bg}> 313 {showHeader && ( 314 <View 315 // HACK: shift up search input. we can't remove the top padding 316 // on the search input because it messes up the layout animation 317 // if we add it only when the header is hidden 318 style={{marginBottom: tokens.space.xs * -1}}> 319 <Layout.Header.Outer noBottomBorder> 320 {navButton === 'menu' ? ( 321 <Layout.Header.MenuButton /> 322 ) : ( 323 <Layout.Header.BackButton /> 324 )} 325 <Layout.Header.Content align="left"> 326 <Layout.Header.TitleText> 327 {isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>} 328 </Layout.Header.TitleText> 329 </Layout.Header.Content> 330 {showFilters ? ( 331 <SearchLanguageDropdown 332 value={params.lang} 333 onChange={params.setLang} 334 /> 335 ) : ( 336 <Layout.Header.Slot /> 337 )} 338 </Layout.Header.Outer> 339 </View> 340 )} 341 <View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}> 342 <View style={[a.gap_sm]}> 343 <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}> 344 <View style={[a.flex_1]}> 345 <SearchInput 346 ref={textInput} 347 value={searchText} 348 onFocus={onSearchInputFocus} 349 onChangeText={onChangeText} 350 onClearText={onPressClearQuery} 351 onSubmitEditing={onSubmit} 352 placeholder={ 353 inputPlaceholder ?? 354 _(msg`Search for skeets, users, or feeds`) 355 } 356 hitSlop={{...HITSLOP_20, top: 0}} 357 /> 358 </View> 359 {showAutocomplete && ( 360 <Button 361 label={_(msg`Cancel search`)} 362 size="large" 363 variant="ghost" 364 color="secondary" 365 shape="rectangular" 366 style={[a.px_sm]} 367 onPress={onPressCancelSearch} 368 hitSlop={HITSLOP_10}> 369 <ButtonText> 370 <Trans>Cancel</Trans> 371 </ButtonText> 372 </Button> 373 )} 374 </View> 375 376 {showFilters && !showHeader && ( 377 <View 378 style={[ 379 a.flex_row, 380 a.align_center, 381 a.justify_between, 382 a.gap_sm, 383 ]}> 384 <SearchLanguageDropdown 385 value={params.lang} 386 onChange={params.setLang} 387 /> 388 </View> 389 )} 390 </View> 391 </View> 392 </Layout.Center> 393 </View> 394 395 <View 396 style={{ 397 display: showAutocomplete && !fixedParams ? 'flex' : 'none', 398 flex: 1, 399 }}> 400 {searchText.length > 0 ? ( 401 <AutocompleteResults 402 isAutocompleteFetching={isAutocompleteFetching} 403 autocompleteData={autocompleteData} 404 searchText={searchText} 405 onSubmit={onSubmit} 406 onResultPress={onAutocompleteResultPress} 407 onProfileClick={handleProfileClick} 408 /> 409 ) : ( 410 <SearchHistory 411 searchHistory={termHistory} 412 selectedProfiles={accountHistoryProfiles?.profiles || []} 413 onItemClick={handleHistoryItemClick} 414 onProfileClick={handleProfileClick} 415 onRemoveItemClick={deleteSearchHistoryItem} 416 onRemoveProfileClick={deleteProfileHistoryItem} 417 /> 418 )} 419 </View> 420 <View 421 style={{ 422 display: showAutocomplete ? 'none' : 'flex', 423 flex: 1, 424 }}> 425 <SearchScreenInner 426 query={query} 427 queryWithParams={queryWithParams} 428 headerHeight={headerHeight} 429 focusSearchInput={focusSearchInput} 430 /> 431 </View> 432 </Layout.Screen> 433 ) 434} 435 436let SearchScreenInner = ({ 437 query, 438 queryWithParams, 439 headerHeight, 440 focusSearchInput, 441}: { 442 query: string 443 queryWithParams: string 444 headerHeight: number 445 focusSearchInput: (tab?: 'user' | 'profile' | 'feed') => void 446}): React.ReactNode => { 447 const t = useTheme() 448 const setMinimalShellMode = useSetMinimalShellMode() 449 const {hasSession} = useSession() 450 const {gtTablet} = useBreakpoints() 451 const route = useRoute() 452 453 // Get tab parameter from route params 454 const tabParam = ( 455 route.params as {q?: string; tab?: 'user' | 'profile' | 'feed'} 456 )?.tab 457 458 // Map tab parameter to tab index 459 const getInitialTabIndex = useCallback(() => { 460 if (!tabParam) return 0 461 switch (tabParam) { 462 case 'user': 463 case 'profile': 464 return 2 // People tab 465 case 'feed': 466 return 3 // Feeds tab 467 default: 468 return 0 469 } 470 }, [tabParam]) 471 472 const [activeTab, setActiveTab] = useState(getInitialTabIndex()) 473 474 // Update activeTab when tabParam changes 475 useLayoutEffect(() => { 476 const newTabIndex = getInitialTabIndex() 477 if (newTabIndex !== activeTab) { 478 setActiveTab(newTabIndex) 479 } 480 }, [tabParam, activeTab, getInitialTabIndex]) 481 482 const onPageSelected = useCallback( 483 (index: number) => { 484 setMinimalShellMode(false) 485 setActiveTab(index) 486 }, 487 [setMinimalShellMode], 488 ) 489 490 return queryWithParams ? ( 491 <SearchResults 492 query={query} 493 queryWithParams={queryWithParams} 494 activeTab={activeTab} 495 headerHeight={headerHeight} 496 onPageSelected={onPageSelected} 497 initialPage={activeTab} 498 /> 499 ) : hasSession ? ( 500 <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} /> 501 ) : ( 502 <Layout.Center> 503 <View style={a.flex_1}> 504 {gtTablet && ( 505 <View 506 style={[ 507 a.border_b, 508 t.atoms.border_contrast_low, 509 a.px_lg, 510 a.pt_sm, 511 a.pb_lg, 512 ]}> 513 <Text style={[a.text_2xl, a.font_bold]}> 514 <Trans>Search</Trans> 515 </Text> 516 </View> 517 )} 518 519 <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}> 520 <MagnifyingGlassIcon 521 strokeWidth={3} 522 size={60} 523 style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>} 524 /> 525 <Text style={[t.atoms.text_contrast_medium, a.text_md]}> 526 <Trans>Find skeets, users, and feeds on Witchsky</Trans> 527 </Text> 528 </View> 529 </View> 530 </Layout.Center> 531 ) 532} 533SearchScreenInner = memo(SearchScreenInner) 534 535function useQueryManager({ 536 initialQuery, 537 fixedParams, 538}: { 539 initialQuery: string 540 fixedParams?: Params 541}) { 542 const {query, params: initialParams} = useMemo(() => { 543 return parseSearchQuery(initialQuery || '') 544 }, [initialQuery]) 545 const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery) 546 const [lang, setLang] = useState(initialParams.lang || '') 547 548 if (initialQuery !== prevInitialQuery) { 549 // handle new queryParam change (from manual search entry) 550 setPrevInitialQuery(initialQuery) 551 setLang(initialParams.lang || '') 552 } 553 554 const params = useMemo( 555 () => ({ 556 // default stuff 557 ...initialParams, 558 // managed stuff 559 lang, 560 ...fixedParams, 561 }), 562 [lang, initialParams, fixedParams], 563 ) 564 const handlers = useMemo( 565 () => ({ 566 setLang, 567 }), 568 [setLang], 569 ) 570 571 return useMemo(() => { 572 return { 573 query, 574 queryWithParams: makeSearchQuery(query, params), 575 params: { 576 ...params, 577 ...handlers, 578 }, 579 } 580 }, [query, params, handlers]) 581} 582 583function scrollToTopWeb() { 584 if (IS_WEB) { 585 window.scrollTo(0, 0) 586 } 587}