Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 381 lines 11 kB view raw
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}