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