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