mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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 {isWeb} from '#/platform/detection'
26import {listenSoftReset} from '#/state/events'
27import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
28import {
29 unstableCacheProfileView,
30 useProfilesQuery,
31} from '#/state/queries/profile'
32import {useSession} from '#/state/session'
33import {useSetMinimalShellMode} from '#/state/shell'
34import {
35 makeSearchQuery,
36 type Params,
37 parseSearchQuery,
38} from '#/screens/Search/utils'
39import {atoms as a, tokens, useBreakpoints, useTheme, web} from '#/alf'
40import {Button, ButtonText} from '#/components/Button'
41import {SearchInput} from '#/components/forms/SearchInput'
42import * as Layout from '#/components/Layout'
43import {Text} from '#/components/Typography'
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 (isWeb) {
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 (isWeb) {
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 (isWeb) {
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 (isWeb) {
192 // Empty params resets the URL to be /search rather than /search?q=
193
194 const {q: _q, ...parameters} = (route.params ?? {}) as {
195 [key: string]: string
196 }
197 // @ts-expect-error route is not typesafe
198 navigation.replace(route.name, parameters)
199 } else {
200 setSearchText('')
201 navigation.setParams({q: ''})
202 }
203 }, [setShowAutocomplete, setSearchText, navigation, route.params, route.name])
204
205 const onSubmit = useCallback(() => {
206 navigateToItem(searchText)
207 }, [navigateToItem, searchText])
208
209 const onAutocompleteResultPress = useCallback(() => {
210 if (isWeb) {
211 setShowAutocomplete(false)
212 } else {
213 textInput.current?.blur()
214 }
215 }, [])
216
217 const handleHistoryItemClick = useCallback(
218 (item: string) => {
219 setSearchText(item)
220 navigateToItem(item)
221 },
222 [navigateToItem],
223 )
224
225 const handleProfileClick = useCallback(
226 (profile: bsky.profile.AnyProfileView) => {
227 unstableCacheProfileView(queryClient, profile)
228 // Slight delay to avoid updating during push nav animation.
229 setTimeout(() => {
230 updateProfileHistory(profile)
231 }, 400)
232 },
233 [updateProfileHistory, queryClient],
234 )
235
236 const onSoftReset = useCallback(() => {
237 if (isWeb) {
238 // Empty params resets the URL to be /search rather than /search?q=
239
240 const {q: _q, ...parameters} = (route.params ?? {}) as {
241 [key: string]: string
242 }
243 // @ts-expect-error route is not typesafe
244 navigation.replace(route.name, parameters)
245 } else {
246 setSearchText('')
247 navigation.setParams({q: ''})
248 textInput.current?.focus()
249 }
250 }, [navigation, route])
251
252 useFocusEffect(
253 useCallback(() => {
254 setMinimalShellMode(false)
255 return listenSoftReset(onSoftReset)
256 }, [onSoftReset, setMinimalShellMode]),
257 )
258
259 const onSearchInputFocus = useCallback(() => {
260 if (isWeb) {
261 // Prevent a jump on iPad by ensuring that
262 // the initial focused render has no result list.
263 requestAnimationFrame(() => {
264 setShowAutocomplete(true)
265 })
266 } else {
267 setShowAutocomplete(true)
268 }
269 }, [setShowAutocomplete])
270
271 const focusSearchInput = useCallback(() => {
272 textInput.current?.focus()
273 }, [])
274
275 const showHeader = !gtMobile || navButton !== 'menu'
276
277 return (
278 <Layout.Screen testID={testID}>
279 <View
280 ref={headerRef}
281 onLayout={evt => {
282 if (isWeb) setHeaderHeight(evt.nativeEvent.layout.height)
283 }}
284 style={[
285 a.relative,
286 a.z_10,
287 web({
288 position: 'sticky',
289 top: 0,
290 }),
291 ]}>
292 <Layout.Center style={t.atoms.bg}>
293 {showHeader && (
294 <View
295 // HACK: shift up search input. we can't remove the top padding
296 // on the search input because it messes up the layout animation
297 // if we add it only when the header is hidden
298 style={{marginBottom: tokens.space.xs * -1}}>
299 <Layout.Header.Outer noBottomBorder>
300 {navButton === 'menu' ? (
301 <Layout.Header.MenuButton />
302 ) : (
303 <Layout.Header.BackButton />
304 )}
305 <Layout.Header.Content align="left">
306 <Layout.Header.TitleText>
307 {isExplore ? <Trans>Explore</Trans> : <Trans>Search</Trans>}
308 </Layout.Header.TitleText>
309 </Layout.Header.Content>
310 {showFilters ? (
311 <SearchLanguageDropdown
312 value={params.lang}
313 onChange={params.setLang}
314 />
315 ) : (
316 <Layout.Header.Slot />
317 )}
318 </Layout.Header.Outer>
319 </View>
320 )}
321 <View style={[a.px_lg, a.pt_sm, a.pb_sm, a.overflow_hidden]}>
322 <View style={[a.gap_sm]}>
323 <View style={[a.w_full, a.flex_row, a.align_stretch, a.gap_xs]}>
324 <View style={[a.flex_1]}>
325 <SearchInput
326 ref={textInput}
327 value={searchText}
328 onFocus={onSearchInputFocus}
329 onChangeText={onChangeText}
330 onClearText={onPressClearQuery}
331 onSubmitEditing={onSubmit}
332 placeholder={
333 inputPlaceholder ??
334 _(msg`Search for posts, users, or feeds`)
335 }
336 hitSlop={{...HITSLOP_20, top: 0}}
337 />
338 </View>
339 {showAutocomplete && (
340 <Button
341 label={_(msg`Cancel search`)}
342 size="large"
343 variant="ghost"
344 color="secondary"
345 style={[a.px_sm]}
346 onPress={onPressCancelSearch}
347 hitSlop={HITSLOP_10}>
348 <ButtonText>
349 <Trans>Cancel</Trans>
350 </ButtonText>
351 </Button>
352 )}
353 </View>
354
355 {showFilters && !showHeader && (
356 <View
357 style={[
358 a.flex_row,
359 a.align_center,
360 a.justify_between,
361 a.gap_sm,
362 ]}>
363 <SearchLanguageDropdown
364 value={params.lang}
365 onChange={params.setLang}
366 />
367 </View>
368 )}
369 </View>
370 </View>
371 </Layout.Center>
372 </View>
373
374 <View
375 style={{
376 display: showAutocomplete && !fixedParams ? 'flex' : 'none',
377 flex: 1,
378 }}>
379 {searchText.length > 0 ? (
380 <AutocompleteResults
381 isAutocompleteFetching={isAutocompleteFetching}
382 autocompleteData={autocompleteData}
383 searchText={searchText}
384 onSubmit={onSubmit}
385 onResultPress={onAutocompleteResultPress}
386 onProfileClick={handleProfileClick}
387 />
388 ) : (
389 <SearchHistory
390 searchHistory={termHistory}
391 selectedProfiles={accountHistoryProfiles?.profiles || []}
392 onItemClick={handleHistoryItemClick}
393 onProfileClick={handleProfileClick}
394 onRemoveItemClick={deleteSearchHistoryItem}
395 onRemoveProfileClick={deleteProfileHistoryItem}
396 />
397 )}
398 </View>
399 <View
400 style={{
401 display: showAutocomplete ? 'none' : 'flex',
402 flex: 1,
403 }}>
404 <SearchScreenInner
405 query={query}
406 queryWithParams={queryWithParams}
407 headerHeight={headerHeight}
408 focusSearchInput={focusSearchInput}
409 />
410 </View>
411 </Layout.Screen>
412 )
413}
414
415let SearchScreenInner = ({
416 query,
417 queryWithParams,
418 headerHeight,
419 focusSearchInput,
420}: {
421 query: string
422 queryWithParams: string
423 headerHeight: number
424 focusSearchInput: () => void
425}): React.ReactNode => {
426 const t = useTheme()
427 const setMinimalShellMode = useSetMinimalShellMode()
428 const {hasSession} = useSession()
429 const {gtTablet} = useBreakpoints()
430 const [activeTab, setActiveTab] = useState(0)
431 const {_} = useLingui()
432
433 const onPageSelected = useCallback(
434 (index: number) => {
435 setMinimalShellMode(false)
436 setActiveTab(index)
437 },
438 [setMinimalShellMode],
439 )
440
441 return queryWithParams ? (
442 <SearchResults
443 query={query}
444 queryWithParams={queryWithParams}
445 activeTab={activeTab}
446 headerHeight={headerHeight}
447 onPageSelected={onPageSelected}
448 />
449 ) : hasSession ? (
450 <Explore focusSearchInput={focusSearchInput} headerHeight={headerHeight} />
451 ) : (
452 <Layout.Center>
453 <View style={a.flex_1}>
454 {gtTablet && (
455 <View
456 style={[
457 a.border_b,
458 t.atoms.border_contrast_low,
459 a.px_lg,
460 a.pt_sm,
461 a.pb_lg,
462 ]}>
463 <Text style={[a.text_2xl, a.font_bold]}>
464 <Trans>Search</Trans>
465 </Text>
466 </View>
467 )}
468
469 <View style={[a.align_center, a.justify_center, a.py_4xl, a.gap_lg]}>
470 <MagnifyingGlassIcon
471 strokeWidth={3}
472 size={60}
473 style={t.atoms.text_contrast_medium as StyleProp<ViewStyle>}
474 />
475 <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
476 <Trans>Find posts, users, and feeds on Bluesky</Trans>
477 </Text>
478 </View>
479 </View>
480 </Layout.Center>
481 )
482}
483SearchScreenInner = memo(SearchScreenInner)
484
485function useQueryManager({
486 initialQuery,
487 fixedParams,
488}: {
489 initialQuery: string
490 fixedParams?: Params
491}) {
492 const {query, params: initialParams} = useMemo(() => {
493 return parseSearchQuery(initialQuery || '')
494 }, [initialQuery])
495 const [prevInitialQuery, setPrevInitialQuery] = useState(initialQuery)
496 const [lang, setLang] = useState(initialParams.lang || '')
497
498 if (initialQuery !== prevInitialQuery) {
499 // handle new queryParam change (from manual search entry)
500 setPrevInitialQuery(initialQuery)
501 setLang(initialParams.lang || '')
502 }
503
504 const params = useMemo(
505 () => ({
506 // default stuff
507 ...initialParams,
508 // managed stuff
509 lang,
510 ...fixedParams,
511 }),
512 [lang, initialParams, fixedParams],
513 )
514 const handlers = useMemo(
515 () => ({
516 setLang,
517 }),
518 [setLang],
519 )
520
521 return useMemo(() => {
522 return {
523 query,
524 queryWithParams: makeSearchQuery(query, params),
525 params: {
526 ...params,
527 ...handlers,
528 },
529 }
530 }, [query, params, handlers])
531}
532
533function scrollToTopWeb() {
534 if (isWeb) {
535 window.scrollTo(0, 0)
536 }
537}