forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 💫
1import {memo, useCallback, useMemo, useState} from 'react'
2import {ActivityIndicator, View} from 'react-native'
3import {type AppBskyFeedDefs} from '@atproto/api'
4import {Trans, useLingui} from '@lingui/react/macro'
5
6import {urls} from '#/lib/constants'
7import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
8import {useCallOnce} from '#/lib/once'
9import {cleanError} from '#/lib/strings/errors'
10import {augmentSearchQuery} from '#/lib/strings/helpers'
11import {useActorSearch} from '#/state/queries/actor-search'
12import {usePopularFeedsSearch} from '#/state/queries/feed'
13import {useSearchPostsQuery} from '#/state/queries/search-posts'
14import {useSession} from '#/state/session'
15import {useLoggedOutViewControls} from '#/state/shell/logged-out'
16import {useCloseAllActiveElements} from '#/state/util'
17import {Pager} from '#/view/com/pager/Pager'
18import {TabBar} from '#/view/com/pager/TabBar'
19import {Post} from '#/view/com/post/Post'
20import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard'
21import {List} from '#/view/com/util/List'
22import {atoms as a, useTheme, web} from '#/alf'
23import * as FeedCard from '#/components/FeedCard'
24import * as Layout from '#/components/Layout'
25import {InlineLinkText} from '#/components/Link'
26import {ListFooter} from '#/components/Lists'
27import {SearchError} from '#/components/SearchError'
28import {Text} from '#/components/Typography'
29import {type Metrics, useAnalytics} from '#/analytics'
30import type * as bsky from '#/types/bsky'
31
32let SearchResults = ({
33 query,
34 queryWithParams,
35 activeTab,
36 onPageSelected,
37 headerHeight,
38 initialPage = 0,
39}: {
40 query: string
41 queryWithParams: string
42 activeTab: number
43 onPageSelected: (page: number) => void
44 headerHeight: number
45 initialPage?: number
46}): React.ReactNode => {
47 const {t: l} = useLingui()
48
49 const sections = useMemo(() => {
50 if (!queryWithParams) return []
51 const noParams = queryWithParams === query
52 return [
53 {
54 title: l`Top`,
55 component: (
56 <SearchScreenPostResults
57 query={queryWithParams}
58 sort="top"
59 active={activeTab === 0}
60 />
61 ),
62 },
63 {
64 title: l`Latest`,
65 component: (
66 <SearchScreenPostResults
67 query={queryWithParams}
68 sort="latest"
69 active={activeTab === 1}
70 />
71 ),
72 },
73 noParams && {
74 title: l`People`,
75 component: (
76 <SearchScreenUserResults query={query} active={activeTab === 2} />
77 ),
78 },
79 noParams && {
80 title: l`Feeds`,
81 component: (
82 <SearchScreenFeedsResults query={query} active={activeTab === 3} />
83 ),
84 },
85 ].filter(Boolean) as {
86 title: string
87 component: React.ReactNode
88 }[]
89 }, [l, query, queryWithParams, activeTab])
90
91 // There may be fewer tabs after changing the search options.
92 const selectedPage = initialPage > sections.length - 1 ? 0 : initialPage
93
94 return (
95 <Pager
96 onPageSelected={onPageSelected}
97 renderTabBar={props => (
98 <Layout.Center style={[a.z_10, web([a.sticky, {top: headerHeight}])]}>
99 <TabBar items={sections.map(section => section.title)} {...props} />
100 </Layout.Center>
101 )}
102 initialPage={selectedPage}>
103 {sections.map((section, i) => (
104 <View key={i}>{section.component}</View>
105 ))}
106 </Pager>
107 )
108}
109SearchResults = memo(SearchResults)
110export {SearchResults}
111
112function Loader() {
113 const t = useTheme()
114
115 return (
116 <Layout.Content>
117 <View style={[a.py_xl]}>
118 <ActivityIndicator color={t.palette.primary_500} />
119 </View>
120 </Layout.Content>
121 )
122}
123
124function EmptyState({
125 messageText,
126 error,
127 children,
128}: {
129 messageText: React.ReactNode
130 error?: string
131 children?: React.ReactNode
132}) {
133 const t = useTheme()
134
135 return (
136 <Layout.Content>
137 <View style={[a.p_xl]}>
138 <View style={[t.atoms.bg_contrast_25, a.rounded_sm, a.p_lg]}>
139 <Text style={[a.text_md]}>{messageText}</Text>
140
141 {error && (
142 <>
143 <View
144 style={[
145 {
146 marginVertical: 12,
147 height: 1,
148 width: '100%',
149 backgroundColor: t.atoms.text.color,
150 opacity: 0.2,
151 },
152 ]}
153 />
154
155 <Text style={[t.atoms.text_contrast_medium]}>
156 <Trans>Error: {error}</Trans>
157 </Text>
158 </>
159 )}
160
161 {children}
162 </View>
163 </View>
164 </Layout.Content>
165 )
166}
167
168function NoResultsText({
169 query,
170}: {
171 sort?: 'top' | 'latest' | 'people' | 'feeds'
172 query: string
173}) {
174 const t = useTheme()
175 const {t: l} = useLingui()
176
177 return (
178 <>
179 <Text style={[a.text_lg, t.atoms.text_contrast_high]}>
180 <Trans>
181 No results found for “
182 <Text style={[a.text_lg, t.atoms.text, a.font_medium]}>{query}</Text>
183 ”.
184 </Trans>
185 </Text>
186 {'\n\n'}
187 <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
188 <Trans context="english-only-resource">
189 Try a different search term, or{' '}
190 <InlineLinkText
191 label={l({
192 message: 'read about how to use search filters',
193 context: 'english-only-resource',
194 })}
195 to={urls.website.blog.searchTipsAndTricks}
196 style={[a.text_md, a.leading_snug]}>
197 read about how to use search filters
198 </InlineLinkText>
199 .
200 </Trans>
201 </Text>
202 </>
203 )
204}
205
206type SearchResultSlice =
207 | {
208 type: 'post'
209 key: string
210 post: AppBskyFeedDefs.PostView
211 }
212 | {
213 type: 'loadingMore'
214 key: string
215 }
216
217let SearchScreenPostResults = ({
218 query,
219 sort,
220 active,
221}: {
222 query: string
223 sort?: 'top' | 'latest'
224 active: boolean
225}): React.ReactNode => {
226 const ax = useAnalytics()
227 const {t: l} = useLingui()
228 const {currentAccount, hasSession} = useSession()
229 const [isPTR, setIsPTR] = useState(false)
230 const trackPostView = usePostViewTracking('SearchResults')
231
232 const augmentedQuery = useMemo(() => {
233 return augmentSearchQuery(query || '', {did: currentAccount?.did})
234 }, [query, currentAccount])
235
236 const {
237 isFetched,
238 data: results,
239 isFetching,
240 error,
241 refetch,
242 fetchNextPage,
243 isFetchingNextPage,
244 hasNextPage,
245 } = useSearchPostsQuery({query: augmentedQuery, sort, enabled: active})
246
247 const t = useTheme()
248 const onPullToRefresh = useCallback(async () => {
249 setIsPTR(true)
250 await refetch()
251 setIsPTR(false)
252 }, [setIsPTR, refetch])
253 const onEndReached = useCallback(() => {
254 if (isFetching || !hasNextPage || error) return
255 void fetchNextPage()
256 }, [isFetching, error, hasNextPage, fetchNextPage])
257
258 const posts = useMemo(() => {
259 return results?.pages.flatMap(page => page.posts) || []
260 }, [results])
261 const items = useMemo(() => {
262 let temp: SearchResultSlice[] = []
263
264 const seenUris = new Set()
265 for (const post of posts) {
266 if (seenUris.has(post.uri)) {
267 continue
268 }
269 temp.push({
270 type: 'post',
271 key: post.uri,
272 post,
273 })
274 seenUris.add(post.uri)
275 }
276
277 if (isFetchingNextPage) {
278 temp.push({
279 type: 'loadingMore',
280 key: 'loadingMore',
281 })
282 }
283
284 return temp
285 }, [posts, isFetchingNextPage])
286
287 const closeAllActiveElements = useCloseAllActiveElements()
288 const {requestSwitchToAccount} = useLoggedOutViewControls()
289
290 const fireTracking = useCallOnce(() => {
291 if (sort) {
292 // ts only
293 ax.metric('search:results:loaded', {
294 tab: sort,
295 initialCount: items.length,
296 })
297 }
298 })
299 if (isFetched && sort) {
300 fireTracking()
301 }
302
303 const showSignIn = () => {
304 closeAllActiveElements()
305 requestSwitchToAccount({requestedAccount: 'none'})
306 }
307
308 const showCreateAccount = () => {
309 closeAllActiveElements()
310 requestSwitchToAccount({requestedAccount: 'new'})
311 }
312
313 if (!hasSession) {
314 return (
315 <SearchError title={l`Search is currently unavailable when logged out`}>
316 <Text style={[a.text_md, a.text_center, a.leading_snug]}>
317 <Trans>
318 <InlineLinkText label={l`Sign in`} to="#" onPress={showSignIn}>
319 Sign in
320 </InlineLinkText>
321 <Text style={t.atoms.text_contrast_medium}> or </Text>
322 <InlineLinkText
323 label={l`Create an account`}
324 to={'#'}
325 onPress={showCreateAccount}>
326 create an account
327 </InlineLinkText>
328 <Text> </Text>
329 <Text style={t.atoms.text_contrast_medium}>
330 to search for news, sports, politics, and everything else
331 happening on Bluesky.
332 </Text>
333 </Trans>
334 </Text>
335 </SearchError>
336 )
337 }
338
339 return error ? (
340 <EmptyState
341 messageText={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`}
342 error={cleanError(error)}
343 />
344 ) : (
345 <>
346 {isFetched ? (
347 <>
348 {posts.length ? (
349 <List
350 data={items}
351 renderItem={({
352 item,
353 index,
354 }: {
355 item: SearchResultSlice
356 index: number
357 }) => {
358 if (item.type === 'post') {
359 return (
360 <SearchPost from={sort} position={index} post={item.post} />
361 )
362 } else {
363 return null
364 }
365 }}
366 keyExtractor={(item: SearchResultSlice) => item.key}
367 refreshing={isPTR}
368 onRefresh={() => {
369 void onPullToRefresh()
370 }}
371 onEndReached={onEndReached}
372 onItemSeen={(item: SearchResultSlice) => {
373 if (item.type === 'post') {
374 trackPostView(item.post)
375 }
376 }}
377 desktopFixedHeight
378 ListFooterComponent={
379 <ListFooter
380 isFetchingNextPage={isFetchingNextPage}
381 hasNextPage={hasNextPage}
382 />
383 }
384 />
385 ) : (
386 <EmptyState messageText={<NoResultsText query={query} />} />
387 )}
388 </>
389 ) : (
390 <Loader />
391 )}
392 </>
393 )
394}
395SearchScreenPostResults = memo(SearchScreenPostResults)
396
397function SearchPost({
398 from,
399 position,
400 post,
401}: {
402 from: Metrics['search:result:press']['tab']
403 position: Metrics['search:result:press']['position']
404 post: AppBskyFeedDefs.PostView
405}) {
406 const ax = useAnalytics()
407
408 const onBeforePress = useCallback(() => {
409 ax.metric('search:result:press', {
410 tab: from,
411 resultType: 'post',
412 position,
413 uri: post.uri,
414 })
415 }, [ax, from, position, post])
416
417 return <Post post={post} onBeforePress={onBeforePress} />
418}
419
420let SearchScreenUserResults = ({
421 query,
422 active,
423}: {
424 query: string
425 active: boolean
426}): React.ReactNode => {
427 const ax = useAnalytics()
428 const {t: l} = useLingui()
429 const {hasSession} = useSession()
430 const [isPTR, setIsPTR] = useState(false)
431
432 const {
433 isFetched,
434 data: results,
435 isFetching,
436 error,
437 refetch,
438 fetchNextPage,
439 isFetchingNextPage,
440 hasNextPage,
441 } = useActorSearch({
442 query,
443 enabled: active,
444 })
445
446 const onPullToRefresh = useCallback(async () => {
447 setIsPTR(true)
448 await refetch()
449 setIsPTR(false)
450 }, [setIsPTR, refetch])
451 const onEndReached = useCallback(() => {
452 if (!hasSession) return
453 if (isFetching || !hasNextPage || error) return
454 void fetchNextPage()
455 }, [isFetching, error, hasNextPage, fetchNextPage, hasSession])
456
457 const profiles = useMemo(() => {
458 return results?.pages.flatMap(page => page.actors) || []
459 }, [results])
460
461 const fireTracking = useCallOnce(() => {
462 ax.metric('search:results:loaded', {
463 tab: 'people',
464 initialCount: profiles.length,
465 })
466 })
467 if (isFetched) {
468 fireTracking()
469 }
470
471 if (error) {
472 return (
473 <EmptyState
474 messageText={l`We’re sorry, but your search could not be completed. Please try again in a few minutes.`}
475 error={error.toString()}
476 />
477 )
478 }
479
480 return isFetched && profiles ? (
481 <>
482 {profiles.length ? (
483 <List
484 data={profiles}
485 renderItem={({
486 item,
487 index,
488 }: {
489 item: bsky.profile.AnyProfileView
490 index: number
491 }) => <SearchScreenProfileButton position={index} profile={item} />}
492 keyExtractor={(item: bsky.profile.AnyProfileView) => item.did}
493 refreshing={isPTR}
494 onRefresh={() => void onPullToRefresh()}
495 onEndReached={onEndReached}
496 desktopFixedHeight
497 ListFooterComponent={
498 <ListFooter
499 hasNextPage={hasNextPage && hasSession}
500 isFetchingNextPage={isFetchingNextPage}
501 />
502 }
503 />
504 ) : (
505 <EmptyState messageText={<NoResultsText query={query} />} />
506 )}
507 </>
508 ) : (
509 <Loader />
510 )
511}
512SearchScreenUserResults = memo(SearchScreenUserResults)
513
514function SearchScreenProfileButton({
515 position,
516 profile,
517}: {
518 position: number
519 profile: bsky.profile.AnyProfileView
520}) {
521 const ax = useAnalytics()
522
523 const handlePress = () => {
524 ax.metric('search:result:press', {
525 tab: 'people',
526 resultType: 'profile',
527 position,
528 uri: profile.did,
529 })
530 }
531 return <ProfileCardWithFollowBtn profile={profile} onPress={handlePress} />
532}
533
534let SearchScreenFeedsResults = ({
535 query,
536 active,
537}: {
538 query: string
539 active: boolean
540}): React.ReactNode => {
541 const ax = useAnalytics()
542 const t = useTheme()
543
544 const {data: results, isFetched} = usePopularFeedsSearch({
545 query,
546 enabled: active,
547 })
548
549 const fireTracking = useCallOnce(() => {
550 ax.metric('search:results:loaded', {
551 tab: 'feeds',
552 initialCount: results?.length ?? 0,
553 })
554 })
555 if (isFetched) {
556 fireTracking()
557 }
558
559 return isFetched && results ? (
560 <>
561 {results.length ? (
562 <List
563 data={results}
564 renderItem={({
565 item,
566 index,
567 }: {
568 item: AppBskyFeedDefs.GeneratorView
569 index: number
570 }) => (
571 <View
572 style={[
573 a.border_t,
574 t.atoms.border_contrast_low,
575 a.px_lg,
576 a.py_lg,
577 ]}>
578 <SearchFeedCard position={index} view={item} />
579 </View>
580 )}
581 keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri}
582 desktopFixedHeight
583 ListFooterComponent={<ListFooter />}
584 />
585 ) : (
586 <EmptyState messageText={<NoResultsText query={query} />} />
587 )}
588 </>
589 ) : (
590 <Loader />
591 )
592}
593SearchScreenFeedsResults = memo(SearchScreenFeedsResults)
594
595function SearchFeedCard({
596 position,
597 view,
598}: {
599 position: number
600 view: AppBskyFeedDefs.GeneratorView
601}) {
602 const ax = useAnalytics()
603
604 const handleOnPress = () => {
605 ax.metric('search:result:press', {
606 tab: 'feeds',
607 resultType: 'feed',
608 position,
609 uri: view.uri,
610 })
611 }
612
613 return <FeedCard.Default view={view} onPress={handleOnPress} />
614}