Bluesky app fork with some witchin' additions 馃挮
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}