this repo has no description
1import {useCallback, useMemo, useState} from 'react'
2import {type ListRenderItemInfo, View} from 'react-native'
3import {type AppBskyFeedDefs} from '@atproto/api'
4import {msg} from '@lingui/core/macro'
5import {useLingui} from '@lingui/react'
6import {Trans} from '@lingui/react/macro'
7import {useFocusEffect} from '@react-navigation/native'
8import {type NativeStackScreenProps} from '@react-navigation/native-stack'
9
10import {HITSLOP_10} from '#/lib/constants'
11import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
12import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking'
13import {type CommonNavigatorParams} from '#/lib/routes/types'
14import {shareUrl} from '#/lib/sharing'
15import {cleanError} from '#/lib/strings/errors'
16import {sanitizeHandle} from '#/lib/strings/handles'
17import {enforceLen} from '#/lib/strings/helpers'
18import {useSearchPostsQuery} from '#/state/queries/search-posts'
19import {useSession} from '#/state/session'
20import {useSetMinimalShellMode} from '#/state/shell'
21import {useLoggedOutViewControls} from '#/state/shell/logged-out'
22import {useCloseAllActiveElements} from '#/state/util'
23import {Pager} from '#/view/com/pager/Pager'
24import {TabBar} from '#/view/com/pager/TabBar'
25import {Post} from '#/view/com/post/Post'
26import {List} from '#/view/com/util/List'
27import {atoms as a, useTheme, web} from '#/alf'
28import {Button, ButtonIcon} from '#/components/Button'
29import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
30import * as Layout from '#/components/Layout'
31import {InlineLinkText} from '#/components/Link'
32import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
33import {SearchError} from '#/components/SearchError'
34import {Text} from '#/components/Typography'
35
36const renderItem = ({item}: ListRenderItemInfo<AppBskyFeedDefs.PostView>) => {
37 return <Post post={item} />
38}
39
40const keyExtractor = (item: AppBskyFeedDefs.PostView, index: number) => {
41 return `${item.uri}-${index}`
42}
43
44export default function HashtagScreen({
45 route,
46}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
47 const {tag, author} = route.params
48 const {_} = useLingui()
49
50 const decodedTag = useMemo(() => {
51 return decodeURIComponent(tag)
52 }, [tag])
53
54 const isCashtag = decodedTag.startsWith('$')
55
56 const fullTag = useMemo(() => {
57 // Cashtags already include the $ prefix, hashtags need # added
58 return isCashtag ? decodedTag : `#${decodedTag}`
59 }, [decodedTag, isCashtag])
60
61 const headerTitle = useMemo(() => {
62 // Keep cashtags uppercase, lowercase hashtags
63 const displayTag = isCashtag ? fullTag.toUpperCase() : fullTag.toLowerCase()
64 return enforceLen(displayTag, 24, true, 'middle')
65 }, [fullTag, isCashtag])
66
67 const sanitizedAuthor = useMemo(() => {
68 if (!author) return ''
69 return sanitizeHandle(author)
70 }, [author])
71
72 const onShare = useCallback(() => {
73 const url = new URL('https://bsky.app')
74 url.pathname = `/hashtag/${decodeURIComponent(tag)}`
75 if (author) {
76 url.searchParams.set('author', author)
77 }
78 shareUrl(url.toString())
79 }, [tag, author])
80
81 const [activeTab, setActiveTab] = useState(0)
82 const setMinimalShellMode = useSetMinimalShellMode()
83
84 useFocusEffect(
85 useCallback(() => {
86 setMinimalShellMode(false)
87 }, [setMinimalShellMode]),
88 )
89
90 const onPageSelected = useCallback(
91 (index: number) => {
92 setMinimalShellMode(false)
93 setActiveTab(index)
94 },
95 [setMinimalShellMode],
96 )
97
98 const sections = useMemo(() => {
99 return [
100 {
101 title: _(msg`Top`),
102 component: (
103 <HashtagScreenTab
104 fullTag={fullTag}
105 author={author}
106 sort="top"
107 active={activeTab === 0}
108 />
109 ),
110 },
111 {
112 title: _(msg`Latest`),
113 component: (
114 <HashtagScreenTab
115 fullTag={fullTag}
116 author={author}
117 sort="latest"
118 active={activeTab === 1}
119 />
120 ),
121 },
122 ]
123 }, [_, fullTag, author, activeTab])
124
125 return (
126 <Layout.Screen>
127 <Pager
128 onPageSelected={onPageSelected}
129 renderTabBar={props => (
130 <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}>
131 <Layout.Header.Outer noBottomBorder>
132 <Layout.Header.BackButton />
133 <Layout.Header.Content>
134 <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
135 {author && (
136 <Layout.Header.SubtitleText>
137 {_(msg`From @${sanitizedAuthor}`)}
138 </Layout.Header.SubtitleText>
139 )}
140 </Layout.Header.Content>
141 <Layout.Header.Slot>
142 <Button
143 label={_(msg`Share`)}
144 size="small"
145 variant="ghost"
146 color="primary"
147 shape="round"
148 onPress={onShare}
149 hitSlop={HITSLOP_10}
150 style={[{right: -3}]}>
151 <ButtonIcon icon={Share} size="md" />
152 </Button>
153 </Layout.Header.Slot>
154 </Layout.Header.Outer>
155 <TabBar items={sections.map(section => section.title)} {...props} />
156 </Layout.Center>
157 )}
158 initialPage={0}>
159 {sections.map((section, i) => (
160 <View key={i}>{section.component}</View>
161 ))}
162 </Pager>
163 </Layout.Screen>
164 )
165}
166
167function HashtagScreenTab({
168 fullTag,
169 author,
170 sort,
171 active,
172}: {
173 fullTag: string
174 author: string | undefined
175 sort: 'top' | 'latest'
176 active: boolean
177}) {
178 const {_} = useLingui()
179 const initialNumToRender = useInitialNumToRender()
180 const [isPTR, setIsPTR] = useState(false)
181 const t = useTheme()
182 const {hasSession} = useSession()
183 const trackPostView = usePostViewTracking('Hashtag')
184
185 const isCashtag = fullTag.startsWith('$')
186
187 const queryParam = useMemo(() => {
188 // Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author"
189 const searchTag = isCashtag ? `#${fullTag}` : fullTag
190 if (!author) return searchTag
191 return `${searchTag} from:${author}`
192 }, [fullTag, author, isCashtag])
193
194 const {
195 data,
196 isFetched,
197 isFetchingNextPage,
198 isLoading,
199 isError,
200 error,
201 refetch,
202 fetchNextPage,
203 hasNextPage,
204 } = useSearchPostsQuery({query: queryParam, sort, enabled: active})
205
206 const posts = useMemo(() => {
207 return data?.pages.flatMap(page => page.posts) || []
208 }, [data])
209
210 const onRefresh = useCallback(async () => {
211 setIsPTR(true)
212 await refetch()
213 setIsPTR(false)
214 }, [refetch])
215
216 const onEndReached = useCallback(() => {
217 if (isFetchingNextPage || !hasNextPage || error) return
218 fetchNextPage()
219 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
220
221 const closeAllActiveElements = useCloseAllActiveElements()
222 const {requestSwitchToAccount} = useLoggedOutViewControls()
223
224 const showSignIn = () => {
225 closeAllActiveElements()
226 requestSwitchToAccount({requestedAccount: 'none'})
227 }
228
229 const showCreateAccount = () => {
230 closeAllActiveElements()
231 requestSwitchToAccount({requestedAccount: 'new'})
232 }
233
234 if (!hasSession) {
235 return (
236 <SearchError
237 title={_(msg`Search is currently unavailable when logged out`)}>
238 <Text style={[a.text_md, a.text_center, a.leading_snug]}>
239 <Trans>
240 <InlineLinkText
241 label={_(msg`Sign in`)}
242 to={'#'}
243 onPress={showSignIn}>
244 Sign in
245 </InlineLinkText>
246 <Text style={t.atoms.text_contrast_medium}> or </Text>
247 <InlineLinkText
248 label={_(msg`Create an account`)}
249 to={'#'}
250 onPress={showCreateAccount}>
251 create an account
252 </InlineLinkText>
253 <Text> </Text>
254 <Text style={t.atoms.text_contrast_medium}>
255 to search for news, sports, politics, and everything else
256 happening on Bluesky.
257 </Text>
258 </Trans>
259 </Text>
260 </SearchError>
261 )
262 }
263
264 return (
265 <>
266 {posts.length < 1 ? (
267 <ListMaybePlaceholder
268 isLoading={isLoading || !isFetched}
269 isError={isError}
270 onRetry={refetch}
271 emptyType="results"
272 emptyMessage={_(msg`We couldn't find any results for that tag.`)}
273 />
274 ) : (
275 <List
276 data={posts}
277 renderItem={renderItem}
278 keyExtractor={keyExtractor}
279 refreshing={isPTR}
280 onRefresh={onRefresh}
281 onEndReached={onEndReached}
282 onEndReachedThreshold={4}
283 onItemSeen={trackPostView}
284 // @ts-ignore web only -prf
285 desktopFixedHeight
286 ListFooterComponent={
287 <ListFooter
288 isFetchingNextPage={isFetchingNextPage}
289 error={cleanError(error)}
290 onRetry={fetchNextPage}
291 />
292 }
293 initialNumToRender={initialNumToRender}
294 windowSize={11}
295 />
296 )}
297 </>
298 )
299}