about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Login/LoginForm.tsx3
-rw-r--r--src/screens/Login/ScreenTransition.tsx11
-rw-r--r--src/screens/Onboarding/StepFinished.tsx117
-rw-r--r--src/screens/Profile/Header/DisplayName.tsx6
-rw-r--r--src/screens/Signup/index.tsx40
-rw-r--r--src/screens/StarterPack/StarterPackLandingScreen.tsx378
-rw-r--r--src/screens/StarterPack/StarterPackScreen.tsx627
-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
13 files changed, 2206 insertions, 12 deletions
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
index dfa10668b..7cfd38e34 100644
--- a/src/screens/Login/LoginForm.tsx
+++ b/src/screens/Login/LoginForm.tsx
@@ -21,6 +21,7 @@ import {logger} from '#/logger'
 import {useSessionApi} from '#/state/session'
 import {useLoggedOutViewControls} from '#/state/shell/logged-out'
 import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
+import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {FormError} from '#/components/forms/FormError'
@@ -69,6 +70,7 @@ export const LoginForm = ({
   const {login} = useSessionApi()
   const requestNotificationsPermission = useRequestNotificationsPermission()
   const {setShowLoggedOut} = useLoggedOutViewControls()
+  const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
 
   const onPressSelectService = React.useCallback(() => {
     Keyboard.dismiss()
@@ -116,6 +118,7 @@ export const LoginForm = ({
         'LoginForm',
       )
       setShowLoggedOut(false)
+      setHasCheckedForStarterPack(true)
       requestNotificationsPermission('Login')
     } catch (e: any) {
       const errMsg = e.toString()
diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx
index ab0a22367..6fad26680 100644
--- a/src/screens/Login/ScreenTransition.tsx
+++ b/src/screens/Login/ScreenTransition.tsx
@@ -1,9 +1,16 @@
 import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
 import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
 
-export function ScreenTransition({children}: {children: React.ReactNode}) {
+export function ScreenTransition({
+  style,
+  children,
+}: {
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
   return (
-    <Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
+    <Animated.View style={style} entering={FadeInRight} exiting={FadeOutLeft}>
       {children}
     </Animated.View>
   )
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index c75dd4fa7..c7a459659 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,11 +1,18 @@
 import React from 'react'
 import {View} from 'react-native'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {SavedFeed} from '@atproto/api/dist/client/types/app/bsky/actor/defs'
+import {TID} from '@atproto/common-web'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useQueryClient} from '@tanstack/react-query'
 
 import {useAnalytics} from '#/lib/analytics/analytics'
-import {BSKY_APP_ACCOUNT_DID} from '#/lib/constants'
+import {
+  BSKY_APP_ACCOUNT_DID,
+  DISCOVER_SAVED_FEED,
+  TIMELINE_SAVED_FEED,
+} from '#/lib/constants'
 import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
 import {preferencesQueryKey} from '#/state/queries/preferences'
@@ -14,6 +21,11 @@ import {useAgent} from '#/state/session'
 import {useOnboardingDispatch} from '#/state/shell'
 import {uploadBlob} from 'lib/api'
 import {useRequestNotificationsPermission} from 'lib/notifications/notifications'
+import {useSetHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
+import {
+  useActiveStarterPack,
+  useSetActiveStarterPack,
+} from 'state/shell/starter-pack'
 import {
   DescriptionText,
   OnboardingControls,
@@ -41,17 +53,74 @@ export function StepFinished() {
   const queryClient = useQueryClient()
   const agent = useAgent()
   const requestNotificationsPermission = useRequestNotificationsPermission()
+  const activeStarterPack = useActiveStarterPack()
+  const setActiveStarterPack = useSetActiveStarterPack()
+  const setHasCheckedForStarterPack = useSetHasCheckedForStarterPack()
 
   const finishOnboarding = React.useCallback(async () => {
     setSaving(true)
 
-    const {interestsStepResults, profileStepResults} = state
-    const {selectedInterests} = interestsStepResults
+    let starterPack: AppBskyGraphDefs.StarterPackView | undefined
+    let listItems: AppBskyGraphDefs.ListItemView[] | undefined
+
+    if (activeStarterPack?.uri) {
+      try {
+        const spRes = await agent.app.bsky.graph.getStarterPack({
+          starterPack: activeStarterPack.uri,
+        })
+        starterPack = spRes.data.starterPack
+
+        if (starterPack.list) {
+          const listRes = await agent.app.bsky.graph.getList({
+            list: starterPack.list.uri,
+            limit: 50,
+          })
+          listItems = listRes.data.items
+        }
+      } catch (e) {
+        logger.error('Failed to fetch starter pack', {safeMessage: e})
+        // don't tell the user, just get them through onboarding.
+      }
+    }
+
     try {
+      const {interestsStepResults, profileStepResults} = state
+      const {selectedInterests} = interestsStepResults
+
       await Promise.all([
-        bulkWriteFollows(agent, [BSKY_APP_ACCOUNT_DID]),
+        bulkWriteFollows(agent, [
+          BSKY_APP_ACCOUNT_DID,
+          ...(listItems?.map(i => i.subject.did) ?? []),
+        ]),
         (async () => {
+          // Interests need to get saved first, then we can write the feeds to prefs
           await agent.setInterestsPref({tags: selectedInterests})
+
+          // Default feeds that every user should have pinned when landing in the app
+          const feedsToSave: SavedFeed[] = [
+            {
+              ...DISCOVER_SAVED_FEED,
+              id: TID.nextStr(),
+            },
+            {
+              ...TIMELINE_SAVED_FEED,
+              id: TID.nextStr(),
+            },
+          ]
+
+          // Any starter pack feeds will be pinned _after_ the defaults
+          if (starterPack && starterPack.feeds?.length) {
+            feedsToSave.concat(
+              starterPack.feeds.map(f => ({
+                type: 'feed',
+                value: f.uri,
+                pinned: true,
+                id: TID.nextStr(),
+              })),
+            )
+          }
+
+          await agent.overwriteSavedFeeds(feedsToSave)
         })(),
         (async () => {
           const {imageUri, imageMime} = profileStepResults
@@ -63,9 +132,24 @@ export function StepFinished() {
               if (res.data.blob) {
                 existing.avatar = res.data.blob
               }
+
+              if (starterPack) {
+                existing.joinedViaStarterPack = {
+                  uri: starterPack.uri,
+                  cid: starterPack.cid,
+                }
+              }
+
+              existing.displayName = ''
+              // HACKFIX
+              // creating a bunch of identical profile objects is breaking the relay
+              // tossing this unspecced field onto it to reduce the size of the problem
+              // -prf
+              existing.createdAt = new Date().toISOString()
               return existing
             })
           }
+
           logEvent('onboarding:finished:avatarResult', {
             avatarResult: profileStepResults.isCreatedAvatar
               ? 'created'
@@ -96,19 +180,40 @@ export function StepFinished() {
     })
 
     setSaving(false)
+    setActiveStarterPack(undefined)
+    setHasCheckedForStarterPack(true)
     dispatch({type: 'finish'})
     onboardDispatch({type: 'finish'})
     track('OnboardingV2:StepFinished:End')
     track('OnboardingV2:Complete')
-    logEvent('onboarding:finished:nextPressed', {})
+    logEvent('onboarding:finished:nextPressed', {
+      usedStarterPack: Boolean(starterPack),
+      starterPackName: AppBskyGraphStarterpack.isRecord(starterPack?.record)
+        ? starterPack.record.name
+        : undefined,
+      starterPackCreator: starterPack?.creator.did,
+      starterPackUri: starterPack?.uri,
+      profilesFollowed: listItems?.length ?? 0,
+      feedsPinned: starterPack?.feeds?.length ?? 0,
+    })
+    if (starterPack && listItems?.length) {
+      logEvent('starterPack:followAll', {
+        logContext: 'Onboarding',
+        starterPack: starterPack.uri,
+        count: listItems?.length,
+      })
+    }
   }, [
-    state,
     queryClient,
     agent,
     dispatch,
     onboardDispatch,
     track,
+    activeStarterPack,
+    state,
     requestNotificationsPermission,
+    setActiveStarterPack,
+    setHasCheckedForStarterPack,
   ])
 
   React.useEffect(() => {
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
index b6d88db71..c63658a44 100644
--- a/src/screens/Profile/Header/DisplayName.tsx
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -1,10 +1,10 @@
 import React from 'react'
 import {View} from 'react-native'
 import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
-import {sanitizeHandle} from 'lib/strings/handles'
-import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {Shadow} from '#/state/cache/types'
 
+import {Shadow} from '#/state/cache/types'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
index 2cc1bcab0..3203d443c 100644
--- a/src/screens/Signup/index.tsx
+++ b/src/screens/Signup/index.tsx
@@ -1,6 +1,11 @@
 import React from 'react'
 import {View} from 'react-native'
-import {LayoutAnimationConfig} from 'react-native-reanimated'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  LayoutAnimationConfig,
+} from 'react-native-reanimated'
+import {AppBskyGraphStarterpack} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -11,6 +16,8 @@ import {createFullHandle} from '#/lib/strings/handles'
 import {logger} from '#/logger'
 import {useServiceQuery} from '#/state/queries/service'
 import {useAgent} from '#/state/session'
+import {useStarterPackQuery} from 'state/queries/starter-packs'
+import {useActiveStarterPack} from 'state/shell/starter-pack'
 import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
 import {
   initialState,
@@ -26,6 +33,7 @@ import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {AppLanguageDropdown} from '#/components/AppLanguageDropdown'
 import {Button, ButtonText} from '#/components/Button'
 import {Divider} from '#/components/Divider'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
 import {InlineLinkText} from '#/components/Link'
 import {Text} from '#/components/Typography'
 
@@ -38,6 +46,11 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
   const {gtMobile} = useBreakpoints()
   const agent = useAgent()
 
+  const activeStarterPack = useActiveStarterPack()
+  const {data: starterPack} = useStarterPackQuery({
+    uri: activeStarterPack?.uri,
+  })
+
   const {
     data: serviceInfo,
     isFetching,
@@ -142,6 +155,31 @@ export function Signup({onPressBack}: {onPressBack: () => void}) {
         description={_(msg`We're so excited to have you join us!`)}
         scrollable>
         <View testID="createAccount" style={a.flex_1}>
+          {state.activeStep === SignupStep.INFO &&
+          starterPack &&
+          AppBskyGraphStarterpack.isRecord(starterPack.record) ? (
+            <Animated.View entering={FadeIn} exiting={FadeOut}>
+              <LinearGradientBackground
+                style={[a.mx_lg, a.p_lg, a.gap_sm, a.rounded_sm]}>
+                <Text style={[a.font_bold, a.text_xl, {color: 'white'}]}>
+                  {starterPack.record.name}
+                </Text>
+                <Text style={[{color: 'white'}]}>
+                  {starterPack.feeds?.length ? (
+                    <Trans>
+                      You'll follow the suggested users and feeds once you
+                      finish creating your account!
+                    </Trans>
+                  ) : (
+                    <Trans>
+                      You'll follow the suggested users once you finish creating
+                      your account!
+                    </Trans>
+                  )}
+                </Text>
+              </LinearGradientBackground>
+            </Animated.View>
+          ) : null}
           <View
             style={[
               a.flex_1,
diff --git a/src/screens/StarterPack/StarterPackLandingScreen.tsx b/src/screens/StarterPack/StarterPackLandingScreen.tsx
new file mode 100644
index 000000000..1c9587a79
--- /dev/null
+++ b/src/screens/StarterPack/StarterPackLandingScreen.tsx
@@ -0,0 +1,378 @@
+import React from 'react'
+import {Pressable, ScrollView, View} from 'react-native'
+import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
+import {
+  AppBskyGraphDefs,
+  AppBskyGraphStarterpack,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isAndroidWeb} from 'lib/browser'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {createStarterPackGooglePlayUri} from 'lib/strings/starter-pack'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
+import {useStarterPackQuery} from 'state/queries/starter-packs'
+import {
+  useActiveStarterPack,
+  useSetActiveStarterPack,
+} from 'state/shell/starter-pack'
+import {LoggedOutScreenState} from 'view/com/auth/LoggedOut'
+import {CenteredView} from 'view/com/util/Views'
+import {Logo} from 'view/icons/Logo'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import * as FeedCard from '#/components/FeedCard'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {ListMaybePlaceholder} from '#/components/Lists'
+import {Default as ProfileCard} from '#/components/ProfileCard'
+import * as Prompt from '#/components/Prompt'
+import {Text} from '#/components/Typography'
+
+const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
+
+interface AppClipMessage {
+  action: 'present' | 'store'
+  keyToStoreAs?: string
+  jsonToStore?: string
+}
+
+function postAppClipMessage(message: AppClipMessage) {
+  // @ts-expect-error safari webview only
+  window.webkit.messageHandlers.onMessage.postMessage(JSON.stringify(message))
+}
+
+export function LandingScreen({
+  setScreenState,
+}: {
+  setScreenState: (state: LoggedOutScreenState) => void
+}) {
+  const moderationOpts = useModerationOpts()
+  const activeStarterPack = useActiveStarterPack()
+
+  const {data: starterPack, isError: isErrorStarterPack} = useStarterPackQuery({
+    uri: activeStarterPack?.uri,
+  })
+
+  const isValid =
+    starterPack &&
+    starterPack.list &&
+    AppBskyGraphDefs.validateStarterPackView(starterPack) &&
+    AppBskyGraphStarterpack.validateRecord(starterPack.record)
+
+  React.useEffect(() => {
+    if (isErrorStarterPack || (starterPack && !isValid)) {
+      setScreenState(LoggedOutScreenState.S_LoginOrCreateAccount)
+    }
+  }, [isErrorStarterPack, setScreenState, isValid, starterPack])
+
+  if (!starterPack || !isValid || !moderationOpts) {
+    return <ListMaybePlaceholder isLoading={true} />
+  }
+
+  return (
+    <LandingScreenLoaded
+      starterPack={starterPack}
+      setScreenState={setScreenState}
+      moderationOpts={moderationOpts}
+    />
+  )
+}
+
+function LandingScreenLoaded({
+  starterPack,
+  setScreenState,
+  // TODO apply this to profile card
+
+  moderationOpts,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  setScreenState: (state: LoggedOutScreenState) => void
+  moderationOpts: ModerationOpts
+}) {
+  const {record, creator, listItemsSample, feeds, joinedWeekCount} = starterPack
+  const {_} = useLingui()
+  const t = useTheme()
+  const activeStarterPack = useActiveStarterPack()
+  const setActiveStarterPack = useSetActiveStarterPack()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+  const androidDialogControl = useDialogControl()
+
+  const [appClipOverlayVisible, setAppClipOverlayVisible] =
+    React.useState(false)
+
+  const listItemsCount = starterPack.list?.listItemCount ?? 0
+
+  const onContinue = () => {
+    setActiveStarterPack({
+      uri: starterPack.uri,
+    })
+    setScreenState(LoggedOutScreenState.S_CreateAccount)
+  }
+
+  const onJoinPress = () => {
+    if (activeStarterPack?.isClip) {
+      setAppClipOverlayVisible(true)
+      postAppClipMessage({
+        action: 'present',
+      })
+    } else if (isAndroidWeb) {
+      androidDialogControl.open()
+    } else {
+      onContinue()
+    }
+  }
+
+  const onJoinWithoutPress = () => {
+    if (activeStarterPack?.isClip) {
+      setAppClipOverlayVisible(true)
+      postAppClipMessage({
+        action: 'present',
+      })
+    } else {
+      setActiveStarterPack(undefined)
+      setScreenState(LoggedOutScreenState.S_CreateAccount)
+    }
+  }
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <CenteredView style={a.flex_1}>
+      <ScrollView
+        style={[a.flex_1, t.atoms.bg]}
+        contentContainerStyle={{paddingBottom: 100}}>
+        <LinearGradientBackground
+          style={[
+            a.align_center,
+            a.gap_sm,
+            a.px_lg,
+            a.py_2xl,
+            isTabletOrDesktop && [a.mt_2xl, a.rounded_md],
+            activeStarterPack?.isClip && {
+              paddingTop: 100,
+            },
+          ]}>
+          <View style={[a.flex_row, a.gap_md, a.pb_sm]}>
+            <Logo width={76} fill="white" />
+          </View>
+          <Text
+            style={[
+              a.font_bold,
+              a.text_4xl,
+              a.text_center,
+              a.leading_tight,
+              {color: 'white'},
+            ]}>
+            {record.name}
+          </Text>
+          <Text
+            style={[
+              a.text_center,
+              a.font_semibold,
+              a.text_md,
+              {color: 'white'},
+            ]}>
+            Starter pack by {`@${creator.handle}`}
+          </Text>
+        </LinearGradientBackground>
+        <View style={[a.gap_2xl, a.mx_lg, a.my_2xl]}>
+          {record.description ? (
+            <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+              {record.description}
+            </Text>
+          ) : null}
+          <View style={[a.gap_sm]}>
+            <Button
+              label={_(msg`Join Bluesky`)}
+              onPress={onJoinPress}
+              variant="solid"
+              color="primary"
+              size="large">
+              <ButtonText style={[a.text_lg]}>
+                <Trans>Join Bluesky</Trans>
+              </ButtonText>
+            </Button>
+            {joinedWeekCount && joinedWeekCount >= 25 ? (
+              <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                <FontAwesomeIcon
+                  icon="arrow-trend-up"
+                  size={12}
+                  color={t.atoms.text_contrast_medium.color}
+                />
+                <Text
+                  style={[
+                    a.font_semibold,
+                    a.text_sm,
+                    t.atoms.text_contrast_medium,
+                  ]}
+                  numberOfLines={1}>
+                  123,659 joined this week
+                </Text>
+              </View>
+            ) : null}
+          </View>
+          <View style={[a.gap_3xl]}>
+            {Boolean(listItemsSample?.length) && (
+              <View style={[a.gap_md]}>
+                <Text style={[a.font_heavy, a.text_lg]}>
+                  {listItemsCount <= 8 ? (
+                    <Trans>You'll follow these people right away</Trans>
+                  ) : (
+                    <Trans>
+                      You'll follow these people and {listItemsCount - 8} others
+                    </Trans>
+                  )}
+                </Text>
+                <View>
+                  {starterPack.listItemsSample?.slice(0, 8).map(item => (
+                    <View
+                      key={item.subject.did}
+                      style={[
+                        a.py_lg,
+                        a.px_md,
+                        a.border_t,
+                        t.atoms.border_contrast_low,
+                      ]}>
+                      <ProfileCard
+                        profile={item.subject}
+                        moderationOpts={moderationOpts}
+                      />
+                    </View>
+                  ))}
+                </View>
+              </View>
+            )}
+            {feeds?.length ? (
+              <View style={[a.gap_md]}>
+                <Text style={[a.font_heavy, a.text_lg]}>
+                  <Trans>You'll stay updated with these feeds</Trans>
+                </Text>
+
+                <View style={[{pointerEvents: 'none'}]}>
+                  {feeds?.map(feed => (
+                    <View
+                      style={[
+                        a.py_lg,
+                        a.px_md,
+                        a.border_t,
+                        t.atoms.border_contrast_low,
+                      ]}
+                      key={feed.uri}>
+                      <FeedCard.Default type="feed" view={feed} />
+                    </View>
+                  ))}
+                </View>
+              </View>
+            ) : null}
+          </View>
+          <Button
+            label={_(msg`Signup without a starter pack`)}
+            variant="solid"
+            color="secondary"
+            size="medium"
+            style={[a.py_lg]}
+            onPress={onJoinWithoutPress}>
+            <ButtonText>
+              <Trans>Signup without a starter pack</Trans>
+            </ButtonText>
+          </Button>
+        </View>
+      </ScrollView>
+      <AppClipOverlay
+        visible={appClipOverlayVisible}
+        setIsVisible={setAppClipOverlayVisible}
+      />
+      <Prompt.Outer control={androidDialogControl}>
+        <Prompt.TitleText>
+          <Trans>Download Bluesky</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>
+            The experience is better in the app. Download Bluesky now and we'll
+            pick back up where you left off.
+          </Trans>
+        </Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            cta="Download on Google Play"
+            color="primary"
+            onPress={() => {
+              const rkey = new AtUri(starterPack.uri).rkey
+              if (!rkey) return
+
+              const googlePlayUri = createStarterPackGooglePlayUri(
+                creator.handle,
+                rkey,
+              )
+              if (!googlePlayUri) return
+
+              window.location.href = googlePlayUri
+            }}
+          />
+          <Prompt.Action
+            cta="Continue on web"
+            color="secondary"
+            onPress={onContinue}
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+      {isWeb && (
+        <meta
+          name="apple-itunes-app"
+          content="app-id=xyz.blueskyweb.app, app-clip-bundle-id=xyz.blueskyweb.app.AppClip, app-clip-display=card"
+        />
+      )}
+    </CenteredView>
+  )
+}
+
+function AppClipOverlay({
+  visible,
+  setIsVisible,
+}: {
+  visible: boolean
+  setIsVisible: (visible: boolean) => void
+}) {
+  if (!visible) return
+
+  return (
+    <AnimatedPressable
+      accessibilityRole="button"
+      style={[
+        a.absolute,
+        {
+          top: 0,
+          left: 0,
+          right: 0,
+          bottom: 0,
+          backgroundColor: 'rgba(0, 0, 0, 0.95)',
+          zIndex: 1,
+        },
+      ]}
+      entering={FadeIn}
+      exiting={FadeOut}
+      onPress={() => setIsVisible(false)}>
+      <View style={[a.flex_1, a.px_lg, {marginTop: 250}]}>
+        {/* Webkit needs this to have a zindex of 2? */}
+        <View style={[a.gap_md, {zIndex: 2}]}>
+          <Text
+            style={[a.font_bold, a.text_4xl, {lineHeight: 40, color: 'white'}]}>
+            Download Bluesky to get started!
+          </Text>
+          <Text style={[a.text_lg, {color: 'white'}]}>
+            We'll remember the starter pack you chose and use it when you create
+            an account in the app.
+          </Text>
+        </View>
+      </View>
+    </AnimatedPressable>
+  )
+}
diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx
new file mode 100644
index 000000000..46ce25236
--- /dev/null
+++ b/src/screens/StarterPack/StarterPackScreen.tsx
@@ -0,0 +1,627 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Image} from 'expo-image'
+import {
+  AppBskyGraphDefs,
+  AppBskyGraphGetList,
+  AppBskyGraphStarterpack,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {
+  InfiniteData,
+  UseInfiniteQueryResult,
+  useQueryClient,
+} from '@tanstack/react-query'
+
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {useDeleteStarterPackMutation} from '#/state/queries/starter-packs'
+import {HITSLOP_20} from 'lib/constants'
+import {makeProfileLink, makeStarterPackLink} from 'lib/routes/links'
+import {CommonNavigatorParams, NavigationProp} from 'lib/routes/types'
+import {logEvent} from 'lib/statsig/statsig'
+import {getStarterPackOgCard} from 'lib/strings/starter-pack'
+import {isWeb} from 'platform/detection'
+import {useModerationOpts} from 'state/preferences/moderation-opts'
+import {RQKEY, useListMembersQuery} from 'state/queries/list-members'
+import {useResolveDidQuery} from 'state/queries/resolve-uri'
+import {useShortenLink} from 'state/queries/shorten-link'
+import {useStarterPackQuery} from 'state/queries/starter-packs'
+import {useAgent, useSession} from 'state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {PagerWithHeader} from 'view/com/pager/PagerWithHeader'
+import {ProfileSubpageHeader} from 'view/com/profile/ProfileSubpageHeader'
+import {CenteredView} from 'view/com/util/Views'
+import {bulkWriteFollows} from '#/screens/Onboarding/util'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
+import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil'
+import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
+import {ListMaybePlaceholder} from '#/components/Lists'
+import {Loader} from '#/components/Loader'
+import * as Menu from '#/components/Menu'
+import * as Prompt from '#/components/Prompt'
+import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
+import {FeedsList} from '#/components/StarterPack/Main/FeedsList'
+import {ProfilesList} from '#/components/StarterPack/Main/ProfilesList'
+import {QrCodeDialog} from '#/components/StarterPack/QrCodeDialog'
+import {ShareDialog} from '#/components/StarterPack/ShareDialog'
+import {Text} from '#/components/Typography'
+
+type StarterPackScreeProps = NativeStackScreenProps<
+  CommonNavigatorParams,
+  'StarterPack'
+>
+
+export function StarterPackScreen({route}: StarterPackScreeProps) {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+
+  const {name, rkey} = route.params
+  const moderationOpts = useModerationOpts()
+  const {
+    data: did,
+    isLoading: isLoadingDid,
+    isError: isErrorDid,
+  } = useResolveDidQuery(name)
+  const {
+    data: starterPack,
+    isLoading: isLoadingStarterPack,
+    isError: isErrorStarterPack,
+  } = useStarterPackQuery({did, rkey})
+  const listMembersQuery = useListMembersQuery(starterPack?.list?.uri, 50)
+
+  const isValid =
+    starterPack &&
+    (starterPack.list || starterPack?.creator?.did === currentAccount?.did) &&
+    AppBskyGraphDefs.validateStarterPackView(starterPack) &&
+    AppBskyGraphStarterpack.validateRecord(starterPack.record)
+
+  if (!did || !starterPack || !isValid || !moderationOpts) {
+    return (
+      <ListMaybePlaceholder
+        isLoading={
+          isLoadingDid ||
+          isLoadingStarterPack ||
+          listMembersQuery.isLoading ||
+          !moderationOpts
+        }
+        isError={isErrorDid || isErrorStarterPack || !isValid}
+        errorMessage={_(msg`That starter pack could not be found.`)}
+        emptyMessage={_(msg`That starter pack could not be found.`)}
+      />
+    )
+  }
+
+  if (!starterPack.list && starterPack.creator.did === currentAccount?.did) {
+    return <InvalidStarterPack rkey={rkey} />
+  }
+
+  return (
+    <StarterPackScreenInner
+      starterPack={starterPack}
+      routeParams={route.params}
+      listMembersQuery={listMembersQuery}
+      moderationOpts={moderationOpts}
+    />
+  )
+}
+
+function StarterPackScreenInner({
+  starterPack,
+  routeParams,
+  listMembersQuery,
+  moderationOpts,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  routeParams: StarterPackScreeProps['route']['params']
+  listMembersQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetList.OutputSchema>
+  >
+  moderationOpts: ModerationOpts
+}) {
+  const tabs = [
+    ...(starterPack.list ? ['People'] : []),
+    ...(starterPack.feeds?.length ? ['Feeds'] : []),
+  ]
+
+  const qrCodeDialogControl = useDialogControl()
+  const shareDialogControl = useDialogControl()
+
+  const shortenLink = useShortenLink()
+  const [link, setLink] = React.useState<string>()
+  const [imageLoaded, setImageLoaded] = React.useState(false)
+
+  const onOpenShareDialog = React.useCallback(() => {
+    const rkey = new AtUri(starterPack.uri).rkey
+    shortenLink(makeStarterPackLink(starterPack.creator.did, rkey)).then(
+      res => {
+        setLink(res.url)
+      },
+    )
+    Image.prefetch(getStarterPackOgCard(starterPack))
+      .then(() => {
+        setImageLoaded(true)
+      })
+      .catch(() => {
+        setImageLoaded(true)
+      })
+    shareDialogControl.open()
+  }, [shareDialogControl, shortenLink, starterPack])
+
+  React.useEffect(() => {
+    if (routeParams.new) {
+      onOpenShareDialog()
+    }
+  }, [onOpenShareDialog, routeParams.new, shareDialogControl])
+
+  return (
+    <CenteredView style={[a.h_full_vh]}>
+      <View style={isWeb ? {minHeight: '100%'} : {height: '100%'}}>
+        <PagerWithHeader
+          items={tabs}
+          isHeaderReady={true}
+          renderHeader={() => (
+            <Header
+              starterPack={starterPack}
+              routeParams={routeParams}
+              onOpenShareDialog={onOpenShareDialog}
+            />
+          )}>
+          {starterPack.list != null
+            ? ({headerHeight, scrollElRef}) => (
+                <ProfilesList
+                  key={0}
+                  // Validated above
+                  listUri={starterPack!.list!.uri}
+                  headerHeight={headerHeight}
+                  // @ts-expect-error
+                  scrollElRef={scrollElRef}
+                  listMembersQuery={listMembersQuery}
+                  moderationOpts={moderationOpts}
+                />
+              )
+            : null}
+          {starterPack.feeds != null
+            ? ({headerHeight, scrollElRef}) => (
+                <FeedsList
+                  key={1}
+                  // @ts-expect-error ?
+                  feeds={starterPack?.feeds}
+                  headerHeight={headerHeight}
+                  // @ts-expect-error
+                  scrollElRef={scrollElRef}
+                />
+              )
+            : null}
+        </PagerWithHeader>
+      </View>
+
+      <QrCodeDialog
+        control={qrCodeDialogControl}
+        starterPack={starterPack}
+        link={link}
+      />
+      <ShareDialog
+        control={shareDialogControl}
+        qrDialogControl={qrCodeDialogControl}
+        starterPack={starterPack}
+        link={link}
+        imageLoaded={imageLoaded}
+      />
+    </CenteredView>
+  )
+}
+
+function Header({
+  starterPack,
+  routeParams,
+  onOpenShareDialog,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  routeParams: StarterPackScreeProps['route']['params']
+  onOpenShareDialog: () => void
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const agent = useAgent()
+  const queryClient = useQueryClient()
+
+  const [isProcessing, setIsProcessing] = React.useState(false)
+
+  const {record, creator} = starterPack
+  const isOwn = creator?.did === currentAccount?.did
+  const joinedAllTimeCount = starterPack.joinedAllTimeCount ?? 0
+
+  const onFollowAll = async () => {
+    if (!starterPack.list) return
+
+    setIsProcessing(true)
+
+    try {
+      const list = await agent.app.bsky.graph.getList({
+        list: starterPack.list.uri,
+      })
+      const dids = list.data.items
+        .filter(li => !li.subject.viewer?.following)
+        .map(li => li.subject.did)
+
+      await bulkWriteFollows(agent, dids)
+
+      await queryClient.refetchQueries({
+        queryKey: RQKEY(starterPack.list.uri),
+      })
+
+      logEvent('starterPack:followAll', {
+        logContext: 'StarterPackProfilesList',
+        starterPack: starterPack.uri,
+        count: dids.length,
+      })
+      Toast.show(_(msg`All accounts have been followed!`))
+    } catch (e) {
+      Toast.show(_(msg`An error occurred while trying to follow all`))
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <>
+      <ProfileSubpageHeader
+        isLoading={false}
+        href={makeProfileLink(creator)}
+        title={record.name}
+        isOwner={isOwn}
+        avatar={undefined}
+        creator={creator}
+        avatarType="starter-pack">
+        <View style={[a.flex_row, a.gap_sm, a.align_center]}>
+          {isOwn ? (
+            <Button
+              label={_(msg`Share this starter pack`)}
+              hitSlop={HITSLOP_20}
+              variant="solid"
+              color="primary"
+              size="small"
+              onPress={onOpenShareDialog}>
+              <ButtonText>
+                <Trans>Share</Trans>
+              </ButtonText>
+            </Button>
+          ) : (
+            <Button
+              label={_(msg`Follow all`)}
+              variant="solid"
+              color="primary"
+              size="small"
+              disabled={isProcessing}
+              onPress={onFollowAll}>
+              <ButtonText>
+                <Trans>Follow all</Trans>
+                {isProcessing && <Loader size="xs" />}
+              </ButtonText>
+            </Button>
+          )}
+          <OverflowMenu
+            routeParams={routeParams}
+            starterPack={starterPack}
+            onOpenShareDialog={onOpenShareDialog}
+          />
+        </View>
+      </ProfileSubpageHeader>
+      {record.description || joinedAllTimeCount >= 25 ? (
+        <View style={[a.px_lg, a.pt_md, a.pb_sm, a.gap_md]}>
+          {record.description ? (
+            <Text style={[a.text_md, a.leading_snug]}>
+              {record.description}
+            </Text>
+          ) : null}
+          {joinedAllTimeCount >= 25 ? (
+            <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+              <FontAwesomeIcon
+                icon="arrow-trend-up"
+                size={12}
+                color={t.atoms.text_contrast_medium.color}
+              />
+              <Text
+                style={[a.font_bold, a.text_sm, t.atoms.text_contrast_medium]}>
+                <Trans>
+                  {starterPack.joinedAllTimeCount || 0} people have used this
+                  starter pack!
+                </Trans>
+              </Text>
+            </View>
+          ) : null}
+        </View>
+      ) : null}
+    </>
+  )
+}
+
+function OverflowMenu({
+  starterPack,
+  routeParams,
+  onOpenShareDialog,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  routeParams: StarterPackScreeProps['route']['params']
+  onOpenShareDialog: () => void
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const {currentAccount} = useSession()
+  const reportDialogControl = useReportDialogControl()
+  const deleteDialogControl = useDialogControl()
+  const navigation = useNavigation<NavigationProp>()
+
+  const {
+    mutate: deleteStarterPack,
+    isPending: isDeletePending,
+    error: deleteError,
+  } = useDeleteStarterPackMutation({
+    onSuccess: () => {
+      logEvent('starterPack:delete', {})
+      deleteDialogControl.close(() => {
+        if (navigation.canGoBack()) {
+          navigation.popToTop()
+        } else {
+          navigation.navigate('Home')
+        }
+      })
+    },
+    onError: e => {
+      logger.error('Failed to delete starter pack', {safeMessage: e})
+    },
+  })
+
+  const isOwn = starterPack.creator.did === currentAccount?.did
+
+  const onDeleteStarterPack = async () => {
+    if (!starterPack.list) {
+      logger.error(`Unable to delete starterpack because list is missing`)
+      return
+    }
+
+    deleteStarterPack({
+      rkey: routeParams.rkey,
+      listUri: starterPack.list.uri,
+    })
+    logEvent('starterPack:delete', {})
+  }
+
+  return (
+    <>
+      <Menu.Root>
+        <Menu.Trigger label={_(msg`Repost or quote post`)}>
+          {({props}) => (
+            <Button
+              {...props}
+              testID="headerDropdownBtn"
+              label={_(msg`Open starter pack menu`)}
+              hitSlop={HITSLOP_20}
+              variant="solid"
+              color="secondary"
+              size="small"
+              shape="round">
+              <ButtonIcon icon={Ellipsis} />
+            </Button>
+          )}
+        </Menu.Trigger>
+        <Menu.Outer style={{minWidth: 170}}>
+          {isOwn ? (
+            <>
+              <Menu.Item
+                label={_(msg`Edit starter pack`)}
+                testID="editStarterPackLinkBtn"
+                onPress={() => {
+                  navigation.navigate('StarterPackEdit', {
+                    rkey: routeParams.rkey,
+                  })
+                }}>
+                <Menu.ItemText>
+                  <Trans>Edit</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Pencil} position="right" />
+              </Menu.Item>
+              <Menu.Item
+                label={_(msg`Delete starter pack`)}
+                testID="deleteStarterPackBtn"
+                onPress={() => {
+                  deleteDialogControl.open()
+                }}>
+                <Menu.ItemText>
+                  <Trans>Delete</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={Trash} position="right" />
+              </Menu.Item>
+            </>
+          ) : (
+            <>
+              <Menu.Group>
+                <Menu.Item
+                  label={_(msg`Share`)}
+                  testID="shareStarterPackLinkBtn"
+                  onPress={onOpenShareDialog}>
+                  <Menu.ItemText>
+                    <Trans>Share link</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={ArrowOutOfBox} position="right" />
+                </Menu.Item>
+              </Menu.Group>
+
+              <Menu.Item
+                label={_(msg`Report starter pack`)}
+                onPress={reportDialogControl.open}>
+                <Menu.ItemText>
+                  <Trans>Report starter pack</Trans>
+                </Menu.ItemText>
+                <Menu.ItemIcon icon={CircleInfo} position="right" />
+              </Menu.Item>
+            </>
+          )}
+        </Menu.Outer>
+      </Menu.Root>
+
+      {starterPack.list && (
+        <ReportDialog
+          control={reportDialogControl}
+          params={{
+            type: 'starterpack',
+            uri: starterPack.uri,
+            cid: starterPack.cid,
+          }}
+        />
+      )}
+
+      <Prompt.Outer control={deleteDialogControl}>
+        <Prompt.TitleText>
+          <Trans>Delete starter pack?</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>Are you sure you want delete this starter pack?</Trans>
+        </Prompt.DescriptionText>
+        {deleteError && (
+          <View
+            style={[
+              a.flex_row,
+              a.gap_sm,
+              a.rounded_sm,
+              a.p_md,
+              a.mb_lg,
+              a.border,
+              t.atoms.border_contrast_medium,
+              t.atoms.bg_contrast_25,
+            ]}>
+            <View style={[a.flex_1, a.gap_2xs]}>
+              <Text style={[a.font_bold]}>
+                <Trans>Unable to delete</Trans>
+              </Text>
+              <Text style={[a.leading_snug]}>{cleanError(deleteError)}</Text>
+            </View>
+            <CircleInfo size="sm" fill={t.palette.negative_400} />
+          </View>
+        )}
+        <Prompt.Actions>
+          <Button
+            variant="solid"
+            color="negative"
+            size={gtMobile ? 'small' : 'medium'}
+            label={_(msg`Yes, delete this starter pack`)}
+            onPress={onDeleteStarterPack}>
+            <ButtonText>
+              <Trans>Delete</Trans>
+            </ButtonText>
+            {isDeletePending && <ButtonIcon icon={Loader} />}
+          </Button>
+          <Prompt.Cancel />
+        </Prompt.Actions>
+      </Prompt.Outer>
+    </>
+  )
+}
+
+function InvalidStarterPack({rkey}: {rkey: string}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const {gtMobile} = useBreakpoints()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+
+  const goBack = () => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.replace('Home')
+    }
+  }
+
+  const {mutate: deleteStarterPack} = useDeleteStarterPackMutation({
+    onSuccess: () => {
+      setIsProcessing(false)
+      goBack()
+    },
+    onError: e => {
+      setIsProcessing(false)
+      logger.error('Failed to delete invalid starter pack', {safeMessage: e})
+      Toast.show(_(msg`Failed to delete starter pack`))
+    },
+  })
+
+  return (
+    <CenteredView
+      style={[
+        a.flex_1,
+        a.align_center,
+        a.gap_5xl,
+        !gtMobile && a.justify_between,
+        t.atoms.border_contrast_low,
+        {paddingTop: 175, paddingBottom: 110},
+      ]}
+      sideBorders={true}>
+      <View style={[a.w_full, a.align_center, a.gap_lg]}>
+        <Text style={[a.font_bold, a.text_3xl]}>
+          <Trans>Starter pack is invalid</Trans>
+        </Text>
+        <Text
+          style={[
+            a.text_md,
+            a.text_center,
+            t.atoms.text_contrast_high,
+            {lineHeight: 1.4},
+            gtMobile ? {width: 450} : [a.w_full, a.px_lg],
+          ]}>
+          <Trans>
+            The starter pack that you are trying to view is invalid. You may
+            delete this starter pack instead.
+          </Trans>
+        </Text>
+      </View>
+      <View style={[a.gap_md, gtMobile ? {width: 350} : [a.w_full, a.px_lg]]}>
+        <Button
+          variant="solid"
+          color="primary"
+          label={_(msg`Delete starter pack`)}
+          size="large"
+          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}
+          disabled={isProcessing}
+          onPress={() => {
+            setIsProcessing(true)
+            deleteStarterPack({rkey})
+          }}>
+          <ButtonText>
+            <Trans>Delete</Trans>
+          </ButtonText>
+          {isProcessing && <Loader size="xs" color="white" />}
+        </Button>
+        <Button
+          variant="solid"
+          color="secondary"
+          label={_(msg`Return to previous page`)}
+          size="large"
+          style={[a.rounded_sm, a.overflow_hidden, {paddingVertical: 10}]}
+          disabled={isProcessing}
+          onPress={goBack}>
+          <ButtonText>
+            <Trans>Go Back</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </CenteredView>
+  )
+}
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 ''
+}