about summary refs log tree commit diff
path: root/src/screens/StarterPack/Wizard
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/StarterPack/Wizard')
-rw-r--r--src/screens/StarterPack/Wizard/State.tsx163
-rw-r--r--src/screens/StarterPack/Wizard/StepDetails.tsx84
-rw-r--r--src/screens/StarterPack/Wizard/StepFeeds.tsx113
-rw-r--r--src/screens/StarterPack/Wizard/StepFinished.tsx0
-rw-r--r--src/screens/StarterPack/Wizard/StepProfiles.tsx101
-rw-r--r--src/screens/StarterPack/Wizard/index.tsx575
6 files changed, 1036 insertions, 0 deletions
diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx
new file mode 100644
index 000000000..ea9bbf9d3
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/State.tsx
@@ -0,0 +1,163 @@
+import React from 'react'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
+} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+
+import {useSession} from 'state/session'
+import * as Toast from '#/view/com/util/Toast'
+
+const steps = ['Details', 'Profiles', 'Feeds'] as const
+type Step = (typeof steps)[number]
+
+type Action =
+  | {type: 'Next'}
+  | {type: 'Back'}
+  | {type: 'SetCanNext'; canNext: boolean}
+  | {type: 'SetName'; name: string}
+  | {type: 'SetDescription'; description: string}
+  | {type: 'AddProfile'; profile: AppBskyActorDefs.ProfileViewBasic}
+  | {type: 'RemoveProfile'; profileDid: string}
+  | {type: 'AddFeed'; feed: GeneratorView}
+  | {type: 'RemoveFeed'; feedUri: string}
+  | {type: 'SetProcessing'; processing: boolean}
+  | {type: 'SetError'; error: string}
+
+interface State {
+  canNext: boolean
+  currentStep: Step
+  name?: string
+  description?: string
+  profiles: AppBskyActorDefs.ProfileViewBasic[]
+  feeds: GeneratorView[]
+  processing: boolean
+  error?: string
+  transitionDirection: 'Backward' | 'Forward'
+}
+
+type TStateContext = [State, (action: Action) => void]
+
+const StateContext = React.createContext<TStateContext>([
+  {} as State,
+  (_: Action) => {},
+])
+export const useWizardState = () => React.useContext(StateContext)
+
+function reducer(state: State, action: Action): State {
+  let updatedState = state
+
+  // -- Navigation
+  const currentIndex = steps.indexOf(state.currentStep)
+  if (action.type === 'Next' && state.currentStep !== 'Feeds') {
+    updatedState = {
+      ...state,
+      currentStep: steps[currentIndex + 1],
+      transitionDirection: 'Forward',
+    }
+  } else if (action.type === 'Back' && state.currentStep !== 'Details') {
+    updatedState = {
+      ...state,
+      currentStep: steps[currentIndex - 1],
+      transitionDirection: 'Backward',
+    }
+  }
+
+  switch (action.type) {
+    case 'SetName':
+      updatedState = {...state, name: action.name.slice(0, 50)}
+      break
+    case 'SetDescription':
+      updatedState = {...state, description: action.description}
+      break
+    case 'AddProfile':
+      if (state.profiles.length >= 51) {
+        Toast.show(msg`You may only add up to 50 profiles`.message ?? '')
+      } else {
+        updatedState = {...state, profiles: [...state.profiles, action.profile]}
+      }
+      break
+    case 'RemoveProfile':
+      updatedState = {
+        ...state,
+        profiles: state.profiles.filter(
+          profile => profile.did !== action.profileDid,
+        ),
+      }
+      break
+    case 'AddFeed':
+      if (state.feeds.length >= 50) {
+        Toast.show(msg`You may only add up to 50 feeds`.message ?? '')
+      } else {
+        updatedState = {...state, feeds: [...state.feeds, action.feed]}
+      }
+      break
+    case 'RemoveFeed':
+      updatedState = {
+        ...state,
+        feeds: state.feeds.filter(f => f.uri !== action.feedUri),
+      }
+      break
+    case 'SetProcessing':
+      updatedState = {...state, processing: action.processing}
+      break
+  }
+
+  return updatedState
+}
+
+// TODO supply the initial state to this component
+export function Provider({
+  starterPack,
+  listItems,
+  children,
+}: {
+  starterPack?: AppBskyGraphDefs.StarterPackView
+  listItems?: AppBskyGraphDefs.ListItemView[]
+  children: React.ReactNode
+}) {
+  const {currentAccount} = useSession()
+
+  const createInitialState = (): State => {
+    if (starterPack && AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+      return {
+        canNext: true,
+        currentStep: 'Details',
+        name: starterPack.record.name,
+        description: starterPack.record.description,
+        profiles:
+          listItems
+            ?.map(i => i.subject)
+            .filter(p => p.did !== currentAccount?.did) ?? [],
+        feeds: starterPack.feeds ?? [],
+        processing: false,
+        transitionDirection: 'Forward',
+      }
+    }
+
+    return {
+      canNext: true,
+      currentStep: 'Details',
+      profiles: [],
+      feeds: [],
+      processing: false,
+      transitionDirection: 'Forward',
+    }
+  }
+
+  const [state, dispatch] = React.useReducer(reducer, null, createInitialState)
+
+  return (
+    <StateContext.Provider value={[state, dispatch]}>
+      {children}
+    </StateContext.Provider>
+  )
+}
+
+export {
+  type Action as WizardAction,
+  type State as WizardState,
+  type Step as WizardStep,
+}
diff --git a/src/screens/StarterPack/Wizard/StepDetails.tsx b/src/screens/StarterPack/Wizard/StepDetails.tsx
new file mode 100644
index 000000000..24c992c60
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepDetails.tsx
@@ -0,0 +1,84 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useProfileQuery} from 'state/queries/profile'
+import {useSession} from 'state/session'
+import {useWizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
+import {Text} from '#/components/Typography'
+
+export function StepDetails() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+
+  const {currentAccount} = useSession()
+  const {data: currentProfile} = useProfileQuery({
+    did: currentAccount?.did,
+    staleTime: 300,
+  })
+
+  return (
+    <ScreenTransition direction={state.transitionDirection}>
+      <View style={[a.px_xl, a.gap_xl, a.mt_4xl]}>
+        <View style={[a.gap_md, a.align_center, a.px_md, a.mb_md]}>
+          <StarterPack width={90} gradient="sky" />
+          <Text style={[a.font_bold, a.text_3xl]}>
+            <Trans>Invites, but personal</Trans>
+          </Text>
+          <Text style={[a.text_center, a.text_md, a.px_md]}>
+            <Trans>
+              Invite your friends to follow your favorite feeds and people
+            </Trans>
+          </Text>
+        </View>
+        <View>
+          <TextField.LabelText>
+            <Trans>What do you want to call your starter pack?</Trans>
+          </TextField.LabelText>
+          <TextField.Root>
+            <TextField.Input
+              label={_(
+                msg`${
+                  currentProfile?.displayName || currentProfile?.handle
+                }'s starter pack`,
+              )}
+              value={state.name}
+              onChangeText={text => dispatch({type: 'SetName', name: text})}
+            />
+            <TextField.SuffixText label={_(`${state.name?.length} out of 50`)}>
+              <Text style={[t.atoms.text_contrast_medium]}>
+                {state.name?.length ?? 0}/50
+              </Text>
+            </TextField.SuffixText>
+          </TextField.Root>
+        </View>
+        <View>
+          <TextField.LabelText>
+            <Trans>Tell us a little more</Trans>
+          </TextField.LabelText>
+          <TextField.Root>
+            <TextField.Input
+              label={_(
+                msg`${
+                  currentProfile?.displayName || currentProfile?.handle
+                }'s favorite feeds and people - join me!`,
+              )}
+              value={state.description}
+              onChangeText={text =>
+                dispatch({type: 'SetDescription', description: text})
+              }
+              multiline
+              style={{minHeight: 150}}
+            />
+          </TextField.Root>
+        </View>
+      </View>
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/StarterPack/Wizard/StepFeeds.tsx b/src/screens/StarterPack/Wizard/StepFeeds.tsx
new file mode 100644
index 000000000..6752a95db
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepFeeds.tsx
@@ -0,0 +1,113 @@
+import React, {useState} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
+import {AppBskyFeedDefs, ModerationOpts} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {useA11y} from '#/state/a11y'
+import {DISCOVER_FEED_URI} from 'lib/constants'
+import {
+  useGetPopularFeedsQuery,
+  useSavedFeeds,
+  useSearchPopularFeedsQuery,
+} from 'state/queries/feed'
+import {SearchInput} from 'view/com/util/forms/SearchInput'
+import {List} from 'view/com/util/List'
+import {useWizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import {useThrottledValue} from '#/components/hooks/useThrottledValue'
+import {Loader} from '#/components/Loader'
+import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
+import {WizardFeedCard} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
+  return item.uri
+}
+
+export function StepFeeds({moderationOpts}: {moderationOpts: ModerationOpts}) {
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+  const [query, setQuery] = useState('')
+  const throttledQuery = useThrottledValue(query, 500)
+  const {screenReaderEnabled} = useA11y()
+
+  const {data: savedFeedsAndLists} = useSavedFeeds()
+  const savedFeeds = savedFeedsAndLists?.feeds
+    .filter(f => f.type === 'feed' && f.view.uri !== DISCOVER_FEED_URI)
+    .map(f => f.view) as AppBskyFeedDefs.GeneratorView[]
+
+  const {data: popularFeedsPages, fetchNextPage} = useGetPopularFeedsQuery({
+    limit: 30,
+  })
+  const popularFeeds =
+    popularFeedsPages?.pages
+      .flatMap(page => page.feeds)
+      .filter(f => !savedFeeds?.some(sf => sf?.uri === f.uri)) ?? []
+
+  const suggestedFeeds = savedFeeds?.concat(popularFeeds)
+
+  const {data: searchedFeeds, isLoading: isLoadingSearch} =
+    useSearchPopularFeedsQuery({q: throttledQuery})
+
+  const renderItem = ({
+    item,
+  }: ListRenderItemInfo<AppBskyFeedDefs.GeneratorView>) => {
+    return (
+      <WizardFeedCard
+        generator={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    )
+  }
+
+  return (
+    <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
+      <View style={[a.border_b, t.atoms.border_contrast_medium]}>
+        <View style={[a.my_sm, a.px_md, {height: 40}]}>
+          <SearchInput
+            query={query}
+            onChangeQuery={t => setQuery(t)}
+            onPressCancelSearch={() => setQuery('')}
+            onSubmitQuery={() => {}}
+          />
+        </View>
+      </View>
+      <List
+        data={query ? searchedFeeds : suggestedFeeds}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        contentContainerStyle={{paddingTop: 6}}
+        onEndReached={
+          !query && !screenReaderEnabled ? () => fetchNextPage() : undefined
+        }
+        onEndReachedThreshold={2}
+        renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
+        keyboardShouldPersistTaps="handled"
+        containWeb={true}
+        sideBorders={false}
+        style={{flex: 1}}
+        ListEmptyComponent={
+          <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}>
+            {isLoadingSearch ? (
+              <Loader size="lg" />
+            ) : (
+              <Text
+                style={[
+                  a.font_bold,
+                  a.text_lg,
+                  a.text_center,
+                  a.mt_lg,
+                  a.leading_snug,
+                ]}>
+                <Trans>No feeds found. Try searching for something else.</Trans>
+              </Text>
+            )}
+          </View>
+        }
+      />
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/StarterPack/Wizard/StepFinished.tsx b/src/screens/StarterPack/Wizard/StepFinished.tsx
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepFinished.tsx
diff --git a/src/screens/StarterPack/Wizard/StepProfiles.tsx b/src/screens/StarterPack/Wizard/StepProfiles.tsx
new file mode 100644
index 000000000..8fe7f52fe
--- /dev/null
+++ b/src/screens/StarterPack/Wizard/StepProfiles.tsx
@@ -0,0 +1,101 @@
+import React, {useState} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'
+import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {useA11y} from '#/state/a11y'
+import {isNative} from 'platform/detection'
+import {useActorAutocompleteQuery} from 'state/queries/actor-autocomplete'
+import {useActorSearchPaginated} from 'state/queries/actor-search'
+import {SearchInput} from 'view/com/util/forms/SearchInput'
+import {List} from 'view/com/util/List'
+import {useWizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import {Loader} from '#/components/Loader'
+import {ScreenTransition} from '#/components/StarterPack/Wizard/ScreenTransition'
+import {WizardProfileCard} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) {
+  return item?.did ?? ''
+}
+
+export function StepProfiles({
+  moderationOpts,
+}: {
+  moderationOpts: ModerationOpts
+}) {
+  const t = useTheme()
+  const [state, dispatch] = useWizardState()
+  const [query, setQuery] = useState('')
+  const {screenReaderEnabled} = useA11y()
+
+  const {data: topPages, fetchNextPage} = useActorSearchPaginated({
+    query: encodeURIComponent('*'),
+  })
+  const topFollowers = topPages?.pages.flatMap(p => p.actors)
+
+  const {data: results, isLoading: isLoadingResults} =
+    useActorAutocompleteQuery(query, true, 12)
+
+  const renderItem = ({
+    item,
+  }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
+    return (
+      <WizardProfileCard
+        profile={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    )
+  }
+
+  return (
+    <ScreenTransition style={[a.flex_1]} direction={state.transitionDirection}>
+      <View style={[a.border_b, t.atoms.border_contrast_medium]}>
+        <View style={[a.my_sm, a.px_md, {height: 40}]}>
+          <SearchInput
+            query={query}
+            onChangeQuery={setQuery}
+            onPressCancelSearch={() => setQuery('')}
+            onSubmitQuery={() => {}}
+          />
+        </View>
+      </View>
+      <List
+        data={query ? results : topFollowers}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        renderScrollComponent={props => <KeyboardAwareScrollView {...props} />}
+        keyboardShouldPersistTaps="handled"
+        containWeb={true}
+        sideBorders={false}
+        style={[a.flex_1]}
+        onEndReached={
+          !query && !screenReaderEnabled ? () => fetchNextPage() : undefined
+        }
+        onEndReachedThreshold={isNative ? 2 : 0.25}
+        ListEmptyComponent={
+          <View style={[a.flex_1, a.align_center, a.mt_lg, a.px_lg]}>
+            {isLoadingResults ? (
+              <Loader size="lg" />
+            ) : (
+              <Text
+                style={[
+                  a.font_bold,
+                  a.text_lg,
+                  a.text_center,
+                  a.mt_lg,
+                  a.leading_snug,
+                ]}>
+                <Trans>Nobody was found. Try searching for someone else.</Trans>
+              </Text>
+            )}
+          </View>
+        }
+      />
+    </ScreenTransition>
+  )
+}
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 ''
+}