forked from
jollywhoppers.com/witchsky.app
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 {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 hotkey={true}
384 />
385 </View>
386 {showAutocomplete && (
387 <Button
388 label={l`Cancel search`}
389 size="large"
390 variant="ghost"
391 color="secondary"
392 shape="rectangular"
393 style={[a.px_sm]}
394 onPress={onPressCancelSearch}
395 hitSlop={HITSLOP_10}>
396 <ButtonText>
397 <Trans>Cancel</Trans>
398 </ButtonText>
399 </Button>
400 )}
401 </View>
402
403 {showFilters && !showHeader && (
404 <View
405 style={[
406 a.flex_row,
407 a.align_center,
408 a.justify_between,
409 a.gap_sm,
410 ]}>
411 <SearchLanguageDropdown
412 value={params.lang}
413 onChange={params.setLang}
414 />
415 </View>
416 )}
417 </View>
418 </View>
419 </Layout.Center>
420 </View>
421
422 <View
423 style={{
424 display: showAutocomplete && !fixedParams ? 'flex' : 'none',
425 flex: 1,
426 }}>
427 {searchText.length > 0 ? (
428 <AutocompleteResults
429 isAutocompleteFetching={isAutocompleteFetching}
430 autocompleteData={autocompleteData}
431 searchText={searchText}
432 onSubmit={onSubmit('autocomplete')}
433 onResultPress={onAutocompleteResultPress}
434 onProfileClick={handleProfileClick}
435 />
436 ) : (
437 <SearchHistory
438 searchHistory={termHistory}
439 selectedProfiles={accountHistoryProfiles?.profiles || []}
440 onItemClick={handleHistoryItemClick}
441 onProfileClick={handleProfileClick}
442 onRemoveItemClick={deleteSearchHistoryItem}
443 onRemoveProfileClick={deleteProfileHistoryItem}
444 />
445 )}
446 </View>
447 <View
448 style={{
449 display: showAutocomplete ? 'none' : 'flex',
450 flex: 1,
451 }}>
452 <SearchScreenInner
453 key={params.lang}
454 activeTab={activeTab}
455 setActiveTab={setActiveTab}
456 query={query}
457 queryWithParams={queryWithParams}
458 headerHeight={headerHeight}
459 focusSearchInput={focusSearchInput}
460 />
461 </View>
462 </Layout.Screen>
463 )
464}
465
466let SearchScreenInner = ({
467 activeTab,
468 setActiveTab,
469 query,
470 queryWithParams,
471 headerHeight,
472 focusSearchInput,
473}: {
474 activeTab: number
475 setActiveTab: React.Dispatch<React.SetStateAction<number>>
476 query: string
477 queryWithParams: string
478 headerHeight: number
479 focusSearchInput: (tab?: TabParam) => void
480}): React.ReactNode => {
481 const t = useTheme()
482 const setMinimalShellMode = useSetMinimalShellMode()
483 const {hasSession} = useSession()
484 const {gtTablet} = useBreakpoints()
485
486 const onPageSelected = useCallback(
487 (index: number) => {
488 setMinimalShellMode(false)
489 setActiveTab(index)
490 },
491 [setActiveTab, setMinimalShellMode],
492 )
493
494 return queryWithParams ? (
495 <SearchResults
496 query={query}
497 queryWithParams={queryWithParams}
498 activeTab={activeTab}
499 headerHeight={headerHeight}
500 onPageSelected={onPageSelected}
501 initialPage={activeTab}
502 />
503 ) : hasSession ? (
504 <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
505 ) : (
506 <Layout.Center>
507 <View style={a.flex_1}>
508 {gtTablet && (
509 <View
510 style={[
511 a.border_b,
512 t.atoms.border_contrast_low,
513 a.px_lg,
514 a.pt_sm,
515 a.pb_lg,
516 ]}>
517 <Text style={[a.text_2xl, a.font_bold]}>
518 <Trans>Search</Trans>
519 </Text>
520 </View>
521 )}
522
523 <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
524 <MagnifyingGlassIcon
525 strokeWidth={3}
526 size={60}
527 style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
528 />
529 <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
530 <Trans>Find posts, users, and feeds on Witchsky</Trans>
531 </Text>
532 </View>
533 </View>
534 </Layout.Center>
535 )
536}
537SearchScreenInner = memo(SearchScreenInner)
538
539function useQueryManager({
540 initialQuery,
541 fixedParams,
542}: {
543 initialQuery: string
544 fixedParams?: Params
545}) {
546 const {query, params: initialParams} = useMemo(() => {
547 return parseSearchQuery(initialQuery || '')
548 }, [initialQuery])
549 const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
550 const [lang, setLang] = useState(initialParams.lang || '')
551
552 if (initialQuery !== prevInitialQuery) {
553 // handle new queryParam change (from manual search entry)
554 setPrevInitialQuery(initialQuery)
555 setLang(initialParams.lang || '')
556 }
557
558 const params = useMemo(
559 () => ({
560 // default stuff
561 ...initialParams,
562 // managed stuff
563 lang,
564 ...fixedParams,
565 }),
566 [lang, initialParams, fixedParams],
567 )
568 const handlers = useMemo(
569 () => ({
570 setLang,
571 }),
572 [setLang],
573 )
574
575 return useMemo(() => {
576 return {
577 query,
578 queryWithParams: makeSearchQuery(query, params),
579 params: {
580 ...params,
581 ...handlers,
582 },
583 }
584 }, [query, params, handlers])
585}
586
587function scrollToTopWeb() {
588 if (IS_WEB) {
589 window.scrollTo(0, 0)
590 }
591}