about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-06-21 21:38:04 -0700
committerGitHub <noreply@github.com>2024-06-21 21:38:04 -0700
commitf089f4578131e83cd177b7809ce0f7b75779dfdc (patch)
tree51978aede2040fb8dc319f0749d3de77c7811fbe /src/components
parent35f64535cb8dfa0fe46e740a6398f3b991ecfbc7 (diff)
downloadvoidsky-f089f4578131e83cd177b7809ce0f7b75779dfdc.tar.zst
Starter Packs (#4332)
Co-authored-by: Dan Abramov <dan.abramov@gmail.com>
Co-authored-by: Paul Frazee <pfrazee@gmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components')
-rw-r--r--src/components/LinearGradientBackground.tsx23
-rw-r--r--src/components/NewskieDialog.tsx70
-rw-r--r--src/components/ProfileCard.tsx91
-rw-r--r--src/components/ReportDialog/SelectReportOptionView.tsx3
-rw-r--r--src/components/ReportDialog/types.ts2
-rw-r--r--src/components/StarterPack/Main/FeedsList.tsx68
-rw-r--r--src/components/StarterPack/Main/ProfilesList.tsx119
-rw-r--r--src/components/StarterPack/ProfileStarterPacks.tsx320
-rw-r--r--src/components/StarterPack/QrCode.tsx119
-rw-r--r--src/components/StarterPack/QrCodeDialog.tsx201
-rw-r--r--src/components/StarterPack/ShareDialog.tsx180
-rw-r--r--src/components/StarterPack/StarterPackCard.tsx117
-rw-r--r--src/components/StarterPack/Wizard/ScreenTransition.tsx31
-rw-r--r--src/components/StarterPack/Wizard/WizardEditListDialog.tsx152
-rw-r--r--src/components/StarterPack/Wizard/WizardListCard.tsx182
-rw-r--r--src/components/forms/TextField.tsx2
-rw-r--r--src/components/hooks/useStarterPackEntry.native.ts68
-rw-r--r--src/components/hooks/useStarterPackEntry.ts29
-rw-r--r--src/components/icons/QrCode.tsx5
-rw-r--r--src/components/icons/StarterPack.tsx8
-rw-r--r--src/components/icons/TEMPLATE.tsx31
-rw-r--r--src/components/icons/common.ts32
-rw-r--r--src/components/icons/common.tsx59
23 files changed, 1868 insertions, 44 deletions
diff --git a/src/components/LinearGradientBackground.tsx b/src/components/LinearGradientBackground.tsx
new file mode 100644
index 000000000..f516b19f5
--- /dev/null
+++ b/src/components/LinearGradientBackground.tsx
@@ -0,0 +1,23 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import {LinearGradient} from 'expo-linear-gradient'
+
+import {gradients} from '#/alf/tokens'
+
+export function LinearGradientBackground({
+  style,
+  children,
+}: {
+  style: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  const gradient = gradients.sky.values.map(([_, color]) => {
+    return color
+  })
+
+  return (
+    <LinearGradient colors={gradient} style={style}>
+      {children}
+    </LinearGradient>
+  )
+}
diff --git a/src/components/NewskieDialog.tsx b/src/components/NewskieDialog.tsx
index 0354bfc43..6743a592b 100644
--- a/src/components/NewskieDialog.tsx
+++ b/src/components/NewskieDialog.tsx
@@ -9,11 +9,13 @@ import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {HITSLOP_10} from 'lib/constants'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {atoms as a} from '#/alf'
-import {Button} from '#/components/Button'
+import {isWeb} from 'platform/detection'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
 import {useDialogControl} from '#/components/Dialog'
 import {Newskie} from '#/components/icons/Newskie'
+import * as StarterPackCard from '#/components/StarterPack/StarterPackCard'
 import {Text} from '#/components/Typography'
 
 export function NewskieDialog({
@@ -24,6 +26,7 @@ export function NewskieDialog({
   disabled?: boolean
 }) {
   const {_} = useLingui()
+  const t = useTheme()
   const moderationOpts = useModerationOpts()
   const control = useDialogControl()
   const profileName = React.useMemo(() => {
@@ -68,15 +71,62 @@ export function NewskieDialog({
           label={_(msg`New user info dialog`)}
           style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
           <View style={[a.gap_sm]}>
-            <Text style={[a.font_bold, a.text_xl]}>
-              <Trans>Say hello!</Trans>
-            </Text>
-            <Text style={[a.text_md]}>
-              <Trans>
-                {profileName} joined Bluesky{' '}
-                {timeAgo(createdAt, now, {format: 'long'})} ago
-              </Trans>
+            <View style={[a.align_center]}>
+              <Newskie
+                width={64}
+                height={64}
+                fill="#FFC404"
+                style={{marginTop: -10}}
+              />
+              <Text style={[a.font_bold, a.text_xl, {marginTop: -10}]}>
+                <Trans>Say hello!</Trans>
+              </Text>
+            </View>
+            <Text style={[a.text_md, a.text_center, a.leading_tight]}>
+              {profile.joinedViaStarterPack ? (
+                <Trans>
+                  {profileName} joined Bluesky using a starter pack{' '}
+                  {timeAgo(createdAt, now, {format: 'long'})} ago
+                </Trans>
+              ) : (
+                <Trans>
+                  {profileName} joined Bluesky{' '}
+                  {timeAgo(createdAt, now, {format: 'long'})} ago
+                </Trans>
+              )}
             </Text>
+            {profile.joinedViaStarterPack ? (
+              <StarterPackCard.Link
+                starterPack={profile.joinedViaStarterPack}
+                onPress={() => {
+                  control.close()
+                }}>
+                <View
+                  style={[
+                    a.flex_1,
+                    a.mt_sm,
+                    a.p_lg,
+                    a.border,
+                    a.rounded_sm,
+                    t.atoms.border_contrast_low,
+                  ]}>
+                  <StarterPackCard.Card
+                    starterPack={profile.joinedViaStarterPack}
+                  />
+                </View>
+              </StarterPackCard.Link>
+            ) : null}
+            <Button
+              label={_(msg`Close`)}
+              variant="solid"
+              color="secondary"
+              size="small"
+              style={[a.mt_sm, isWeb && [a.self_center, {marginLeft: 'auto'}]]}
+              onPress={() => control.close()}>
+              <ButtonText>
+                <Trans>Close</Trans>
+              </ButtonText>
+            </Button>
           </View>
         </Dialog.ScrollableInner>
       </Dialog.Outer>
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
new file mode 100644
index 000000000..a0d222854
--- /dev/null
+++ b/src/components/ProfileCard.tsx
@@ -0,0 +1,91 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api'
+
+import {createSanitizedDisplayName} from 'lib/moderation/create-sanitized-display-name'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useSession} from 'state/session'
+import {FollowButton} from 'view/com/profile/FollowButton'
+import {ProfileCardPills} from 'view/com/profile/ProfileCard'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Link} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Default({
+  profile: profileUnshadowed,
+  moderationOpts,
+  logContext = 'ProfileCard',
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  moderationOpts: ModerationOpts
+  logContext?: 'ProfileCard' | 'StarterPackProfilesList'
+}) {
+  const t = useTheme()
+  const {currentAccount, hasSession} = useSession()
+
+  const profile = useProfileShadow(profileUnshadowed)
+  const name = createSanitizedDisplayName(profile)
+  const handle = `@${sanitizeHandle(profile.handle)}`
+  const moderation = moderateProfile(profile, moderationOpts)
+
+  return (
+    <Wrapper did={profile.did}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        <UserAvatar
+          size={42}
+          avatar={profile.avatar}
+          type={
+            profile.associated?.labeler
+              ? 'labeler'
+              : profile.associated?.feedgens
+              ? 'algo'
+              : 'user'
+          }
+          moderation={moderation.ui('avatar')}
+        />
+        <View style={[a.flex_1]}>
+          <Text
+            style={[a.text_md, a.font_bold, a.leading_snug]}
+            numberOfLines={1}>
+            {name}
+          </Text>
+          <Text
+            style={[a.leading_snug, t.atoms.text_contrast_medium]}
+            numberOfLines={1}>
+            {handle}
+          </Text>
+        </View>
+        {hasSession && profile.did !== currentAccount?.did && (
+          <View style={[a.justify_center, {marginLeft: 'auto'}]}>
+            <FollowButton profile={profile} logContext={logContext} />
+          </View>
+        )}
+      </View>
+      <View style={[a.mb_xs]}>
+        <ProfileCardPills
+          followedBy={Boolean(profile.viewer?.followedBy)}
+          moderation={moderation}
+        />
+      </View>
+      {profile.description && (
+        <Text numberOfLines={3} style={[a.leading_snug]}>
+          {profile.description}
+        </Text>
+      )}
+    </Wrapper>
+  )
+}
+
+function Wrapper({did, children}: {did: string; children: React.ReactNode}) {
+  return (
+    <Link
+      to={{
+        screen: 'Profile',
+        params: {name: did},
+      }}>
+      <View style={[a.flex_1, a.gap_xs]}>{children}</View>
+    </Link>
+  )
+}
diff --git a/src/components/ReportDialog/SelectReportOptionView.tsx b/src/components/ReportDialog/SelectReportOptionView.tsx
index 4413cbe89..169c07d73 100644
--- a/src/components/ReportDialog/SelectReportOptionView.tsx
+++ b/src/components/ReportDialog/SelectReportOptionView.tsx
@@ -55,6 +55,9 @@ export function SelectReportOptionView({
     } else if (props.params.type === 'feedgen') {
       title = _(msg`Report this feed`)
       description = _(msg`Why should this feed be reviewed?`)
+    } else if (props.params.type === 'starterpack') {
+      title = _(msg`Report this starter pack`)
+      description = _(msg`Why should this starter pack be reviewed?`)
     } else if (props.params.type === 'convoMessage') {
       title = _(msg`Report this message`)
       description = _(msg`Why should this message be reviewed?`)
diff --git a/src/components/ReportDialog/types.ts b/src/components/ReportDialog/types.ts
index ceabe0b90..3f43db4a1 100644
--- a/src/components/ReportDialog/types.ts
+++ b/src/components/ReportDialog/types.ts
@@ -4,7 +4,7 @@ export type ReportDialogProps = {
   control: Dialog.DialogOuterProps['control']
   params:
     | {
-        type: 'post' | 'list' | 'feedgen' | 'other'
+        type: 'post' | 'list' | 'feedgen' | 'starterpack' | 'other'
         uri: string
         cid: string
       }
diff --git a/src/components/StarterPack/Main/FeedsList.tsx b/src/components/StarterPack/Main/FeedsList.tsx
new file mode 100644
index 000000000..e350a422c
--- /dev/null
+++ b/src/components/StarterPack/Main/FeedsList.tsx
@@ -0,0 +1,68 @@
+import React, {useCallback} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {AppBskyFeedDefs} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {isNative, isWeb} from 'platform/detection'
+import {List, ListRef} from 'view/com/util/List'
+import {SectionRef} from '#/screens/Profile/Sections/types'
+import {atoms as a, useTheme} from '#/alf'
+import * as FeedCard from '#/components/FeedCard'
+
+function keyExtractor(item: AppBskyFeedDefs.GeneratorView) {
+  return item.uri
+}
+
+interface ProfilesListProps {
+  feeds: AppBskyFeedDefs.GeneratorView[]
+  headerHeight: number
+  scrollElRef: ListRef
+}
+
+export const FeedsList = React.forwardRef<SectionRef, ProfilesListProps>(
+  function FeedsListImpl({feeds, headerHeight, scrollElRef}, ref) {
+    const [initialHeaderHeight] = React.useState(headerHeight)
+    const bottomBarOffset = useBottomBarOffset(20)
+    const t = useTheme()
+
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({
+        animated: isNative,
+        offset: -headerHeight,
+      })
+    }, [scrollElRef, headerHeight])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderItem = ({item, index}: ListRenderItemInfo<GeneratorView>) => {
+      return (
+        <View
+          style={[
+            a.p_lg,
+            (isWeb || index !== 0) && a.border_t,
+            t.atoms.border_contrast_low,
+          ]}>
+          <FeedCard.Default type="feed" view={item} />
+        </View>
+      )
+    }
+
+    return (
+      <List
+        data={feeds}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        ref={scrollElRef}
+        headerOffset={headerHeight}
+        ListFooterComponent={
+          <View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
+        }
+        showsVerticalScrollIndicator={false}
+        desktopFixedHeight={true}
+      />
+    )
+  },
+)
diff --git a/src/components/StarterPack/Main/ProfilesList.tsx b/src/components/StarterPack/Main/ProfilesList.tsx
new file mode 100644
index 000000000..72d35fe2b
--- /dev/null
+++ b/src/components/StarterPack/Main/ProfilesList.tsx
@@ -0,0 +1,119 @@
+import React, {useCallback} from 'react'
+import {ListRenderItemInfo, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyGraphGetList,
+  AtUri,
+  ModerationOpts,
+} from '@atproto/api'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {isNative, isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {List, ListRef} from 'view/com/util/List'
+import {SectionRef} from '#/screens/Profile/Sections/types'
+import {atoms as a, useTheme} from '#/alf'
+import {Default as ProfileCard} from '#/components/ProfileCard'
+
+function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic, index: number) {
+  return `${item.did}-${index}`
+}
+
+interface ProfilesListProps {
+  listUri: string
+  listMembersQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetList.OutputSchema>
+  >
+  moderationOpts: ModerationOpts
+  headerHeight: number
+  scrollElRef: ListRef
+}
+
+export const ProfilesList = React.forwardRef<SectionRef, ProfilesListProps>(
+  function ProfilesListImpl(
+    {listUri, listMembersQuery, moderationOpts, headerHeight, scrollElRef},
+    ref,
+  ) {
+    const t = useTheme()
+    const [initialHeaderHeight] = React.useState(headerHeight)
+    const bottomBarOffset = useBottomBarOffset(20)
+    const {currentAccount} = useSession()
+
+    const [isPTRing, setIsPTRing] = React.useState(false)
+
+    const {data, refetch} = listMembersQuery
+
+    // The server returns these sorted by descending creation date, so we want to invert
+    const profiles = data?.pages
+      .flatMap(p => p.items.map(i => i.subject))
+      .reverse()
+    const isOwn = new AtUri(listUri).host === currentAccount?.did
+
+    const getSortedProfiles = () => {
+      if (!profiles) return
+      if (!isOwn) return profiles
+
+      const myIndex = profiles.findIndex(p => p.did === currentAccount?.did)
+      return myIndex !== -1
+        ? [
+            profiles[myIndex],
+            ...profiles.slice(0, myIndex),
+            ...profiles.slice(myIndex + 1),
+          ]
+        : profiles
+    }
+    const onScrollToTop = useCallback(() => {
+      scrollElRef.current?.scrollToOffset({
+        animated: isNative,
+        offset: -headerHeight,
+      })
+    }, [scrollElRef, headerHeight])
+
+    React.useImperativeHandle(ref, () => ({
+      scrollToTop: onScrollToTop,
+    }))
+
+    const renderItem = ({
+      item,
+      index,
+    }: ListRenderItemInfo<AppBskyActorDefs.ProfileViewBasic>) => {
+      return (
+        <View
+          style={[
+            a.p_lg,
+            t.atoms.border_contrast_low,
+            (isWeb || index !== 0) && a.border_t,
+          ]}>
+          <ProfileCard
+            profile={item}
+            moderationOpts={moderationOpts}
+            logContext="StarterPackProfilesList"
+          />
+        </View>
+      )
+    }
+
+    if (listMembersQuery)
+      return (
+        <List
+          data={getSortedProfiles()}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          ref={scrollElRef}
+          headerOffset={headerHeight}
+          ListFooterComponent={
+            <View style={[{height: initialHeaderHeight + bottomBarOffset}]} />
+          }
+          showsVerticalScrollIndicator={false}
+          desktopFixedHeight
+          refreshing={isPTRing}
+          onRefresh={async () => {
+            setIsPTRing(true)
+            await refetch()
+            setIsPTRing(false)
+          }}
+        />
+      )
+  },
+)
diff --git a/src/components/StarterPack/ProfileStarterPacks.tsx b/src/components/StarterPack/ProfileStarterPacks.tsx
new file mode 100644
index 000000000..096f04f2d
--- /dev/null
+++ b/src/components/StarterPack/ProfileStarterPacks.tsx
@@ -0,0 +1,320 @@
+import React from 'react'
+import {
+  findNodeHandle,
+  ListRenderItemInfo,
+  StyleProp,
+  View,
+  ViewStyle,
+} from 'react-native'
+import {AppBskyGraphDefs, AppBskyGraphGetActorStarterPacks} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+import {InfiniteData, UseInfiniteQueryResult} from '@tanstack/react-query'
+
+import {logger} from '#/logger'
+import {useGenerateStarterPackMutation} from 'lib/generate-starterpack'
+import {useBottomBarOffset} from 'lib/hooks/useBottomBarOffset'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {NavigationProp} from 'lib/routes/types'
+import {parseStarterPackUri} from 'lib/strings/starter-pack'
+import {List, ListRef} from 'view/com/util/List'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {Loader} from '#/components/Loader'
+import * as Prompt from '#/components/Prompt'
+import {Default as StarterPackCard} from '#/components/StarterPack/StarterPackCard'
+import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '../icons/Plus'
+
+interface SectionRef {
+  scrollToTop: () => void
+}
+
+interface ProfileFeedgensProps {
+  starterPacksQuery: UseInfiniteQueryResult<
+    InfiniteData<AppBskyGraphGetActorStarterPacks.OutputSchema, unknown>,
+    Error
+  >
+  scrollElRef: ListRef
+  headerOffset: number
+  enabled?: boolean
+  style?: StyleProp<ViewStyle>
+  testID?: string
+  setScrollViewTag: (tag: number | null) => void
+  isMe: boolean
+}
+
+function keyExtractor(item: AppBskyGraphDefs.StarterPackView) {
+  return item.uri
+}
+
+export const ProfileStarterPacks = React.forwardRef<
+  SectionRef,
+  ProfileFeedgensProps
+>(function ProfileFeedgensImpl(
+  {
+    starterPacksQuery: query,
+    scrollElRef,
+    headerOffset,
+    enabled,
+    style,
+    testID,
+    setScrollViewTag,
+    isMe,
+  },
+  ref,
+) {
+  const t = useTheme()
+  const bottomBarOffset = useBottomBarOffset(100)
+  const [isPTRing, setIsPTRing] = React.useState(false)
+  const {data, refetch, isFetching, hasNextPage, fetchNextPage} = query
+  const {isTabletOrDesktop} = useWebMediaQueries()
+
+  const items = data?.pages.flatMap(page => page.starterPacks)
+
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: () => {},
+  }))
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTRing(true)
+    try {
+      await refetch()
+    } catch (err) {
+      logger.error('Failed to refresh starter packs', {message: err})
+    }
+    setIsPTRing(false)
+  }, [refetch, setIsPTRing])
+
+  const onEndReached = React.useCallback(async () => {
+    if (isFetching || !hasNextPage) return
+
+    try {
+      await fetchNextPage()
+    } catch (err) {
+      logger.error('Failed to load more starter packs', {message: err})
+    }
+  }, [isFetching, hasNextPage, fetchNextPage])
+
+  React.useEffect(() => {
+    if (enabled && scrollElRef.current) {
+      const nativeTag = findNodeHandle(scrollElRef.current)
+      setScrollViewTag(nativeTag)
+    }
+  }, [enabled, scrollElRef, setScrollViewTag])
+
+  const renderItem = ({
+    item,
+    index,
+  }: ListRenderItemInfo<AppBskyGraphDefs.StarterPackView>) => {
+    return (
+      <View
+        style={[
+          a.p_lg,
+          (isTabletOrDesktop || index !== 0) && a.border_t,
+          t.atoms.border_contrast_low,
+        ]}>
+        <StarterPackCard starterPack={item} />
+      </View>
+    )
+  }
+
+  return (
+    <View testID={testID} style={style}>
+      <List
+        testID={testID ? `${testID}-flatlist` : undefined}
+        ref={scrollElRef}
+        data={items}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        refreshing={isPTRing}
+        headerOffset={headerOffset}
+        contentContainerStyle={{paddingBottom: headerOffset + bottomBarOffset}}
+        indicatorStyle={t.name === 'light' ? 'black' : 'white'}
+        removeClippedSubviews={true}
+        desktopFixedHeight
+        onEndReached={onEndReached}
+        onRefresh={onRefresh}
+        ListEmptyComponent={Empty}
+        ListFooterComponent={
+          items?.length !== 0 && isMe ? CreateAnother : undefined
+        }
+      />
+    </View>
+  )
+})
+
+function CreateAnother() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+
+  return (
+    <View
+      style={[
+        a.pr_md,
+        a.pt_lg,
+        a.gap_lg,
+        a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <Button
+        label={_(msg`Create a starter pack`)}
+        variant="solid"
+        color="secondary"
+        size="small"
+        style={[a.self_center]}
+        onPress={() => navigation.navigate('StarterPackWizard')}>
+        <ButtonText>
+          <Trans>Create another</Trans>
+        </ButtonText>
+        <ButtonIcon icon={Plus} position="right" />
+      </Button>
+    </View>
+  )
+}
+
+function Empty() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const navigation = useNavigation<NavigationProp>()
+  const confirmDialogControl = useDialogControl()
+  const followersDialogControl = useDialogControl()
+  const errorDialogControl = useDialogControl()
+
+  const [isGenerating, setIsGenerating] = React.useState(false)
+
+  const {mutate: generateStarterPack} = useGenerateStarterPackMutation({
+    onSuccess: ({uri}) => {
+      const parsed = parseStarterPackUri(uri)
+      if (parsed) {
+        navigation.push('StarterPack', {
+          name: parsed.name,
+          rkey: parsed.rkey,
+        })
+      }
+      setIsGenerating(false)
+    },
+    onError: e => {
+      logger.error('Failed to generate starter pack', {safeMessage: e})
+      setIsGenerating(false)
+      if (e.name === 'NOT_ENOUGH_FOLLOWERS') {
+        followersDialogControl.open()
+      } else {
+        errorDialogControl.open()
+      }
+    },
+  })
+
+  const generate = () => {
+    setIsGenerating(true)
+    generateStarterPack()
+  }
+
+  return (
+    <LinearGradientBackground
+      style={[
+        a.px_lg,
+        a.py_lg,
+        a.justify_between,
+        a.gap_lg,
+        a.shadow_lg,
+        {marginTop: 2},
+      ]}>
+      <View style={[a.gap_xs]}>
+        <Text
+          style={[
+            a.font_bold,
+            a.text_lg,
+            t.atoms.text_contrast_medium,
+            {color: 'white'},
+          ]}>
+          You haven't created a starter pack yet!
+        </Text>
+        <Text style={[a.text_md, {color: 'white'}]}>
+          Starter packs let you easily share your favorite feeds and people with
+          your friends.
+        </Text>
+      </View>
+      <View style={[a.flex_row, a.gap_md, {marginLeft: 'auto'}]}>
+        <Button
+          label={_(msg`Create a starter pack for me`)}
+          variant="ghost"
+          color="primary"
+          size="small"
+          disabled={isGenerating}
+          onPress={confirmDialogControl.open}
+          style={{backgroundColor: 'transparent'}}>
+          <ButtonText style={{color: 'white'}}>
+            <Trans>Make one for me</Trans>
+          </ButtonText>
+          {isGenerating && <Loader size="md" />}
+        </Button>
+        <Button
+          label={_(msg`Create a starter pack`)}
+          variant="ghost"
+          color="primary"
+          size="small"
+          disabled={isGenerating}
+          onPress={() => navigation.navigate('StarterPackWizard')}
+          style={{
+            backgroundColor: 'white',
+            borderColor: 'white',
+            width: 100,
+          }}
+          hoverStyle={[{backgroundColor: '#dfdfdf'}]}>
+          <ButtonText>
+            <Trans>Create</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+
+      <Prompt.Outer control={confirmDialogControl}>
+        <Prompt.TitleText>
+          <Trans>Generate a starter pack</Trans>
+        </Prompt.TitleText>
+        <Prompt.DescriptionText>
+          <Trans>
+            Bluesky will choose a set of recommended accounts from people in
+            your network.
+          </Trans>
+        </Prompt.DescriptionText>
+        <Prompt.Actions>
+          <Prompt.Action
+            color="primary"
+            cta={_(msg`Choose for me`)}
+            onPress={generate}
+          />
+          <Prompt.Action
+            color="secondary"
+            cta={_(msg`Let me choose`)}
+            onPress={() => {
+              navigation.navigate('StarterPackWizard')
+            }}
+          />
+        </Prompt.Actions>
+      </Prompt.Outer>
+      <Prompt.Basic
+        control={followersDialogControl}
+        title={_(msg`Oops!`)}
+        description={_(
+          msg`You must be following at least seven other people to generate a starter pack.`,
+        )}
+        onConfirm={() => {}}
+        showCancel={false}
+      />
+      <Prompt.Basic
+        control={errorDialogControl}
+        title={_(msg`Oops!`)}
+        description={_(
+          msg`An error occurred while generating your starter pack. Want to try again?`,
+        )}
+        onConfirm={generate}
+        confirmButtonCta={_(msg`Retry`)}
+      />
+    </LinearGradientBackground>
+  )
+}
diff --git a/src/components/StarterPack/QrCode.tsx b/src/components/StarterPack/QrCode.tsx
new file mode 100644
index 000000000..08ee03d62
--- /dev/null
+++ b/src/components/StarterPack/QrCode.tsx
@@ -0,0 +1,119 @@
+import React from 'react'
+import {View} from 'react-native'
+import QRCode from 'react-native-qrcode-styled'
+import ViewShot from 'react-native-view-shot'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {Trans} from '@lingui/macro'
+
+import {isWeb} from 'platform/detection'
+import {Logo} from 'view/icons/Logo'
+import {Logotype} from 'view/icons/Logotype'
+import {useTheme} from '#/alf'
+import {atoms as a} from '#/alf'
+import {LinearGradientBackground} from '#/components/LinearGradientBackground'
+import {Text} from '#/components/Typography'
+
+interface Props {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  link: string
+}
+
+export const QrCode = React.forwardRef<ViewShot, Props>(function QrCode(
+  {starterPack, link},
+  ref,
+) {
+  const {record} = starterPack
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <ViewShot ref={ref}>
+      <LinearGradientBackground
+        style={[
+          {width: 300, minHeight: 390},
+          a.align_center,
+          a.px_sm,
+          a.py_xl,
+          a.rounded_sm,
+          a.justify_between,
+          a.gap_md,
+        ]}>
+        <View style={[a.gap_sm]}>
+          <Text
+            style={[a.font_bold, a.text_3xl, a.text_center, {color: 'white'}]}>
+            {record.name}
+          </Text>
+        </View>
+        <View style={[a.gap_xl, a.align_center]}>
+          <Text
+            style={[
+              a.font_bold,
+              a.text_center,
+              {color: 'white', fontSize: 18},
+            ]}>
+            <Trans>Join the conversation</Trans>
+          </Text>
+          <View style={[a.rounded_sm, a.overflow_hidden]}>
+            <QrCodeInner link={link} />
+          </View>
+
+          <View style={[a.flex_row, a.align_center, {gap: 5}]}>
+            <Text
+              style={[
+                a.font_bold,
+                a.text_center,
+                {color: 'white', fontSize: 18},
+              ]}>
+              <Trans>on</Trans>
+            </Text>
+            <Logo width={26} fill="white" />
+            <View style={[{marginTop: 5, marginLeft: 2.5}]}>
+              <Logotype width={68} fill="white" />
+            </View>
+          </View>
+        </View>
+      </LinearGradientBackground>
+    </ViewShot>
+  )
+})
+
+export function QrCodeInner({link}: {link: string}) {
+  const t = useTheme()
+
+  return (
+    <QRCode
+      data={link}
+      style={[
+        a.rounded_sm,
+        {height: 225, width: 225, backgroundColor: '#f3f3f3'},
+      ]}
+      pieceSize={isWeb ? 8 : 6}
+      padding={20}
+      // pieceLiquidRadius={2}
+      pieceBorderRadius={isWeb ? 4.5 : 3.5}
+      outerEyesOptions={{
+        topLeft: {
+          borderRadius: [12, 12, 0, 12],
+          color: t.palette.primary_500,
+        },
+        topRight: {
+          borderRadius: [12, 12, 12, 0],
+          color: t.palette.primary_500,
+        },
+        bottomLeft: {
+          borderRadius: [12, 0, 12, 12],
+          color: t.palette.primary_500,
+        },
+      }}
+      innerEyesOptions={{borderRadius: 3}}
+      logo={{
+        href: require('../../../assets/logo.png'),
+        scale: 1.2,
+        padding: 2,
+        hidePieces: true,
+      }}
+    />
+  )
+}
diff --git a/src/components/StarterPack/QrCodeDialog.tsx b/src/components/StarterPack/QrCodeDialog.tsx
new file mode 100644
index 000000000..580c6cc7c
--- /dev/null
+++ b/src/components/StarterPack/QrCodeDialog.tsx
@@ -0,0 +1,201 @@
+import React from 'react'
+import {View} from 'react-native'
+import ViewShot from 'react-native-view-shot'
+import * as FS from 'expo-file-system'
+import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
+import * as Sharing from 'expo-sharing'
+import {AppBskyGraphDefs, AppBskyGraphStarterpack} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {saveImageToMediaLibrary} from 'lib/media/manip'
+import {logEvent} from 'lib/statsig/statsig'
+import {isNative, isWeb} from 'platform/detection'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {DialogControlProps} from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {QrCode} from '#/components/StarterPack/QrCode'
+
+export function QrCodeDialog({
+  starterPack,
+  link,
+  control,
+}: {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  link?: string
+  control: DialogControlProps
+}) {
+  const {_} = useLingui()
+  const [isProcessing, setIsProcessing] = React.useState(false)
+
+  const ref = React.useRef<ViewShot>(null)
+
+  const getCanvas = (base64: string): Promise<HTMLCanvasElement> => {
+    return new Promise(resolve => {
+      const image = new Image()
+      image.onload = () => {
+        const canvas = document.createElement('canvas')
+        canvas.width = image.width
+        canvas.height = image.height
+
+        const ctx = canvas.getContext('2d')
+        ctx?.drawImage(image, 0, 0)
+        resolve(canvas)
+      }
+      image.src = base64
+    })
+  }
+
+  const onSavePress = async () => {
+    ref.current?.capture?.().then(async (uri: string) => {
+      if (isNative) {
+        const res = await requestMediaLibraryPermissionsAsync()
+
+        if (!res) {
+          Toast.show(
+            _(
+              msg`You must grant access to your photo library to save a QR code`,
+            ),
+          )
+          return
+        }
+
+        const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
+
+        // Incase of a FS failure, don't crash the app
+        try {
+          await FS.copyAsync({from: uri, to: filename})
+          await saveImageToMediaLibrary({uri: filename})
+          await FS.deleteAsync(filename)
+        } catch (e: unknown) {
+          Toast.show(_(msg`An error occurred while saving the QR code!`))
+          logger.error('Failed to save QR code', {error: e})
+          return
+        }
+      } else {
+        setIsProcessing(true)
+
+        if (!AppBskyGraphStarterpack.isRecord(starterPack.record)) {
+          return
+        }
+
+        const canvas = await getCanvas(uri)
+        const imgHref = canvas
+          .toDataURL('image/png')
+          .replace('image/png', 'image/octet-stream')
+
+        const link = document.createElement('a')
+        link.setAttribute(
+          'download',
+          `${starterPack.record.name.replaceAll(' ', '_')}_Share_Card.png`,
+        )
+        link.setAttribute('href', imgHref)
+        link.click()
+      }
+
+      logEvent('starterPack:share', {
+        starterPack: starterPack.uri,
+        shareType: 'qrcode',
+        qrShareType: 'save',
+      })
+      setIsProcessing(false)
+      Toast.show(
+        isWeb
+          ? _(msg`QR code has been downloaded!`)
+          : _(msg`QR code saved to your camera roll!`),
+      )
+      control.close()
+    })
+  }
+
+  const onCopyPress = async () => {
+    setIsProcessing(true)
+    ref.current?.capture?.().then(async (uri: string) => {
+      const canvas = await getCanvas(uri)
+      // @ts-expect-error web only
+      canvas.toBlob((blob: Blob) => {
+        const item = new ClipboardItem({'image/png': blob})
+        navigator.clipboard.write([item])
+      })
+
+      logEvent('starterPack:share', {
+        starterPack: starterPack.uri,
+        shareType: 'qrcode',
+        qrShareType: 'copy',
+      })
+      Toast.show(_(msg`QR code copied to your clipboard!`))
+      setIsProcessing(false)
+      control.close()
+    })
+  }
+
+  const onSharePress = async () => {
+    ref.current?.capture?.().then(async (uri: string) => {
+      control.close(() => {
+        Sharing.shareAsync(uri, {mimeType: 'image/png', UTI: 'image/png'}).then(
+          () => {
+            logEvent('starterPack:share', {
+              starterPack: starterPack.uri,
+              shareType: 'qrcode',
+              qrShareType: 'share',
+            })
+          },
+        )
+      })
+    })
+  }
+
+  return (
+    <Dialog.Outer control={control}>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner
+        label={_(msg`Create a QR code for a starter pack`)}>
+        <View style={[a.flex_1, a.align_center, a.gap_5xl]}>
+          {!link ? (
+            <View style={[a.align_center, a.p_xl]}>
+              <Loader size="xl" />
+            </View>
+          ) : (
+            <>
+              <QrCode starterPack={starterPack} link={link} ref={ref} />
+              {isProcessing ? (
+                <View>
+                  <Loader size="xl" />
+                </View>
+              ) : (
+                <View
+                  style={[a.w_full, a.gap_md, isWeb && [a.flex_row_reverse]]}>
+                  <Button
+                    label={_(msg`Copy QR code`)}
+                    variant="solid"
+                    color="secondary"
+                    size="small"
+                    onPress={isWeb ? onCopyPress : onSharePress}>
+                    <ButtonText>
+                      {isWeb ? <Trans>Copy</Trans> : <Trans>Share</Trans>}
+                    </ButtonText>
+                  </Button>
+                  <Button
+                    label={_(msg`Save QR code`)}
+                    variant="solid"
+                    color="secondary"
+                    size="small"
+                    onPress={onSavePress}>
+                    <ButtonText>
+                      <Trans>Save</Trans>
+                    </ButtonText>
+                  </Button>
+                </View>
+              )}
+            </>
+          )}
+        </View>
+      </Dialog.ScrollableInner>
+    </Dialog.Outer>
+  )
+}
diff --git a/src/components/StarterPack/ShareDialog.tsx b/src/components/StarterPack/ShareDialog.tsx
new file mode 100644
index 000000000..23fa10fb3
--- /dev/null
+++ b/src/components/StarterPack/ShareDialog.tsx
@@ -0,0 +1,180 @@
+import React from 'react'
+import {View} from 'react-native'
+import * as FS from 'expo-file-system'
+import {Image} from 'expo-image'
+import {requestMediaLibraryPermissionsAsync} from 'expo-image-picker'
+import {AppBskyGraphDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {logger} from '#/logger'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {saveImageToMediaLibrary} from 'lib/media/manip'
+import {shareUrl} from 'lib/sharing'
+import {logEvent} from 'lib/statsig/statsig'
+import {getStarterPackOgCard} from 'lib/strings/starter-pack'
+import {isNative, isWeb} from 'platform/detection'
+import * as Toast from 'view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {DialogControlProps} from '#/components/Dialog'
+import * as Dialog from '#/components/Dialog'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+interface Props {
+  starterPack: AppBskyGraphDefs.StarterPackView
+  link?: string
+  imageLoaded?: boolean
+  qrDialogControl: DialogControlProps
+  control: DialogControlProps
+}
+
+export function ShareDialog(props: Props) {
+  return (
+    <Dialog.Outer control={props.control}>
+      <ShareDialogInner {...props} />
+    </Dialog.Outer>
+  )
+}
+
+function ShareDialogInner({
+  starterPack,
+  link,
+  imageLoaded,
+  qrDialogControl,
+  control,
+}: Props) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {isTabletOrDesktop} = useWebMediaQueries()
+
+  const imageUrl = getStarterPackOgCard(starterPack)
+
+  const onShareLink = async () => {
+    if (!link) return
+    shareUrl(link)
+    logEvent('starterPack:share', {
+      starterPack: starterPack.uri,
+      shareType: 'link',
+    })
+    control.close()
+  }
+
+  const onSave = async () => {
+    const res = await requestMediaLibraryPermissionsAsync()
+
+    if (!res) {
+      Toast.show(
+        _(msg`You must grant access to your photo library to save the image.`),
+      )
+      return
+    }
+
+    const cachePath = await Image.getCachePathAsync(imageUrl)
+    const filename = `${FS.documentDirectory}/${nanoid(12)}.png`
+
+    if (!cachePath) {
+      Toast.show(_(msg`An error occurred while saving the image.`))
+      return
+    }
+
+    try {
+      await FS.copyAsync({from: cachePath, to: filename})
+      await saveImageToMediaLibrary({uri: filename})
+      await FS.deleteAsync(filename)
+
+      Toast.show(_(msg`Image saved to your camera roll!`))
+      control.close()
+    } catch (e: unknown) {
+      Toast.show(_(msg`An error occurred while saving the QR code!`))
+      logger.error('Failed to save QR code', {error: e})
+      return
+    }
+  }
+
+  return (
+    <>
+      <Dialog.Handle />
+      <Dialog.ScrollableInner label={_(msg`Share link dialog`)}>
+        {!imageLoaded || !link ? (
+          <View style={[a.p_xl, a.align_center]}>
+            <Loader size="xl" />
+          </View>
+        ) : (
+          <View style={[!isTabletOrDesktop && a.gap_lg]}>
+            <View style={[a.gap_sm, isTabletOrDesktop && a.pb_lg]}>
+              <Text style={[a.font_bold, a.text_2xl]}>
+                <Trans>Invite people to this starter pack!</Trans>
+              </Text>
+              <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+                <Trans>
+                  Share this starter pack and help people join your community on
+                  Bluesky.
+                </Trans>
+              </Text>
+            </View>
+            <Image
+              source={{uri: imageUrl}}
+              style={[
+                a.rounded_sm,
+                {
+                  aspectRatio: 1200 / 630,
+                  transform: [{scale: isTabletOrDesktop ? 0.85 : 1}],
+                  marginTop: isTabletOrDesktop ? -20 : 0,
+                },
+              ]}
+              accessibilityIgnoresInvertColors={true}
+            />
+            <View
+              style={[
+                a.gap_md,
+                isWeb && [a.gap_sm, a.flex_row_reverse, {marginLeft: 'auto'}],
+              ]}>
+              <Button
+                label="Share link"
+                variant="solid"
+                color="secondary"
+                size="small"
+                style={[isWeb && a.self_center]}
+                onPress={onShareLink}>
+                <ButtonText>
+                  {isWeb ? <Trans>Copy Link</Trans> : <Trans>Share Link</Trans>}
+                </ButtonText>
+              </Button>
+              <Button
+                label="Create QR code"
+                variant="solid"
+                color="secondary"
+                size="small"
+                style={[isWeb && a.self_center]}
+                onPress={() => {
+                  control.close(() => {
+                    qrDialogControl.open()
+                  })
+                }}>
+                <ButtonText>
+                  <Trans>Create QR code</Trans>
+                </ButtonText>
+              </Button>
+              {isNative && (
+                <Button
+                  label={_(msg`Save image`)}
+                  variant="ghost"
+                  color="secondary"
+                  size="small"
+                  style={[isWeb && a.self_center]}
+                  onPress={onSave}>
+                  <ButtonText>
+                    <Trans>Save image</Trans>
+                  </ButtonText>
+                </Button>
+              )}
+            </View>
+          </View>
+        )}
+      </Dialog.ScrollableInner>
+    </>
+  )
+}
diff --git a/src/components/StarterPack/StarterPackCard.tsx b/src/components/StarterPack/StarterPackCard.tsx
new file mode 100644
index 000000000..ab904d7ff
--- /dev/null
+++ b/src/components/StarterPack/StarterPackCard.tsx
@@ -0,0 +1,117 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyGraphStarterpack, AtUri} from '@atproto/api'
+import {StarterPackViewBasic} from '@atproto/api/dist/client/types/app/bsky/graph/defs'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {StarterPack} from '#/components/icons/StarterPack'
+import {Link as InternalLink, LinkProps} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Default({starterPack}: {starterPack?: StarterPackViewBasic}) {
+  if (!starterPack) return null
+  return (
+    <Link starterPack={starterPack}>
+      <Card starterPack={starterPack} />
+    </Link>
+  )
+}
+
+export function Notification({
+  starterPack,
+}: {
+  starterPack?: StarterPackViewBasic
+}) {
+  if (!starterPack) return null
+  return (
+    <Link starterPack={starterPack}>
+      <Card starterPack={starterPack} noIcon={true} noDescription={true} />
+    </Link>
+  )
+}
+
+export function Card({
+  starterPack,
+  noIcon,
+  noDescription,
+}: {
+  starterPack: StarterPackViewBasic
+  noIcon?: boolean
+  noDescription?: boolean
+}) {
+  const {record, creator, joinedAllTimeCount} = starterPack
+
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <View style={[a.flex_1, a.gap_md]}>
+      <View style={[a.flex_row, a.gap_sm]}>
+        {!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
+        <View>
+          <Text style={[a.text_md, a.font_bold, a.leading_snug]}>
+            {record.name}
+          </Text>
+          <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
+            <Trans>
+              Starter pack by{' '}
+              {creator?.did === currentAccount?.did
+                ? _(msg`you`)
+                : `@${sanitizeHandle(creator.handle)}`}
+            </Trans>
+          </Text>
+        </View>
+      </View>
+      {!noDescription && record.description ? (
+        <Text numberOfLines={3} style={[a.leading_snug]}>
+          {record.description}
+        </Text>
+      ) : null}
+      {!!joinedAllTimeCount && joinedAllTimeCount >= 50 && (
+        <Text style={[a.font_bold, t.atoms.text_contrast_medium]}>
+          {joinedAllTimeCount} users have joined!
+        </Text>
+      )}
+    </View>
+  )
+}
+
+export function Link({
+  starterPack,
+  children,
+  ...rest
+}: {
+  starterPack: StarterPackViewBasic
+} & Omit<LinkProps, 'to'>) {
+  const {record} = starterPack
+  const {rkey, handleOrDid} = React.useMemo(() => {
+    const rkey = new AtUri(starterPack.uri).rkey
+    const {creator} = starterPack
+    return {rkey, handleOrDid: creator.handle || creator.did}
+  }, [starterPack])
+
+  if (!AppBskyGraphStarterpack.isRecord(record)) {
+    return null
+  }
+
+  return (
+    <InternalLink
+      label={record.name}
+      {...rest}
+      to={{
+        screen: 'StarterPack',
+        params: {name: handleOrDid, rkey},
+      }}>
+      {children}
+    </InternalLink>
+  )
+}
diff --git a/src/components/StarterPack/Wizard/ScreenTransition.tsx b/src/components/StarterPack/Wizard/ScreenTransition.tsx
new file mode 100644
index 000000000..b7cd4e4c1
--- /dev/null
+++ b/src/components/StarterPack/Wizard/ScreenTransition.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import {StyleProp, ViewStyle} from 'react-native'
+import Animated, {
+  FadeIn,
+  FadeOut,
+  SlideInLeft,
+  SlideInRight,
+} from 'react-native-reanimated'
+
+import {isWeb} from 'platform/detection'
+
+export function ScreenTransition({
+  direction,
+  style,
+  children,
+}: {
+  direction: 'Backward' | 'Forward'
+  style?: StyleProp<ViewStyle>
+  children: React.ReactNode
+}) {
+  const entering = direction === 'Forward' ? SlideInRight : SlideInLeft
+
+  return (
+    <Animated.View
+      entering={isWeb ? FadeIn.duration(90) : entering}
+      exiting={FadeOut.duration(90)} // Totally vibes based
+      style={style}>
+      {children}
+    </Animated.View>
+  )
+}
diff --git a/src/components/StarterPack/Wizard/WizardEditListDialog.tsx b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
new file mode 100644
index 000000000..bf250ac35
--- /dev/null
+++ b/src/components/StarterPack/Wizard/WizardEditListDialog.tsx
@@ -0,0 +1,152 @@
+import React, {useRef} from 'react'
+import type {ListRenderItemInfo} from 'react-native'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationOpts} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {BottomSheetFlatListMethods} from '@discord/bottom-sheet'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from 'platform/detection'
+import {useSession} from 'state/session'
+import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, native, useTheme, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {
+  WizardFeedCard,
+  WizardProfileCard,
+} from '#/components/StarterPack/Wizard/WizardListCard'
+import {Text} from '#/components/Typography'
+
+function keyExtractor(
+  item: AppBskyActorDefs.ProfileViewBasic | GeneratorView,
+  index: number,
+) {
+  return `${item.did}-${index}`
+}
+
+export function WizardEditListDialog({
+  control,
+  state,
+  dispatch,
+  moderationOpts,
+  profile,
+}: {
+  control: Dialog.DialogControlProps
+  state: WizardState
+  dispatch: (action: WizardAction) => void
+  moderationOpts: ModerationOpts
+  profile: AppBskyActorDefs.ProfileViewBasic
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {currentAccount} = useSession()
+
+  const listRef = useRef<BottomSheetFlatListMethods>(null)
+
+  const getData = () => {
+    if (state.currentStep === 'Feeds') return state.feeds
+
+    return [
+      profile,
+      ...state.profiles.filter(p => p.did !== currentAccount?.did),
+    ]
+  }
+
+  const renderItem = ({item}: ListRenderItemInfo<any>) =>
+    state.currentStep === 'Profiles' ? (
+      <WizardProfileCard
+        profile={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    ) : (
+      <WizardFeedCard
+        generator={item}
+        state={state}
+        dispatch={dispatch}
+        moderationOpts={moderationOpts}
+      />
+    )
+
+  return (
+    <Dialog.Outer
+      control={control}
+      testID="newChatDialog"
+      nativeOptions={{sheet: {snapPoints: ['95%']}}}>
+      <Dialog.Handle />
+      <Dialog.InnerFlatList
+        ref={listRef}
+        data={getData()}
+        renderItem={renderItem}
+        keyExtractor={keyExtractor}
+        ListHeaderComponent={
+          <View
+            style={[
+              a.flex_row,
+              a.justify_between,
+              a.border_b,
+              a.px_sm,
+              a.mb_sm,
+              t.atoms.bg,
+              t.atoms.border_contrast_medium,
+              isWeb
+                ? [
+                    a.align_center,
+                    {
+                      height: 48,
+                    },
+                  ]
+                : [
+                    a.pb_sm,
+                    a.align_end,
+                    {
+                      height: 68,
+                    },
+                  ],
+            ]}>
+            <View style={{width: 60}} />
+            <Text style={[a.font_bold, a.text_xl]}>
+              {state.currentStep === 'Profiles' ? (
+                <Trans>Edit People</Trans>
+              ) : (
+                <Trans>Edit Feeds</Trans>
+              )}
+            </Text>
+            <View style={{width: 60}}>
+              {isWeb && (
+                <Button
+                  label={_(msg`Close`)}
+                  variant="ghost"
+                  color="primary"
+                  size="xsmall"
+                  onPress={() => control.close()}>
+                  <ButtonText>
+                    <Trans>Close</Trans>
+                  </ButtonText>
+                </Button>
+              )}
+            </View>
+          </View>
+        }
+        stickyHeaderIndices={[0]}
+        style={[
+          web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]),
+          native({
+            height: '100%',
+            paddingHorizontal: 0,
+            marginTop: 0,
+            paddingTop: 0,
+            borderTopLeftRadius: 40,
+            borderTopRightRadius: 40,
+          }),
+        ]}
+        webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]}
+        keyboardDismissMode="on-drag"
+        removeClippedSubviews={true}
+      />
+    </Dialog.Outer>
+  )
+}
diff --git a/src/components/StarterPack/Wizard/WizardListCard.tsx b/src/components/StarterPack/Wizard/WizardListCard.tsx
new file mode 100644
index 000000000..f1332011d
--- /dev/null
+++ b/src/components/StarterPack/Wizard/WizardListCard.tsx
@@ -0,0 +1,182 @@
+import React from 'react'
+import {Keyboard, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyFeedDefs,
+  moderateFeedGenerator,
+  moderateProfile,
+  ModerationOpts,
+  ModerationUI,
+} from '@atproto/api'
+import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {DISCOVER_FEED_URI} from 'lib/constants'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {useSession} from 'state/session'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
+import {atoms as a, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Checkbox} from '#/components/forms/Toggle'
+import {Text} from '#/components/Typography'
+
+function WizardListCard({
+  type,
+  displayName,
+  subtitle,
+  onPress,
+  avatar,
+  included,
+  disabled,
+  moderationUi,
+}: {
+  type: 'user' | 'algo'
+  profile?: AppBskyActorDefs.ProfileViewBasic
+  feed?: AppBskyFeedDefs.GeneratorView
+  displayName: string
+  subtitle: string
+  onPress: () => void
+  avatar?: string
+  included?: boolean
+  disabled?: boolean
+  moderationUi: ModerationUI
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <Toggle.Item
+      name={type === 'user' ? _(msg`Person toggle`) : _(msg`Feed toggle`)}
+      label={
+        included
+          ? _(msg`Remove ${displayName} from starter pack`)
+          : _(msg`Add ${displayName} to starter pack`)
+      }
+      value={included}
+      disabled={disabled}
+      onChange={onPress}
+      style={[
+        a.flex_row,
+        a.align_center,
+        a.px_lg,
+        a.py_md,
+        a.gap_md,
+        a.border_b,
+        t.atoms.border_contrast_low,
+      ]}>
+      <UserAvatar
+        size={45}
+        avatar={avatar}
+        moderation={moderationUi}
+        type={type}
+      />
+      <View style={[a.flex_1, a.gap_2xs]}>
+        <Text
+          style={[a.flex_1, a.font_bold, a.text_md, a.leading_tight]}
+          numberOfLines={1}>
+          {displayName}
+        </Text>
+        <Text
+          style={[a.flex_1, a.leading_tight, t.atoms.text_contrast_medium]}
+          numberOfLines={1}>
+          {subtitle}
+        </Text>
+      </View>
+      <Checkbox />
+    </Toggle.Item>
+  )
+}
+
+export function WizardProfileCard({
+  state,
+  dispatch,
+  profile,
+  moderationOpts,
+}: {
+  state: WizardState
+  dispatch: (action: WizardAction) => void
+  profile: AppBskyActorDefs.ProfileViewBasic
+  moderationOpts: ModerationOpts
+}) {
+  const {currentAccount} = useSession()
+
+  const isMe = profile.did === currentAccount?.did
+  const included = isMe || state.profiles.some(p => p.did === profile.did)
+  const disabled = isMe || (!included && state.profiles.length >= 49)
+  const moderationUi = moderateProfile(profile, moderationOpts).ui('avatar')
+  const displayName = profile.displayName
+    ? sanitizeDisplayName(profile.displayName)
+    : `@${sanitizeHandle(profile.handle)}`
+
+  const onPress = () => {
+    if (disabled) return
+
+    Keyboard.dismiss()
+    if (profile.did === currentAccount?.did) return
+
+    if (!included) {
+      dispatch({type: 'AddProfile', profile})
+    } else {
+      dispatch({type: 'RemoveProfile', profileDid: profile.did})
+    }
+  }
+
+  return (
+    <WizardListCard
+      type="user"
+      displayName={displayName}
+      subtitle={`@${sanitizeHandle(profile.handle)}`}
+      onPress={onPress}
+      avatar={profile.avatar}
+      included={included}
+      disabled={disabled}
+      moderationUi={moderationUi}
+    />
+  )
+}
+
+export function WizardFeedCard({
+  generator,
+  state,
+  dispatch,
+  moderationOpts,
+}: {
+  generator: GeneratorView
+  state: WizardState
+  dispatch: (action: WizardAction) => void
+  moderationOpts: ModerationOpts
+}) {
+  const isDiscover = generator.uri === DISCOVER_FEED_URI
+  const included = isDiscover || state.feeds.some(f => f.uri === generator.uri)
+  const disabled = isDiscover || (!included && state.feeds.length >= 3)
+  const moderationUi = moderateFeedGenerator(generator, moderationOpts).ui(
+    'avatar',
+  )
+
+  const onPress = () => {
+    if (disabled) return
+
+    Keyboard.dismiss()
+    if (included) {
+      dispatch({type: 'RemoveFeed', feedUri: generator.uri})
+    } else {
+      dispatch({type: 'AddFeed', feed: generator})
+    }
+  }
+
+  return (
+    <WizardListCard
+      type="algo"
+      displayName={sanitizeDisplayName(generator.displayName)}
+      subtitle={`Feed by @${sanitizeHandle(generator.creator.handle)}`}
+      onPress={onPress}
+      avatar={generator.avatar}
+      included={included}
+      disabled={disabled}
+      moderationUi={moderationUi}
+    />
+  )
+}
diff --git a/src/components/forms/TextField.tsx b/src/components/forms/TextField.tsx
index f7a827b49..d513a6db9 100644
--- a/src/components/forms/TextField.tsx
+++ b/src/components/forms/TextField.tsx
@@ -140,6 +140,7 @@ export function createInput(Component: typeof TextInput) {
     onChangeText,
     isInvalid,
     inputRef,
+    style,
     ...rest
   }: InputProps) {
     const t = useTheme()
@@ -206,6 +207,7 @@ export function createInput(Component: typeof TextInput) {
             android({
               paddingBottom: 16,
             }),
+            style,
           ]}
         />
 
diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts
new file mode 100644
index 000000000..b6e4ab05b
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.native.ts
@@ -0,0 +1,68 @@
+import React from 'react'
+
+import {
+  createStarterPackLinkFromAndroidReferrer,
+  httpStarterPackUriToAtUri,
+} from 'lib/strings/starter-pack'
+import {isAndroid} from 'platform/detection'
+import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
+import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
+
+export function useStarterPackEntry() {
+  const [ready, setReady] = React.useState(false)
+  const setActiveStarterPack = useSetActiveStarterPack()
+  const hasCheckedForStarterPack = useHasCheckedForStarterPack()
+
+  React.useEffect(() => {
+    if (ready) return
+
+    // On Android, we cannot clear the referral link. It gets stored for 90 days and all we can do is query for it. So,
+    // let's just ensure we never check again after the first time.
+    if (hasCheckedForStarterPack) {
+      setReady(true)
+      return
+    }
+
+    // Safety for Android. Very unlike this could happen, but just in case. The response should be nearly immediate
+    const timeout = setTimeout(() => {
+      setReady(true)
+    }, 500)
+
+    ;(async () => {
+      let uri: string | null | undefined
+
+      if (isAndroid) {
+        const res = await Referrer.getGooglePlayReferrerInfoAsync()
+
+        if (res && res.installReferrer) {
+          uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
+        }
+      } else {
+        const res = await DevicePrefs.getStringValueAsync(
+          'starterPackUri',
+          true,
+        )
+
+        if (res) {
+          uri = httpStarterPackUriToAtUri(res)
+          DevicePrefs.setStringValueAsync('starterPackUri', null, true)
+        }
+      }
+
+      if (uri) {
+        setActiveStarterPack({
+          uri,
+        })
+      }
+
+      setReady(true)
+    })()
+
+    return () => {
+      clearTimeout(timeout)
+    }
+  }, [ready, setActiveStarterPack, hasCheckedForStarterPack])
+
+  return ready
+}
diff --git a/src/components/hooks/useStarterPackEntry.ts b/src/components/hooks/useStarterPackEntry.ts
new file mode 100644
index 000000000..dba801e09
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.ts
@@ -0,0 +1,29 @@
+import React from 'react'
+
+import {httpStarterPackUriToAtUri} from 'lib/strings/starter-pack'
+import {useSetActiveStarterPack} from 'state/shell/starter-pack'
+
+export function useStarterPackEntry() {
+  const [ready, setReady] = React.useState(false)
+
+  const setActiveStarterPack = useSetActiveStarterPack()
+
+  React.useEffect(() => {
+    const href = window.location.href
+    const atUri = httpStarterPackUriToAtUri(href)
+
+    if (atUri) {
+      const url = new URL(href)
+      // Determines if an App Clip is loading this landing page
+      const isClip = url.searchParams.get('clip') === 'true'
+      setActiveStarterPack({
+        uri: atUri,
+        isClip,
+      })
+    }
+
+    setReady(true)
+  }, [setActiveStarterPack])
+
+  return ready
+}
diff --git a/src/components/icons/QrCode.tsx b/src/components/icons/QrCode.tsx
new file mode 100644
index 000000000..e841071f7
--- /dev/null
+++ b/src/components/icons/QrCode.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const QrCode_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M3 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm6 0H5v4h4V5ZM3 15a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4Zm6 0H5v4h4v-4ZM13 5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2V5Zm6 0h-4v4h4V5ZM14 13a1 1 0 0 1 1 1v1h1a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1Zm3 1a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-2a1 1 0 0 1-1-1Zm0 4a1 1 0 0 1 1-1h2a1 1 0 1 1 0 2h-1v1a1 1 0 1 1-2 0v-2Z',
+})
diff --git a/src/components/icons/StarterPack.tsx b/src/components/icons/StarterPack.tsx
new file mode 100644
index 000000000..8c678bca4
--- /dev/null
+++ b/src/components/icons/StarterPack.tsx
@@ -0,0 +1,8 @@
+import {createMultiPathSVG} from './TEMPLATE'
+
+export const StarterPack = createMultiPathSVG({
+  paths: [
+    'M11.26 5.227 5.02 6.899c-.734.197-1.17.95-.973 1.685l1.672 6.24c.197.734.951 1.17 1.685.973l6.24-1.672c.734-.197 1.17-.951.973-1.685L12.945 6.2a1.375 1.375 0 0 0-1.685-.973Zm-6.566.459a2.632 2.632 0 0 0-1.86 3.223l1.672 6.24a2.632 2.632 0 0 0 3.223 1.861l6.24-1.672a2.631 2.631 0 0 0 1.861-3.223l-1.672-6.24a2.632 2.632 0 0 0-3.223-1.861l-6.24 1.672Z',
+    'M15.138 18.411a4.606 4.606 0 1 0 0-9.211 4.606 4.606 0 0 0 0 9.211Zm0 1.257a5.862 5.862 0 1 0 0-11.724 5.862 5.862 0 0 0 0 11.724Z',
+  ],
+})
diff --git a/src/components/icons/TEMPLATE.tsx b/src/components/icons/TEMPLATE.tsx
index f49c4280b..47a5c36b2 100644
--- a/src/components/icons/TEMPLATE.tsx
+++ b/src/components/icons/TEMPLATE.tsx
@@ -30,7 +30,7 @@ export const IconTemplate_Stroke2_Corner0_Rounded = React.forwardRef(
 
 export function createSinglePathSVG({path}: {path: string}) {
   return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
-    const {fill, size, style, ...rest} = useCommonSVGProps(props)
+    const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
 
     return (
       <Svg
@@ -41,8 +41,37 @@ export function createSinglePathSVG({path}: {path: string}) {
         width={size}
         height={size}
         style={[style]}>
+        {gradient}
         <Path fill={fill} fillRule="evenodd" clipRule="evenodd" d={path} />
       </Svg>
     )
   })
 }
+
+export function createMultiPathSVG({paths}: {paths: string[]}) {
+  return React.forwardRef<Svg, Props>(function LogoImpl(props, ref) {
+    const {fill, size, style, gradient, ...rest} = useCommonSVGProps(props)
+
+    return (
+      <Svg
+        fill="none"
+        {...rest}
+        ref={ref}
+        viewBox="0 0 24 24"
+        width={size}
+        height={size}
+        style={[style]}>
+        {gradient}
+        {paths.map((path, i) => (
+          <Path
+            key={i}
+            fill={fill}
+            fillRule="evenodd"
+            clipRule="evenodd"
+            d={path}
+          />
+        ))}
+      </Svg>
+    )
+  })
+}
diff --git a/src/components/icons/common.ts b/src/components/icons/common.ts
deleted file mode 100644
index 669c157f5..000000000
--- a/src/components/icons/common.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import {StyleSheet, TextProps} from 'react-native'
-import type {PathProps, SvgProps} from 'react-native-svg'
-
-import {tokens} from '#/alf'
-
-export type Props = {
-  fill?: PathProps['fill']
-  style?: TextProps['style']
-  size?: keyof typeof sizes
-} & Omit<SvgProps, 'style' | 'size'>
-
-export const sizes = {
-  xs: 12,
-  sm: 16,
-  md: 20,
-  lg: 24,
-  xl: 28,
-}
-
-export function useCommonSVGProps(props: Props) {
-  const {fill, size, ...rest} = props
-  const style = StyleSheet.flatten(rest.style)
-  const _fill = fill || style?.color || tokens.color.blue_500
-  const _size = Number(size ? sizes[size] : rest.width || sizes.md)
-
-  return {
-    fill: _fill,
-    size: _size,
-    style,
-    ...rest,
-  }
-}
diff --git a/src/components/icons/common.tsx b/src/components/icons/common.tsx
new file mode 100644
index 000000000..662718338
--- /dev/null
+++ b/src/components/icons/common.tsx
@@ -0,0 +1,59 @@
+import React from 'react'
+import {StyleSheet, TextProps} from 'react-native'
+import type {PathProps, SvgProps} from 'react-native-svg'
+import {Defs, LinearGradient, Stop} from 'react-native-svg'
+import {nanoid} from 'nanoid/non-secure'
+
+import {tokens} from '#/alf'
+
+export type Props = {
+  fill?: PathProps['fill']
+  style?: TextProps['style']
+  size?: keyof typeof sizes
+  gradient?: keyof typeof tokens.gradients
+} & Omit<SvgProps, 'style' | 'size'>
+
+export const sizes = {
+  xs: 12,
+  sm: 16,
+  md: 20,
+  lg: 24,
+  xl: 28,
+}
+
+export function useCommonSVGProps(props: Props) {
+  const {fill, size, gradient, ...rest} = props
+  const style = StyleSheet.flatten(rest.style)
+  const _size = Number(size ? sizes[size] : rest.width || sizes.md)
+  let _fill = fill || style?.color || tokens.color.blue_500
+  let gradientDef = null
+
+  if (gradient && tokens.gradients[gradient]) {
+    const id = gradient + '_' + nanoid()
+    const config = tokens.gradients[gradient]
+    _fill = `url(#${id})`
+    gradientDef = (
+      <Defs>
+        <LinearGradient
+          id={id}
+          x1="0"
+          y1="0"
+          x2="100%"
+          y2="0"
+          gradientTransform="rotate(45)">
+          {config.values.map(([stop, fill]) => (
+            <Stop key={stop} offset={stop} stopColor={fill} />
+          ))}
+        </LinearGradient>
+      </Defs>
+    )
+  }
+
+  return {
+    fill: _fill,
+    size: _size,
+    style,
+    gradient: gradientDef,
+    ...rest,
+  }
+}