Bluesky app fork with some witchin' additions 馃挮
at readme-update 378 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, 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}