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