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