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