diff options
Diffstat (limited to 'src/screens/StarterPack/Wizard/index.tsx')
-rw-r--r-- | src/screens/StarterPack/Wizard/index.tsx | 575 |
1 files changed, 575 insertions, 0 deletions
diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx new file mode 100644 index 000000000..76691dc98 --- /dev/null +++ b/src/screens/StarterPack/Wizard/index.tsx @@ -0,0 +1,575 @@ +import React from 'react' +import {Keyboard, TouchableOpacity, View} from 'react-native' +import { + KeyboardAwareScrollView, + useKeyboardController, +} from 'react-native-keyboard-controller' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {Image} from 'expo-image' +import { + AppBskyActorDefs, + AppBskyGraphDefs, + AtUri, + ModerationOpts, +} from '@atproto/api' +import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect, useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' + +import {logger} from '#/logger' +import {HITSLOP_10} from 'lib/constants' +import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types' +import {logEvent} from 'lib/statsig/statsig' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {sanitizeHandle} from 'lib/strings/handles' +import {enforceLen} from 'lib/strings/helpers' +import { + getStarterPackOgCard, + parseStarterPackUri, +} from 'lib/strings/starter-pack' +import {isAndroid, isNative, isWeb} from 'platform/detection' +import {useModerationOpts} from 'state/preferences/moderation-opts' +import {useListMembersQuery} from 'state/queries/list-members' +import {useProfileQuery} from 'state/queries/profile' +import { + useCreateStarterPackMutation, + useEditStarterPackMutation, + useStarterPackQuery, +} from 'state/queries/starter-packs' +import {useSession} from 'state/session' +import {useSetMinimalShellMode} from 'state/shell' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from 'view/com/util/UserAvatar' +import {CenteredView} from 'view/com/util/Views' +import {useWizardState, WizardStep} from '#/screens/StarterPack/Wizard/State' +import {StepDetails} from '#/screens/StarterPack/Wizard/StepDetails' +import {StepFeeds} from '#/screens/StarterPack/Wizard/StepFeeds' +import {StepProfiles} from '#/screens/StarterPack/Wizard/StepProfiles' +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {useDialogControl} from '#/components/Dialog' +import {ListMaybePlaceholder} from '#/components/Lists' +import {Loader} from '#/components/Loader' +import {WizardEditListDialog} from '#/components/StarterPack/Wizard/WizardEditListDialog' +import {Text} from '#/components/Typography' +import {Provider} from './State' + +export function Wizard({ + route, +}: NativeStackScreenProps< + CommonNavigatorParams, + 'StarterPackEdit' | 'StarterPackWizard' +>) { + const {rkey} = route.params ?? {} + const {currentAccount} = useSession() + const moderationOpts = useModerationOpts() + + const {_} = useLingui() + + const { + data: starterPack, + isLoading: isLoadingStarterPack, + isError: isErrorStarterPack, + } = useStarterPackQuery({did: currentAccount!.did, rkey}) + const listUri = starterPack?.list?.uri + + const { + data: profilesData, + isLoading: isLoadingProfiles, + isError: isErrorProfiles, + } = useListMembersQuery(listUri, 50) + const listItems = profilesData?.pages.flatMap(p => p.items) + + const { + data: profile, + isLoading: isLoadingProfile, + isError: isErrorProfile, + } = useProfileQuery({did: currentAccount?.did}) + + const isEdit = Boolean(rkey) + const isReady = + (!isEdit || (isEdit && starterPack && listItems)) && + profile && + moderationOpts + + if (!isReady) { + return ( + <ListMaybePlaceholder + isLoading={ + isLoadingStarterPack || isLoadingProfiles || isLoadingProfile + } + isError={isErrorStarterPack || isErrorProfiles || isErrorProfile} + errorMessage={_(msg`That starter pack could not be found.`)} + /> + ) + } else if (isEdit && starterPack?.creator.did !== currentAccount?.did) { + return ( + <ListMaybePlaceholder + isLoading={false} + isError={true} + errorMessage={_(msg`That starter pack could not be found.`)} + /> + ) + } + + return ( + <Provider starterPack={starterPack} listItems={listItems}> + <WizardInner + currentStarterPack={starterPack} + currentListItems={listItems} + profile={profile} + moderationOpts={moderationOpts} + /> + </Provider> + ) +} + +function WizardInner({ + currentStarterPack, + currentListItems, + profile, + moderationOpts, +}: { + currentStarterPack?: AppBskyGraphDefs.StarterPackView + currentListItems?: AppBskyGraphDefs.ListItemView[] + profile: AppBskyActorDefs.ProfileViewBasic + moderationOpts: ModerationOpts +}) { + const navigation = useNavigation<NavigationProp>() + const {_} = useLingui() + const t = useTheme() + const setMinimalShellMode = useSetMinimalShellMode() + const {setEnabled} = useKeyboardController() + const [state, dispatch] = useWizardState() + const {currentAccount} = useSession() + const {data: currentProfile} = useProfileQuery({ + did: currentAccount?.did, + staleTime: 0, + }) + const parsed = parseStarterPackUri(currentStarterPack?.uri) + + React.useEffect(() => { + navigation.setOptions({ + gestureEnabled: false, + }) + }, [navigation]) + + useFocusEffect( + React.useCallback(() => { + setEnabled(true) + setMinimalShellMode(true) + + return () => { + setMinimalShellMode(false) + setEnabled(false) + } + }, [setMinimalShellMode, setEnabled]), + ) + + const getDefaultName = () => { + let displayName + if ( + currentProfile?.displayName != null && + currentProfile?.displayName !== '' + ) { + displayName = sanitizeDisplayName(currentProfile.displayName) + } else { + displayName = sanitizeHandle(currentProfile!.handle) + } + return _(msg`${displayName}'s Starter Pack`).slice(0, 50) + } + + const wizardUiStrings: Record< + WizardStep, + {header: string; nextBtn: string; subtitle?: string} + > = { + Details: { + header: _(msg`Starter Pack`), + nextBtn: _(msg`Next`), + }, + Profiles: { + header: _(msg`People`), + nextBtn: _(msg`Next`), + subtitle: _( + msg`Add people to your starter pack that you think others will enjoy following`, + ), + }, + Feeds: { + header: _(msg`Feeds`), + nextBtn: state.feeds.length === 0 ? _(msg`Skip`) : _(msg`Finish`), + subtitle: _(msg`Some subtitle`), + }, + } + const currUiStrings = wizardUiStrings[state.currentStep] + + const onSuccessCreate = (data: {uri: string; cid: string}) => { + const rkey = new AtUri(data.uri).rkey + logEvent('starterPack:create', { + setName: state.name != null, + setDescription: state.description != null, + profilesCount: state.profiles.length, + feedsCount: state.feeds.length, + }) + Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)]) + dispatch({type: 'SetProcessing', processing: false}) + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey, + new: true, + }) + } + + const onSuccessEdit = () => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.replace('StarterPack', { + name: currentAccount!.handle, + rkey: parsed!.rkey, + }) + } + } + + const {mutate: createStarterPack} = useCreateStarterPackMutation({ + onSuccess: onSuccessCreate, + onError: e => { + logger.error('Failed to create starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`)) + }, + }) + const {mutate: editStarterPack} = useEditStarterPackMutation({ + onSuccess: onSuccessEdit, + onError: e => { + logger.error('Failed to edit starter pack', {safeMessage: e}) + dispatch({type: 'SetProcessing', processing: false}) + Toast.show(_(msg`Failed to create starter pack`)) + }, + }) + + const submit = async () => { + dispatch({type: 'SetProcessing', processing: true}) + if (currentStarterPack && currentListItems) { + editStarterPack({ + name: state.name ?? getDefaultName(), + description: state.description, + descriptionFacets: [], + profiles: state.profiles, + feeds: state.feeds, + currentStarterPack: currentStarterPack, + currentListItems: currentListItems, + }) + } else { + createStarterPack({ + name: state.name ?? getDefaultName(), + description: state.description, + descriptionFacets: [], + profiles: state.profiles, + feeds: state.feeds, + }) + } + } + + const onNext = () => { + if (state.currentStep === 'Feeds') { + submit() + return + } + + const keyboardVisible = Keyboard.isVisible() + Keyboard.dismiss() + setTimeout( + () => { + dispatch({type: 'Next'}) + }, + keyboardVisible ? 16 : 0, + ) + } + + return ( + <CenteredView style={[a.flex_1]} sideBorders> + <View + style={[ + a.flex_row, + a.pb_sm, + a.px_md, + a.border_b, + t.atoms.border_contrast_medium, + a.gap_sm, + a.justify_between, + a.align_center, + isAndroid && a.pt_sm, + isWeb && [a.py_md], + ]}> + <View style={[{width: 65}]}> + <TouchableOpacity + testID="viewHeaderDrawerBtn" + hitSlop={HITSLOP_10} + accessibilityRole="button" + accessibilityLabel={_(msg`Back`)} + accessibilityHint={_(msg`Go back to the previous step`)} + onPress={() => { + if (state.currentStep === 'Details') { + navigation.pop() + } else { + dispatch({type: 'Back'}) + } + }}> + <FontAwesomeIcon + size={18} + icon="angle-left" + color={t.atoms.text.color} + /> + </TouchableOpacity> + </View> + <Text style={[a.flex_1, a.font_bold, a.text_lg, a.text_center]}> + {currUiStrings.header} + </Text> + <View style={[{width: 65}]} /> + </View> + + <Container> + {state.currentStep === 'Details' ? ( + <StepDetails /> + ) : state.currentStep === 'Profiles' ? ( + <StepProfiles moderationOpts={moderationOpts} /> + ) : state.currentStep === 'Feeds' ? ( + <StepFeeds moderationOpts={moderationOpts} /> + ) : null} + </Container> + + {state.currentStep !== 'Details' && ( + <Footer + onNext={onNext} + nextBtnText={currUiStrings.nextBtn} + moderationOpts={moderationOpts} + profile={profile} + /> + )} + </CenteredView> + ) +} + +function Container({children}: {children: React.ReactNode}) { + const {_} = useLingui() + const [state, dispatch] = useWizardState() + + if (state.currentStep === 'Profiles' || state.currentStep === 'Feeds') { + return <View style={[a.flex_1]}>{children}</View> + } + + return ( + <KeyboardAwareScrollView + style={[a.flex_1]} + keyboardShouldPersistTaps="handled"> + {children} + {state.currentStep === 'Details' && ( + <> + <Button + label={_(msg`Next`)} + variant="solid" + color="primary" + size="medium" + style={[a.mx_xl, a.mb_lg, {marginTop: 35}]} + onPress={() => dispatch({type: 'Next'})}> + <ButtonText> + <Trans>Next</Trans> + </ButtonText> + </Button> + </> + )} + </KeyboardAwareScrollView> + ) +} + +function Footer({ + onNext, + nextBtnText, + moderationOpts, + profile, +}: { + onNext: () => void + nextBtnText: string + moderationOpts: ModerationOpts + profile: AppBskyActorDefs.ProfileViewBasic +}) { + const {_} = useLingui() + const t = useTheme() + const [state, dispatch] = useWizardState() + const editDialogControl = useDialogControl() + const {bottom: bottomInset} = useSafeAreaInsets() + + const items = + state.currentStep === 'Profiles' + ? [profile, ...state.profiles] + : state.feeds + const initialNamesIndex = state.currentStep === 'Profiles' ? 1 : 0 + + const isEditEnabled = + (state.currentStep === 'Profiles' && items.length > 1) || + (state.currentStep === 'Feeds' && items.length > 0) + + const minimumItems = state.currentStep === 'Profiles' ? 8 : 0 + + const textStyles = [a.text_md] + + return ( + <View + style={[ + a.border_t, + a.align_center, + a.px_lg, + a.pt_xl, + a.gap_md, + t.atoms.bg, + t.atoms.border_contrast_medium, + { + paddingBottom: a.pb_lg.paddingBottom + bottomInset, + }, + isNative && [ + a.border_l, + a.border_r, + t.atoms.shadow_md, + { + borderTopLeftRadius: 14, + borderTopRightRadius: 14, + }, + ], + ]}> + {items.length > minimumItems && ( + <View style={[a.absolute, {right: 14, top: 31}]}> + <Text style={[a.font_bold]}> + {items.length}/{state.currentStep === 'Profiles' ? 50 : 3} + </Text> + </View> + )} + + <View style={[a.flex_row, a.gap_xs]}> + {items.slice(0, 6).map((p, index) => ( + <UserAvatar + key={index} + avatar={p.avatar} + size={32} + type={state.currentStep === 'Profiles' ? 'user' : 'algo'} + /> + ))} + </View> + + {items.length === 0 ? ( + <View style={[a.gap_sm]}> + <Text style={[a.font_bold, a.text_center, textStyles]}> + <Trans>Add some feeds to your starter pack!</Trans> + </Text> + <Text style={[a.text_center, textStyles]}> + <Trans>Search for feeds that you want to suggest to others.</Trans> + </Text> + </View> + ) : ( + <Text style={[a.text_center, textStyles]}> + {state.currentStep === 'Profiles' && items.length === 1 ? ( + <Trans> + It's just you right now! Add more people to your starter pack by + searching above. + </Trans> + ) : items.length === 1 ? ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex])} + </Text>{' '} + is included in your starter pack + </Trans> + ) : items.length === 2 ? ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex])}{' '} + </Text> + and + <Text> </Text> + <Text style={[a.font_bold, textStyles]}> + {getName(items[state.currentStep === 'Profiles' ? 0 : 1])}{' '} + </Text> + are included in your starter pack + </Trans> + ) : ( + <Trans> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex])},{' '} + </Text> + <Text style={[a.font_bold, textStyles]}> + {getName(items[initialNamesIndex + 1])},{' '} + </Text> + and {items.length - 2}{' '} + <Plural value={items.length - 2} one="other" other="others" /> are + included in your starter pack + </Trans> + )} + </Text> + )} + + <View + style={[ + a.flex_row, + a.w_full, + a.justify_between, + a.align_center, + isNative ? a.mt_sm : a.mt_md, + ]}> + {isEditEnabled ? ( + <Button + label={_(msg`Edit`)} + variant="solid" + color="secondary" + size="small" + style={{width: 70}} + onPress={editDialogControl.open}> + <ButtonText> + <Trans>Edit</Trans> + </ButtonText> + </Button> + ) : ( + <View style={{width: 70, height: 35}} /> + )} + {state.currentStep === 'Profiles' && items.length < 8 ? ( + <> + <Text + style={[a.font_bold, textStyles, t.atoms.text_contrast_medium]}> + <Trans>Add {8 - items.length} more to continue</Trans> + </Text> + <View style={{width: 70}} /> + </> + ) : ( + <Button + label={nextBtnText} + variant="solid" + color="primary" + size="small" + onPress={onNext} + disabled={!state.canNext || state.processing}> + <ButtonText>{nextBtnText}</ButtonText> + {state.processing && <Loader size="xs" style={{color: 'white'}} />} + </Button> + )} + </View> + + <WizardEditListDialog + control={editDialogControl} + state={state} + dispatch={dispatch} + moderationOpts={moderationOpts} + profile={profile} + /> + </View> + ) +} + +function getName(item: AppBskyActorDefs.ProfileViewBasic | GeneratorView) { + if (typeof item.displayName === 'string') { + return enforceLen(sanitizeDisplayName(item.displayName), 16, true) + } else if (typeof item.handle === 'string') { + return enforceLen(sanitizeHandle(item.handle), 16, true) + } + return '' +} |