about summary refs log tree commit diff
path: root/src/components/StarterPack
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/StarterPack')
-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
10 files changed, 1489 insertions, 0 deletions
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}
+    />
+  )
+}