forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useImperativeHandle, useState} from 'react'
2import {
3 findNodeHandle,
4 type ListRenderItemInfo,
5 type StyleProp,
6 useWindowDimensions,
7 View,
8 type ViewStyle,
9} from 'react-native'
10import {type AppBskyGraphDefs} from '@atproto/api'
11import {msg} from '@lingui/core/macro'
12import {useLingui} from '@lingui/react'
13import {Trans} from '@lingui/react/macro'
14import {useNavigation} from '@react-navigation/native'
15
16import {useGenerateStarterPackMutation} from '#/lib/generate-starterpack'
17import {useBottomBarOffset} from '#/lib/hooks/useBottomBarOffset'
18import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
19import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
20import {type NavigationProp} from '#/lib/routes/types'
21import {parseStarterPackUri} from '#/lib/strings/starter-pack'
22import {logger} from '#/logger'
23import {useActorStarterPacksQuery} from '#/state/queries/actor-starter-packs'
24import {
25 EmptyState,
26 type EmptyStateButtonProps,
27} from '#/view/com/util/EmptyState'
28import {List, type ListRef} from '#/view/com/util/List'
29import {FeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder'
30import {atoms as a, ios, useTheme} from '#/alf'
31import {Button, ButtonIcon, ButtonText} from '#/components/Button'
32import {useDialogControl} from '#/components/Dialog'
33import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
34import {LinearGradientBackground} from '#/components/LinearGradientBackground'
35import {Loader} from '#/components/Loader'
36import * as Prompt from '#/components/Prompt'
37import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
38import {Text} from '#/components/Typography'
39import {IS_IOS} from '#/env'
40
41interface SectionRef {
42 scrollToTop: () => void
43}
44
45interface ProfileFeedgensProps {
46 ref?: React.Ref<SectionRef>
47 scrollElRef: ListRef
48 did: string
49 headerOffset: number
50 enabled?: boolean
51 style?: StyleProp<ViewStyle>
52 testID?: string
53 setScrollViewTag: (tag: number | null) => void
54 isMe: boolean
55 emptyStateMessage?: string
56 emptyStateButton?: EmptyStateButtonProps
57 emptyStateIcon?: React.ComponentType<any> | React.ReactElement
58}
59
60function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
61 return item.uri
62}
63
64export function ProfileStarterPacks({
65 ref,
66 scrollElRef,
67 did,
68 headerOffset,
69 enabled,
70 style,
71 testID,
72 setScrollViewTag,
73 isMe,
74 emptyStateMessage,
75 emptyStateButton,
76 emptyStateIcon,
77}: ProfileFeedgensProps) {
78 const t = useTheme()
79 const bottomBarOffset = useBottomBarOffset(100)
80 const {height} = useWindowDimensions()
81 const [isPTRing, setIsPTRing] = useState(false)
82 const {
83 data,
84 refetch,
85 isError,
86 hasNextPage,
87 isFetchingNextPage,
88 fetchNextPage,
89 } = useActorStarterPacksQuery({did, enabled})
90 const {isTabletOrDesktop} = useWebMediaQueries()
91
92 const items = data?.pages.flatMap(page => page.starterPacks)
93 const {_} = useLingui()
94
95 const EmptyComponent = useCallback(() => {
96 if (emptyStateMessage || emptyStateButton || emptyStateIcon) {
97 return (
98 <View style={[a.px_lg, a.align_center, a.justify_center]}>
99 <EmptyState
100 icon={emptyStateIcon}
101 iconSize="3xl"
102 message={
103 emptyStateMessage ??
104 _(
105 msg`Starter packs let you share your favorite feeds and people with your friends.`,
106 )
107 }
108 button={emptyStateButton}
109 />
110 </View>
111 )
112 }
113 return <Empty />
114 }, [_, emptyStateMessage, emptyStateButton, emptyStateIcon])
115
116 useImperativeHandle(ref, () => ({
117 scrollToTop: () => {},
118 }))
119
120 const onRefresh = useCallback(async () => {
121 setIsPTRing(true)
122 try {
123 await refetch()
124 } catch (err) {
125 logger.error('Failed to refresh starter packs', {message: err})
126 }
127 setIsPTRing(false)
128 }, [refetch, setIsPTRing])
129
130 const onEndReached = useCallback(async () => {
131 if (isFetchingNextPage || !hasNextPage || isError) return
132 try {
133 await fetchNextPage()
134 } catch (err) {
135 logger.error('Failed to load more starter packs', {message: err})
136 }
137 }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
138
139 useEffect(() => {
140 if (IS_IOS && enabled && scrollElRef.current) {
141 const nativeTag = findNodeHandle(scrollElRef.current)
142 setScrollViewTag(nativeTag)
143 }
144 }, [enabled, scrollElRef, setScrollViewTag])
145
146 const renderItem = useCallback(
147 ({item, index}: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
148 return (
149 <View
150 style={[
151 a.p_lg,
152 (isTabletOrDesktop || index !== 0) && a.border_t,
153 t.atoms.border_contrast_low,
154 ]}>
155 <StarterPackCard starterPack={item} />
156 </View>
157 )
158 },
159 [isTabletOrDesktop, t.atoms.border_contrast_low],
160 )
161
162 return (
163 <View testID={testID} style={style}>
164 <List
165 testID={testID ? `${testID}-flatlist` : undefined}
166 ref={scrollElRef}
167 data={items}
168 renderItem={renderItem}
169 keyExtractor={keyExtractor}
170 refreshing={isPTRing}
171 headerOffset={headerOffset}
172 progressViewOffset={ios(0)}
173 contentContainerStyle={{
174 minHeight: height + headerOffset,
175 paddingBottom: bottomBarOffset,
176 }}
177 removeClippedSubviews={true}
178 desktopFixedHeight
179 onEndReached={onEndReached}
180 onRefresh={onRefresh}
181 ListEmptyComponent={
182 data ? (isMe ? EmptyComponent : undefined) : FeedLoadingPlaceholder
183 }
184 ListFooterComponent={
185 !!data && items?.length !== 0 && isMe ? CreateAnother : undefined
186 }
187 />
188 </View>
189 )
190}
191
192function CreateAnother() {
193 const {_} = useLingui()
194 const t = useTheme()
195 const navigation = useNavigation<NavigationProp>()
196
197 return (
198 <View
199 style={[
200 a.pr_md,
201 a.pt_lg,
202 a.gap_lg,
203 a.border_t,
204 t.atoms.border_contrast_low,
205 ]}>
206 <Button
207 label={_(msg`Create a starter pack`)}
208 variant="solid"
209 color="secondary"
210 size="small"
211 style={[a.self_center]}
212 onPress={() => navigation.navigate('StarterPackWizard', {})}>
213 <ButtonText>
214 <Trans>Create another</Trans>
215 </ButtonText>
216 <ButtonIcon icon={Plus} position="right" />
217 </Button>
218 </View>
219 )
220}
221
222function Empty() {
223 const {_} = useLingui()
224 const navigation = useNavigation<NavigationProp>()
225 const confirmDialogControl = useDialogControl()
226 const followersDialogControl = useDialogControl()
227 const errorDialogControl = useDialogControl()
228 const requireEmailVerification = useRequireEmailVerification()
229
230 const [isGenerating, setIsGenerating] = useState(false)
231
232 const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
233 onSuccess: ({uri}) => {
234 const parsed = parseStarterPackUri(uri)
235 if (parsed) {
236 navigation.push('StarterPack', {
237 name: parsed.name,
238 rkey: parsed.rkey,
239 })
240 }
241 setIsGenerating(false)
242 },
243 onError: e => {
244 logger.error('Failed to generate starter pack', {safeMessage: e})
245 setIsGenerating(false)
246 if (e.message.includes('NOT_ENOUGH_FOLLOWERS')) {
247 followersDialogControl.open()
248 } else {
249 errorDialogControl.open()
250 }
251 },
252 })
253
254 const generate = () => {
255 setIsGenerating(true)
256 generateStarterPack()
257 }
258
259 const openConfirmDialog = useCallback(() => {
260 confirmDialogControl.open()
261 }, [confirmDialogControl])
262 const wrappedOpenConfirmDialog = requireEmailVerification(openConfirmDialog, {
263 instructions: [
264 <Trans key="confirm">
265 Before creating a starter pack, you must first verify your email.
266 </Trans>,
267 ],
268 })
269 const navToWizard = useCallback(() => {
270 navigation.navigate('StarterPackWizard', {})
271 }, [navigation])
272 const wrappedNavToWizard = requireEmailVerification(navToWizard, {
273 instructions: [
274 <Trans key="nav">
275 Before creating a starter pack, you must first verify your email.
276 </Trans>,
277 ],
278 })
279
280 return (
281 <LinearGradientBackground
282 style={[
283 a.px_lg,
284 a.py_lg,
285 a.justify_between,
286 a.gap_lg,
287 a.shadow_lg,
288 {marginTop: a.border.borderWidth},
289 ]}>
290 <View style={[a.gap_xs]}>
291 <Text style={[a.font_semi_bold, a.text_lg, {color: 'white'}]}>
292 <Trans>You haven't created a starter pack yet!</Trans>
293 </Text>
294 <Text style={[a.text_md, {color: 'white'}]}>
295 <Trans>
296 Starter packs let you easily share your favorite feeds and people
297 with your friends.
298 </Trans>
299 </Text>
300 </View>
301 <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
302 <Button
303 label={_(msg`Create a starter pack for me`)}
304 variant="ghost"
305 color="primary"
306 size="small"
307 disabled={isGenerating}
308 onPress={wrappedOpenConfirmDialog}
309 style={{backgroundColor: 'transparent'}}>
310 <ButtonText style={{color: 'white'}}>
311 <Trans>Make one for me</Trans>
312 </ButtonText>
313 {isGenerating && <Loader size="md" />}
314 </Button>
315 <Button
316 label={_(msg`Create a starter pack`)}
317 variant="ghost"
318 color="primary"
319 size="small"
320 disabled={isGenerating}
321 onPress={wrappedNavToWizard}
322 style={{
323 backgroundColor: 'white',
324 borderColor: 'white',
325 width: 100,
326 }}
327 hoverStyle={[{backgroundColor: '#dfdfdf'}]}>
328 <ButtonText>
329 <Trans>Create</Trans>
330 </ButtonText>
331 </Button>
332 </View>
333
334 <Prompt.Outer control={confirmDialogControl}>
335 <Prompt.Content>
336 <Prompt.TitleText>
337 <Trans>Generate a starter pack</Trans>
338 </Prompt.TitleText>
339 <Prompt.DescriptionText>
340 <Trans>
341 Bluesky will choose a set of recommended accounts from people in
342 your network.
343 </Trans>
344 </Prompt.DescriptionText>
345 </Prompt.Content>
346 <Prompt.Actions>
347 <Prompt.Action
348 color="primary"
349 cta={_(msg`Choose for me`)}
350 onPress={generate}
351 />
352 <Prompt.Action
353 color="secondary"
354 cta={_(msg`Let me choose`)}
355 onPress={() => {
356 navigation.navigate('StarterPackWizard', {})
357 }}
358 />
359 </Prompt.Actions>
360 </Prompt.Outer>
361 <Prompt.Basic
362 control={followersDialogControl}
363 title={_(msg`Oops!`)}
364 description={_(
365 msg`You must be following at least seven other people to generate a starter pack.`,
366 )}
367 onConfirm={() => {}}
368 showCancel={false}
369 />
370 <Prompt.Basic
371 control={errorDialogControl}
372 title={_(msg`Oops!`)}
373 description={_(
374 msg`An error occurred while generating your starter pack. Want to try again?`,
375 )}
376 onConfirm={generate}
377 confirmButtonCta={_(msg`Retry`)}
378 />
379 </LinearGradientBackground>
380 )
381}