Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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