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