about summary refs log tree commit diff
path: root/src/screens/StarterPack/Wizard/index.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/StarterPack/Wizard/index.tsx')
-rw-r--r--src/screens/StarterPack/Wizard/index.tsx575
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 ''
+}