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 {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
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 = React.useMemo(() => {
51 return decodeURIComponent(tag)
52 }, [tag])
53
54 const isCashtag = decodedTag.startsWith('$')
55
56 const fullTag = React.useMemo(() => {
57 // Cashtags already include the $ prefix, hashtags need # added
58 return isCashtag ? decodedTag : `#${decodedTag}`
59 }, [decodedTag, isCashtag])
60
61 const headerTitle = React.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 = React.useMemo(() => {
68 if (!author) return
69 return sanitizeHandle(author)
70 }, [author])
71
72 const onShare = React.useCallback(() => {
73 const url = new URL('https://witchsky.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] = React.useState(0)
82 const setMinimalShellMode = useSetMinimalShellMode()
83
84 const enableSquareButtons = useEnableSquareButtons()
85
86 useFocusEffect(
87 React.useCallback(() => {
88 setMinimalShellMode(false)
89 }, [setMinimalShellMode]),
90 )
91
92 const onPageSelected = React.useCallback(
93 (index: number) => {
94 setMinimalShellMode(false)
95 setActiveTab(index)
96 },
97 [setMinimalShellMode],
98 )
99
100 const sections = React.useMemo(() => {
101 return [
102 {
103 title: _(msg`Top`),
104 component: (
105 <HashtagScreenTab
106 fullTag={fullTag}
107 author={author}
108 sort="top"
109 active={activeTab === 0}
110 />
111 ),
112 },
113 {
114 title: _(msg`Latest`),
115 component: (
116 <HashtagScreenTab
117 fullTag={fullTag}
118 author={author}
119 sort="latest"
120 active={activeTab === 1}
121 />
122 ),
123 },
124 ]
125 }, [_, fullTag, author, activeTab])
126
127 return (
128 <Layout.Screen>
129 <Pager
130 onPageSelected={onPageSelected}
131 renderTabBar={props => (
132 <Layout.Center style={[a.z_10, web([a.sticky, {top: 0}])]}>
133 <Layout.Header.Outer noBottomBorder>
134 <Layout.Header.BackButton />
135 <Layout.Header.Content>
136 <Layout.Header.TitleText>{headerTitle}</Layout.Header.TitleText>
137 {author && (
138 <Layout.Header.SubtitleText>
139 {_(msg`From @${sanitizedAuthor}`)}
140 </Layout.Header.SubtitleText>
141 )}
142 </Layout.Header.Content>
143 <Layout.Header.Slot>
144 <Button
145 label={_(msg`Share`)}
146 size="small"
147 variant="ghost"
148 color="primary"
149 shape={enableSquareButtons ? 'square' : 'round'}
150 onPress={onShare}
151 hitSlop={HITSLOP_10}
152 style={[{right: -3}]}>
153 <ButtonIcon icon={Share} size="md" />
154 </Button>
155 </Layout.Header.Slot>
156 </Layout.Header.Outer>
157 <TabBar items={sections.map(section => section.title)} {...props} />
158 </Layout.Center>
159 )}
160 initialPage={0}>
161 {sections.map((section, i) => (
162 <View key={i}>{section.component}</View>
163 ))}
164 </Pager>
165 </Layout.Screen>
166 )
167}
168
169function HashtagScreenTab({
170 fullTag,
171 author,
172 sort,
173 active,
174}: {
175 fullTag: string
176 author: string | undefined
177 sort: 'top' | 'latest'
178 active: boolean
179}) {
180 const {_} = useLingui()
181 const initialNumToRender = useInitialNumToRender()
182 const [isPTR, setIsPTR] = React.useState(false)
183 const t = useTheme()
184 const {hasSession} = useSession()
185 const trackPostView = usePostViewTracking('Hashtag')
186
187 const isCashtag = fullTag.startsWith('$')
188
189 const queryParam = React.useMemo(() => {
190 // Cashtags need # prefix for search: "#$BTC" or "#$BTC from:author"
191 const searchTag = isCashtag ? `#${fullTag}` : fullTag
192 if (!author) return searchTag
193 return `${searchTag} from:${author}`
194 }, [fullTag, author, isCashtag])
195
196 const {
197 data,
198 isFetched,
199 isFetchingNextPage,
200 isLoading,
201 isError,
202 error,
203 refetch,
204 fetchNextPage,
205 hasNextPage,
206 } = useSearchPostsQuery({query: queryParam, sort, enabled: active})
207
208 const posts = React.useMemo(() => {
209 return data?.pages.flatMap(page => page.posts) || []
210 }, [data])
211
212 const onRefresh = React.useCallback(async () => {
213 setIsPTR(true)
214 await refetch()
215 setIsPTR(false)
216 }, [refetch])
217
218 const onEndReached = React.useCallback(() => {
219 if (isFetchingNextPage || !hasNextPage || error) return
220 fetchNextPage()
221 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
222
223 const closeAllActiveElements = useCloseAllActiveElements()
224 const {requestSwitchToAccount} = useLoggedOutViewControls()
225
226 const showSignIn = () => {
227 closeAllActiveElements()
228 requestSwitchToAccount({requestedAccount: 'none'})
229 }
230
231 const showCreateAccount = () => {
232 closeAllActiveElements()
233 requestSwitchToAccount({requestedAccount: 'new'})
234 }
235
236 if (!hasSession) {
237 return (
238 <SearchError
239 title={_(msg`Search is currently unavailable when logged out`)}>
240 <Text style={[a.text_md, a.text_center, a.leading_snug]}>
241 <Trans>
242 <InlineLinkText
243 label={_(msg`Sign in`)}
244 to={'#'}
245 onPress={showSignIn}>
246 Sign in
247 </InlineLinkText>
248 <Text style={t.atoms.text_contrast_medium}> or </Text>
249 <InlineLinkText
250 label={_(msg`Create an account`)}
251 to={'#'}
252 onPress={showCreateAccount}>
253 create an account
254 </InlineLinkText>
255 <Text> </Text>
256 <Text style={t.atoms.text_contrast_medium}>
257 to search for news, sports, politics, and everything else
258 happening on Bluesky.
259 </Text>
260 </Trans>
261 </Text>
262 </SearchError>
263 )
264 }
265
266 return (
267 <>
268 {posts.length < 1 ? (
269 <ListMaybePlaceholder
270 isLoading={isLoading || !isFetched}
271 isError={isError}
272 onRetry={refetch}
273 emptyType="results"
274 emptyMessage={_(msg`We couldn't find any results for that tag.`)}
275 />
276 ) : (
277 <List
278 data={posts}
279 renderItem={renderItem}
280 keyExtractor={keyExtractor}
281 refreshing={isPTR}
282 onRefresh={onRefresh}
283 onEndReached={onEndReached}
284 onEndReachedThreshold={4}
285 onItemSeen={trackPostView}
286 // @ts-ignore web only -prf
287 desktopFixedHeight
288 ListFooterComponent={
289 <ListFooter
290 isFetchingNextPage={isFetchingNextPage}
291 error={cleanError(error)}
292 onRetry={fetchNextPage}
293 />
294 }
295 initialNumToRender={initialNumToRender}
296 windowSize={11}
297 />
298 )}
299 </>
300 )
301}