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 fullTag = React.useMemo(() => {
50 return `#${decodeURIComponent(tag)}`
51 }, [tag])
52
53 const headerTitle = React.useMemo(() => {
54 return enforceLen(fullTag.toLowerCase(), 24, true, 'middle')
55 }, [fullTag])
56
57 const sanitizedAuthor = React.useMemo(() => {
58 if (!author) return
59 return sanitizeHandle(author)
60 }, [author])
61
62 const onShare = React.useCallback(() => {
63 const url = new URL('https://bsky.app')
64 url.pathname = `/hashtag/${decodeURIComponent(tag)}`
65 if (author) {
66 url.searchParams.set('author', author)
67 }
68 shareUrl(url.toString())
69 }, [tag, author])
70
71 const [activeTab, setActiveTab] = React.useState(0)
72 const setMinimalShellMode = useSetMinimalShellMode()
73
74 useFocusEffect(
75 React.useCallback(() => {
76 setMinimalShellMode(false)
77 }, [setMinimalShellMode]),
78 )
79
80 const onPageSelected = React.useCallback(
81 (index: number) => {
82 setMinimalShellMode(false)
83 setActiveTab(index)
84 },
85 [setMinimalShellMode],
86 )
87
88 const sections = React.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="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] = React.useState(false)
171 const t = useTheme()
172 const {hasSession} = useSession()
173 const trackPostView = usePostViewTracking('Hashtag')
174
175 const queryParam = React.useMemo(() => {
176 if (!author) return fullTag
177 return `${fullTag} from:${author}`
178 }, [fullTag, author])
179
180 const {
181 data,
182 isFetched,
183 isFetchingNextPage,
184 isLoading,
185 isError,
186 error,
187 refetch,
188 fetchNextPage,
189 hasNextPage,
190 } = useSearchPostsQuery({query: queryParam, sort, enabled: active})
191
192 const posts = React.useMemo(() => {
193 return data?.pages.flatMap(page => page.posts) || []
194 }, [data])
195
196 const onRefresh = React.useCallback(async () => {
197 setIsPTR(true)
198 await refetch()
199 setIsPTR(false)
200 }, [refetch])
201
202 const onEndReached = React.useCallback(() => {
203 if (isFetchingNextPage || !hasNextPage || error) return
204 fetchNextPage()
205 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
206
207 const closeAllActiveElements = useCloseAllActiveElements()
208 const {requestSwitchToAccount} = useLoggedOutViewControls()
209
210 const showSignIn = () => {
211 closeAllActiveElements()
212 requestSwitchToAccount({requestedAccount: 'none'})
213 }
214
215 const showCreateAccount = () => {
216 closeAllActiveElements()
217 requestSwitchToAccount({requestedAccount: 'new'})
218 }
219
220 if (!hasSession) {
221 return (
222 <SearchError
223 title={_(msg`Search is currently unavailable when logged out`)}>
224 <Text style={[a.text_md, a.text_center, a.leading_snug]}>
225 <Trans>
226 <InlineLinkText
227 label={_(msg`Sign in`)}
228 to={'#'}
229 onPress={showSignIn}>
230 Sign in
231 </InlineLinkText>
232 <Text style={t.atoms.text_contrast_medium}> or </Text>
233 <InlineLinkText
234 label={_(msg`Create an account`)}
235 to={'#'}
236 onPress={showCreateAccount}>
237 create an account
238 </InlineLinkText>
239 <Text> </Text>
240 <Text style={t.atoms.text_contrast_medium}>
241 to search for news, sports, politics, and everything else
242 happening on Bluesky.
243 </Text>
244 </Trans>
245 </Text>
246 </SearchError>
247 )
248 }
249
250 return (
251 <>
252 {posts.length < 1 ? (
253 <ListMaybePlaceholder
254 isLoading={isLoading || !isFetched}
255 isError={isError}
256 onRetry={refetch}
257 emptyType="results"
258 emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
259 />
260 ) : (
261 <List
262 data={posts}
263 renderItem={renderItem}
264 keyExtractor={keyExtractor}
265 refreshing={isPTR}
266 onRefresh={onRefresh}
267 onEndReached={onEndReached}
268 onEndReachedThreshold={4}
269 onItemSeen={trackPostView}
270 // @ts-ignore web only -prf
271 desktopFixedHeight
272 ListFooterComponent={
273 <ListFooter
274 isFetchingNextPage={isFetchingNextPage}
275 error={cleanError(error)}
276 onRetry={fetchNextPage}
277 />
278 }
279 initialNumToRender={initialNumToRender}
280 windowSize={11}
281 />
282 )}
283 </>
284 )
285}