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 />
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}