about summary refs log tree commit diff
path: root/src/screens
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens')
-rw-r--r--src/screens/Login/ForgotPasswordForm.tsx6
-rw-r--r--src/screens/Login/SetNewPasswordForm.tsx4
-rw-r--r--src/screens/Moderation/VerificationSettings.tsx7
-rw-r--r--src/screens/Onboarding/Layout.tsx126
-rw-r--r--src/screens/Onboarding/StepFinished.tsx293
-rw-r--r--src/screens/Onboarding/StepInterests/index.tsx12
-rw-r--r--src/screens/Onboarding/StepProfile/index.tsx10
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/index.tsx356
-rw-r--r--src/screens/Onboarding/index.tsx51
-rw-r--r--src/screens/Onboarding/state.ts58
-rw-r--r--src/screens/Onboarding/util.ts14
-rw-r--r--src/screens/PostThread/components/ThreadComposePrompt.tsx95
-rw-r--r--src/screens/PostThread/components/ThreadItemAnchor.tsx12
-rw-r--r--src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx139
-rw-r--r--src/screens/PostThread/index.tsx14
-rw-r--r--src/screens/Profile/Header/EditProfileDialog.tsx33
-rw-r--r--src/screens/Profile/Header/SuggestedFollows.tsx3
-rw-r--r--src/screens/Profile/ProfileFeed/index.tsx2
-rw-r--r--src/screens/Search/Explore.tsx8
-rw-r--r--src/screens/Search/modules/ExploreSuggestedAccounts.tsx74
-rw-r--r--src/screens/Search/util/useSuggestedUsers.ts9
-rw-r--r--src/screens/Settings/AccountSettings.tsx5
-rw-r--r--src/screens/Settings/ThreadPreferences.tsx158
-rw-r--r--src/screens/Settings/components/ChangeHandleDialog.tsx16
-rw-r--r--src/screens/Settings/components/ChangePasswordDialog.tsx300
-rw-r--r--src/screens/Signup/StepHandle/index.tsx5
-rw-r--r--src/screens/StarterPack/Wizard/State.tsx17
-rw-r--r--src/screens/StarterPack/Wizard/index.tsx108
-rw-r--r--src/screens/VideoFeed/index.tsx9
29 files changed, 1532 insertions, 412 deletions
diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx
index e8582f46f..d3b5a4f10 100644
--- a/src/screens/Login/ForgotPasswordForm.tsx
+++ b/src/screens/Login/ForgotPasswordForm.tsx
@@ -1,7 +1,6 @@
 import React, {useState} from 'react'
 import {ActivityIndicator, Keyboard, View} from 'react-native'
-import {ComAtprotoServerDescribeServer} from '@atproto/api'
-import {BskyAgent} from '@atproto/api'
+import {type ComAtprotoServerDescribeServer} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import * as EmailValidator from 'email-validator'
@@ -9,6 +8,7 @@ import * as EmailValidator from 'email-validator'
 import {isNetworkError} from '#/lib/strings/errors'
 import {cleanError} from '#/lib/strings/errors'
 import {logger} from '#/logger'
+import {Agent} from '#/state/session/agent'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {FormError} from '#/components/forms/FormError'
@@ -55,7 +55,7 @@ export const ForgotPasswordForm = ({
     setIsProcessing(true)
 
     try {
-      const agent = new BskyAgent({service: serviceUrl})
+      const agent = new Agent(null, {service: serviceUrl})
       await agent.com.atproto.server.requestPasswordReset({email})
       onEmailSent()
     } catch (e: any) {
diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx
index d2fa0f9c1..be72b558b 100644
--- a/src/screens/Login/SetNewPasswordForm.tsx
+++ b/src/screens/Login/SetNewPasswordForm.tsx
@@ -1,6 +1,5 @@
 import {useState} from 'react'
 import {ActivityIndicator, View} from 'react-native'
-import {BskyAgent} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -9,6 +8,7 @@ import {isNetworkError} from '#/lib/strings/errors'
 import {cleanError} from '#/lib/strings/errors'
 import {checkAndFormatResetCode} from '#/lib/strings/password'
 import {logger} from '#/logger'
+import {Agent} from '#/state/session/agent'
 import {atoms as a, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import {FormError} from '#/components/forms/FormError'
@@ -63,7 +63,7 @@ export const SetNewPasswordForm = ({
     setIsProcessing(true)
 
     try {
-      const agent = new BskyAgent({service: serviceUrl})
+      const agent = new Agent(null, {service: serviceUrl})
       await agent.com.atproto.server.resetPassword({
         token: formattedCode,
         password,
diff --git a/src/screens/Moderation/VerificationSettings.tsx b/src/screens/Moderation/VerificationSettings.tsx
index cd023ae56..aec70cf85 100644
--- a/src/screens/Moderation/VerificationSettings.tsx
+++ b/src/screens/Moderation/VerificationSettings.tsx
@@ -44,7 +44,12 @@ export function Screen() {
                 <InlineLinkText
                   overridePresentation
                   to={urls.website.blog.initialVerificationAnnouncement}
-                  label={_(msg`Learn more`)}
+                  label={_(
+                    msg({
+                      message: `Learn more`,
+                      context: `english-only-resource`,
+                    }),
+                  )}
                   onPress={() => {
                     logger.metric(
                       'verification:learn-more',
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
index 16c37358f..6394d9c96 100644
--- a/src/screens/Onboarding/Layout.tsx
+++ b/src/screens/Onboarding/Layout.tsx
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, {useState} from 'react'
 import {ScrollView, View} from 'react-native'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {msg} from '@lingui/macro'
@@ -11,20 +11,23 @@ import {
   atoms as a,
   flatten,
   native,
-  TextStyleProp,
+  type TextStyleProp,
+  tokens,
   useBreakpoints,
   useTheme,
   web,
 } from '#/alf'
 import {leading} from '#/alf/typography'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
+import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
+import {HEADER_SLOT_SIZE} from '#/components/Layout'
 import {createPortalGroup} from '#/components/Portal'
 import {P, Text} from '#/components/Typography'
 
-const COL_WIDTH = 420
+const ONBOARDING_COL_WIDTH = 420
 
 export const OnboardingControls = createPortalGroup()
+export const OnboardingHeaderSlot = createPortalGroup()
 
 export function Layout({children}: React.PropsWithChildren<{}>) {
   const {_} = useLingui()
@@ -46,6 +49,8 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
   const paddingTop = gtMobile ? a.py_5xl : a.py_lg
   const dialogLabel = _(msg`Set up your account`)
 
+  const [footerHeight, setFooterHeight] = useState(0)
+
   return (
     <View
       aria-modal
@@ -62,45 +67,67 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
         t.atoms.bg,
       ]}>
       {__DEV__ && (
-        <View style={[a.absolute, a.p_xl, a.z_10, {right: 0, top: insets.top}]}>
-          <Button
-            variant="ghost"
-            color="negative"
-            size="small"
-            onPress={() => onboardDispatch({type: 'skip'})}
-            // DEV ONLY
-            label="Clear onboarding state">
-            <ButtonText>Clear</ButtonText>
-          </Button>
-        </View>
+        <Button
+          variant="ghost"
+          color="negative"
+          size="tiny"
+          onPress={() => onboardDispatch({type: 'skip'})}
+          // DEV ONLY
+          label="Clear onboarding state"
+          style={[
+            a.absolute,
+            a.z_10,
+            {
+              left: '50%',
+              top: insets.top + 2,
+              transform: [{translateX: '-50%'}],
+            },
+          ]}>
+          <ButtonText>[DEV] Clear</ButtonText>
+        </Button>
       )}
 
-      {!gtMobile && state.hasPrev && (
+      {!gtMobile && (
         <View
+          pointerEvents="box-none"
           style={[
             web(a.fixed),
             native(a.absolute),
+            a.left_0,
+            a.right_0,
             a.flex_row,
             a.w_full,
             a.justify_center,
             a.z_20,
             a.px_xl,
-            {
-              top: paddingTop.paddingTop + insets.top - 1,
-            },
+            {top: paddingTop.paddingTop + insets.top - 1},
           ]}>
-          <View style={[a.w_full, a.align_start, {maxWidth: COL_WIDTH}]}>
-            <Button
-              key={state.activeStep} // remove focus state on nav
-              variant="ghost"
-              color="secondary"
-              size="small"
-              shape="round"
-              label={_(msg`Go back to previous step`)}
-              style={[a.absolute]}
-              onPress={() => dispatch({type: 'prev'})}>
-              <ButtonIcon icon={ChevronLeft} />
-            </Button>
+          <View
+            pointerEvents="box-none"
+            style={[
+              a.w_full,
+              a.align_start,
+              a.flex_row,
+              a.justify_between,
+              {maxWidth: ONBOARDING_COL_WIDTH},
+            ]}>
+            {state.hasPrev ? (
+              <Button
+                key={state.activeStep} // remove focus state on nav
+                color="secondary"
+                variant="ghost"
+                shape="square"
+                size="small"
+                label={_(msg`Go back to previous step`)}
+                onPress={() => dispatch({type: 'prev'})}
+                style={[a.bg_transparent]}>
+                <ButtonIcon icon={ArrowLeft} size="lg" />
+              </Button>
+            ) : (
+              <View />
+            )}
+
+            <OnboardingHeaderSlot.Outlet />
           </View>
         </View>
       )}
@@ -109,22 +136,24 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
         ref={scrollview}
         style={[a.h_full, a.w_full, {paddingTop: insets.top}]}
         contentContainerStyle={{borderWidth: 0}}
-        // @ts-ignore web only --prf
+        scrollIndicatorInsets={{bottom: footerHeight - insets.bottom}}
+        // @ts-expect-error web only --prf
         dataSet={{'stable-gutters': 1}}>
         <View
           style={[a.flex_row, a.justify_center, gtMobile ? a.px_5xl : a.px_xl]}>
-          <View style={[a.flex_1, {maxWidth: COL_WIDTH}]}>
+          <View style={[a.flex_1, {maxWidth: ONBOARDING_COL_WIDTH}]}>
             <View style={[a.w_full, a.align_center, paddingTop]}>
               <View
                 style={[
                   a.flex_row,
                   a.gap_sm,
                   a.w_full,
-                  {paddingTop: 17, maxWidth: '60%'},
+                  a.align_center,
+                  {height: HEADER_SLOT_SIZE, maxWidth: '60%'},
                 ]}>
                 {Array(state.totalSteps)
                   .fill(0)
-                  .map((_, i) => (
+                  .map((__, i) => (
                     <View
                       key={i}
                       style={[
@@ -144,19 +173,16 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
               </View>
             </View>
 
-            <View
-              style={[a.w_full, a.mb_5xl, {paddingTop: gtMobile ? 20 : 40}]}>
-              {children}
-            </View>
+            <View style={[a.w_full, a.mb_5xl, a.pt_md]}>{children}</View>
 
-            <View style={{height: 400}} />
+            <View style={{height: 100 + footerHeight}} />
           </View>
         </View>
       </ScrollView>
 
       <View
+        onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}
         style={[
-          // @ts-ignore web only -prf
           isWeb ? a.fixed : a.absolute,
           {bottom: 0, left: 0, right: 0},
           t.atoms.bg,
@@ -167,30 +193,30 @@ export function Layout({children}: React.PropsWithChildren<{}>) {
           isWeb
             ? a.py_2xl
             : {
-                paddingTop: a.pt_lg.paddingTop,
-                paddingBottom: insets.bottom + 10,
+                paddingTop: tokens.space.md,
+                paddingBottom: insets.bottom + tokens.space.md,
               },
         ]}>
         <View
           style={[
             a.w_full,
-            {maxWidth: COL_WIDTH},
-            gtMobile && [a.flex_row, a.justify_between],
+            {maxWidth: ONBOARDING_COL_WIDTH},
+            gtMobile && [a.flex_row, a.justify_between, a.align_center],
           ]}>
           {gtMobile &&
             (state.hasPrev ? (
               <Button
                 key={state.activeStep} // remove focus state on nav
-                variant="solid"
                 color="secondary"
-                size="large"
-                shape="round"
+                variant="ghost"
+                shape="square"
+                size="small"
                 label={_(msg`Go back to previous step`)}
                 onPress={() => dispatch({type: 'prev'})}>
-                <ButtonIcon icon={ChevronLeft} />
+                <ButtonIcon icon={ArrowLeft} size="lg" />
               </Button>
             ) : (
-              <View style={{height: 54}} />
+              <View style={{height: 33}} />
             ))}
           <OnboardingControls.Outlet />
         </View>
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 54d282a5e..c4b723ce1 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,5 +1,12 @@
-import React from 'react'
+import {useCallback, useContext, useState} from 'react'
 import {View} from 'react-native'
+import Animated, {
+  Easing,
+  LayoutAnimationConfig,
+  SlideInRight,
+  SlideOutLeft,
+} from 'react-native-reanimated'
+import {Image} from 'expo-image'
 import {
   type AppBskyActorDefs,
   type AppBskyActorProfile,
@@ -22,6 +29,7 @@ import {
 import {useRequestNotificationsPermission} from '#/lib/notifications/notifications'
 import {logEvent, useGate} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
 import {useSetHasCheckedForStarterPack} from '#/state/preferences/used-starter-packs'
 import {getAllListMembers} from '#/state/queries/list-members'
 import {preferencesQueryKey} from '#/state/queries/preferences'
@@ -36,13 +44,22 @@ import {
 import {
   DescriptionText,
   OnboardingControls,
+  OnboardingHeaderSlot,
   TitleText,
 } from '#/screens/Onboarding/Layout'
-import {Context} from '#/screens/Onboarding/state'
+import {Context, type OnboardingState} from '#/screens/Onboarding/state'
 import {bulkWriteFollows} from '#/screens/Onboarding/util'
-import {atoms as a, useTheme} from '#/alf'
+import {
+  atoms as a,
+  native,
+  platform,
+  tokens,
+  useBreakpoints,
+  useTheme,
+} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
 import {IconCircle} from '#/components/IconCircle'
+import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow'
 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
 import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
 import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
@@ -53,10 +70,9 @@ import * as bsky from '#/types/bsky'
 
 export function StepFinished() {
   const {_} = useLingui()
-  const t = useTheme()
-  const {state, dispatch} = React.useContext(Context)
+  const {state, dispatch} = useContext(Context)
   const onboardDispatch = useOnboardingDispatch()
-  const [saving, setSaving] = React.useState(false)
+  const [saving, setSaving] = useState(false)
   const queryClient = useQueryClient()
   const agent = useAgent()
   const requestNotificationsPermission = useRequestNotificationsPermission()
@@ -66,7 +82,7 @@ export function StepFinished() {
   const {startProgressGuide} = useProgressGuideControls()
   const gate = useGate()
 
-  const finishOnboarding = React.useCallback(async () => {
+  const finishOnboarding = useCallback(async () => {
     setSaving(true)
 
     let starterPack: AppBskyGraphDefs.StarterPackView | undefined
@@ -245,6 +261,267 @@ export function StepFinished() {
     gate,
   ])
 
+  return state.experiments?.onboarding_value_prop ? (
+    <ValueProposition
+      finishOnboarding={finishOnboarding}
+      saving={saving}
+      state={state}
+    />
+  ) : (
+    <LegacyFinalStep
+      finishOnboarding={finishOnboarding}
+      saving={saving}
+      state={state}
+    />
+  )
+}
+
+const PROP_1 = {
+  light: platform({
+    native: require('../../../assets/images/onboarding/value_prop_1_light.webp'),
+    web: require('../../../assets/images/onboarding/value_prop_1_light_borderless.webp'),
+  }),
+  dim: platform({
+    native: require('../../../assets/images/onboarding/value_prop_1_dim.webp'),
+    web: require('../../../assets/images/onboarding/value_prop_1_dim_borderless.webp'),
+  }),
+  dark: platform({
+    native: require('../../../assets/images/onboarding/value_prop_1_dark.webp'),
+    web: require('../../../assets/images/onboarding/value_prop_1_dark_borderless.webp'),
+  }),
+} as const
+
+const PROP_2 = {
+  light: require('../../../assets/images/onboarding/value_prop_2_light.webp'),
+  dim: require('../../../assets/images/onboarding/value_prop_2_dim.webp'),
+  dark: require('../../../assets/images/onboarding/value_prop_2_dark.webp'),
+} as const
+
+const PROP_3 = {
+  light: require('../../../assets/images/onboarding/value_prop_3_light.webp'),
+  dim: require('../../../assets/images/onboarding/value_prop_3_dim.webp'),
+  dark: require('../../../assets/images/onboarding/value_prop_3_dark.webp'),
+} as const
+
+function ValueProposition({
+  finishOnboarding,
+  saving,
+  state,
+}: {
+  finishOnboarding: () => void
+  saving: boolean
+  state: OnboardingState
+}) {
+  const [subStep, setSubStep] = useState<0 | 1 | 2>(0)
+  const t = useTheme()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  const image = [PROP_1[t.name], PROP_2[t.name], PROP_3[t.name]][subStep]
+
+  const onPress = () => {
+    if (subStep === 2) {
+      finishOnboarding() // has its own metrics
+    } else if (subStep === 1) {
+      setSubStep(2)
+      logger.metric('onboarding:valueProp:stepTwo:nextPressed', {})
+    } else if (subStep === 0) {
+      setSubStep(1)
+      logger.metric('onboarding:valueProp:stepOne:nextPressed', {})
+    }
+  }
+
+  const {title, description, alt} = [
+    {
+      title: _(msg`Free your feed`),
+      description: _(
+        msg`No more doomscrolling junk-filled algorithms. Find feeds that work for you, not against you.`,
+      ),
+      alt: _(
+        msg`A collection of popular feeds you can find on Bluesky, including News, Booksky, Game Dev, Blacksky, and Fountain Pens`,
+      ),
+    },
+    {
+      title: _(msg`Find your people`),
+      description: _(
+        msg`Ditch the trolls and clickbait. Find real people and conversations that matter to you.`,
+      ),
+      alt: _(
+        msg`Your profile picture surrounded by concentric circles of other users' profile pictures`,
+      ),
+    },
+    {
+      title: _(msg`Forget the noise`),
+      description: _(
+        msg`No ads, no invasive tracking, no engagement traps. Bluesky respects your time and attention.`,
+      ),
+      alt: _(
+        msg`An illustration of several Bluesky posts alongside repost, like, and comment icons`,
+      ),
+    },
+  ][subStep]
+
+  return (
+    <>
+      {!gtMobile && (
+        <OnboardingHeaderSlot.Portal>
+          <Button
+            disabled={saving}
+            variant="ghost"
+            color="secondary"
+            size="small"
+            label={_(msg`Skip introduction and start using your account`)}
+            onPress={() => {
+              logger.metric('onboarding:valueProp:skipPressed', {})
+              finishOnboarding()
+            }}
+            style={[a.bg_transparent]}>
+            <ButtonText>
+              <Trans>Skip</Trans>
+            </ButtonText>
+          </Button>
+        </OnboardingHeaderSlot.Portal>
+      )}
+
+      <LayoutAnimationConfig skipEntering skipExiting>
+        <Animated.View
+          key={subStep}
+          entering={native(
+            SlideInRight.easing(Easing.out(Easing.exp)).duration(500),
+          )}
+          exiting={native(
+            SlideOutLeft.easing(Easing.out(Easing.exp)).duration(500),
+          )}>
+          <View
+            style={[
+              a.relative,
+              a.align_center,
+              a.justify_center,
+              isNative && {marginHorizontal: tokens.space.xl * -1},
+              a.pointer_events_none,
+            ]}>
+            <Image
+              source={image}
+              style={[a.w_full, {aspectRatio: 1}]}
+              alt={alt}
+              accessibilityIgnoresInvertColors={false} // I guess we do need it to blend into the background
+            />
+            {subStep === 1 && (
+              <Image
+                source={state.profileStepResults.imageUri}
+                style={[
+                  a.z_10,
+                  a.absolute,
+                  a.rounded_full,
+                  {
+                    width: `${(80 / 393) * 100}%`,
+                    height: `${(80 / 393) * 100}%`,
+                  },
+                ]}
+                accessibilityIgnoresInvertColors
+                alt={_(msg`Your profile picture`)}
+              />
+            )}
+          </View>
+
+          <View style={[a.mt_4xl, a.gap_2xl, a.align_center]}>
+            <View style={[a.flex_row, a.gap_sm]}>
+              <Dot active={subStep === 0} />
+              <Dot active={subStep === 1} />
+              <Dot active={subStep === 2} />
+            </View>
+
+            <View style={[a.gap_sm]}>
+              <Text style={[a.font_heavy, a.text_3xl, a.text_center]}>
+                {title}
+              </Text>
+              <Text
+                style={[
+                  t.atoms.text_contrast_medium,
+                  a.text_md,
+                  a.leading_snug,
+                  a.text_center,
+                ]}>
+                {description}
+              </Text>
+            </View>
+          </View>
+        </Animated.View>
+      </LayoutAnimationConfig>
+
+      <OnboardingControls.Portal>
+        <View style={gtMobile && [a.gap_md, a.flex_row]}>
+          {gtMobile && (
+            <Button
+              disabled={saving}
+              color="secondary"
+              size="large"
+              label={_(msg`Skip introduction and start using your account`)}
+              onPress={() => finishOnboarding()}>
+              <ButtonText>
+                <Trans>Skip</Trans>
+              </ButtonText>
+            </Button>
+          )}
+          <Button
+            disabled={saving}
+            key={state.activeStep} // remove focus state on nav
+            color="primary"
+            size="large"
+            label={
+              subStep === 2
+                ? _(msg`Complete onboarding and start using your account`)
+                : _(msg`Next`)
+            }
+            onPress={onPress}>
+            <ButtonText>
+              {saving ? (
+                <Trans>Finalizing</Trans>
+              ) : subStep === 2 ? (
+                <Trans>Let's go!</Trans>
+              ) : (
+                <Trans>Next</Trans>
+              )}
+            </ButtonText>
+            {subStep === 2 && (
+              <ButtonIcon icon={saving ? Loader : ArrowRight} />
+            )}
+          </Button>
+        </View>
+      </OnboardingControls.Portal>
+    </>
+  )
+}
+
+function Dot({active}: {active: boolean}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  return (
+    <View
+      style={[
+        a.rounded_full,
+        {width: 8, height: 8},
+        active
+          ? {backgroundColor: t.palette.primary_500}
+          : t.atoms.bg_contrast_50,
+      ]}
+    />
+  )
+}
+
+function LegacyFinalStep({
+  finishOnboarding,
+  saving,
+  state,
+}: {
+  finishOnboarding: () => void
+  saving: boolean
+  state: OnboardingState
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+
   return (
     <View style={[a.align_start]}>
       <IconCircle icon={Check} style={[a.mb_2xl]} />
@@ -303,9 +580,9 @@ export function StepFinished() {
 
       <OnboardingControls.Portal>
         <Button
+          testID="onboardingFinish"
           disabled={saving}
           key={state.activeStep} // remove focus state on nav
-          variant="solid"
           color="primary"
           size="large"
           label={_(msg`Complete onboarding and start using your account`)}
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
index 2a121cac6..d214937d4 100644
--- a/src/screens/Onboarding/StepInterests/index.tsx
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -160,7 +160,16 @@ export function StepInterests() {
 
       <View style={[a.w_full, a.pt_2xl]}>
         {isLoading ? (
-          <Loader size="xl" />
+          <View
+            style={[
+              a.flex_1,
+              a.mt_md,
+              a.align_center,
+              a.justify_center,
+              {minHeight: 400},
+            ]}>
+            <Loader size="xl" />
+          </View>
         ) : isError || !data ? (
           <View
             style={[
@@ -235,6 +244,7 @@ export function StepInterests() {
         ) : (
           <Button
             disabled={saving || !data}
+            testID="onboardingContinue"
             variant="solid"
             color="primary"
             size="large"
diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx
index 30da5cbb5..6066e4297 100644
--- a/src/screens/Onboarding/StepProfile/index.tsx
+++ b/src/screens/Onboarding/StepProfile/index.tsx
@@ -4,7 +4,7 @@ import {Image as ExpoImage} from 'expo-image'
 import {
   type ImagePickerOptions,
   launchImageLibraryAsync,
-  MediaTypeOptions,
+  UIImagePickerPreferredAssetRepresentationMode,
 } from 'expo-image-picker'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -97,10 +97,12 @@ export function StepProfile() {
       const response = await sheetWrapper(
         launchImageLibraryAsync({
           exif: false,
-          mediaTypes: MediaTypeOptions.Images,
+          mediaTypes: ['images'],
           quality: 1,
           ...opts,
           legacy: true,
+          preferredAssetRepresentationMode:
+            UIImagePickerPreferredAssetRepresentationMode.Automatic,
         }),
       )
 
@@ -266,8 +268,9 @@ export function StepProfile() {
         </View>
 
         <OnboardingControls.Portal>
-          <View style={[a.gap_md, gtMobile && {flexDirection: 'row-reverse'}]}>
+          <View style={[a.gap_md, gtMobile && a.flex_row_reverse]}>
             <Button
+              testID="onboardingContinue"
               variant="solid"
               color="primary"
               size="large"
@@ -279,6 +282,7 @@ export function StepProfile() {
               <ButtonIcon icon={ChevronRight} position="right" />
             </Button>
             <Button
+              testID="onboardingAvatarCreator"
               variant="ghost"
               color="primary"
               size="large"
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
new file mode 100644
index 000000000..5a9d3464c
--- /dev/null
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -0,0 +1,356 @@
+import {useCallback, useContext, useMemo, useState} from 'react'
+import {View} from 'react-native'
+import {type ModerationOpts} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useMutation, useQueryClient} from '@tanstack/react-query'
+import * as bcp47Match from 'bcp-47-match'
+
+import {wait} from '#/lib/async/wait'
+import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
+import {logger} from '#/logger'
+import {isWeb} from '#/platform/detection'
+import {updateProfileShadow} from '#/state/cache/profile-shadow'
+import {useLanguagePrefs} from '#/state/preferences'
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {useAgent, useSession} from '#/state/session'
+import {useOnboardingDispatch} from '#/state/shell'
+import {OnboardingControls} from '#/screens/Onboarding/Layout'
+import {
+  Context,
+  popularInterests,
+  useInterestsDisplayNames,
+} from '#/screens/Onboarding/state'
+import {useSuggestedUsers} from '#/screens/Search/util/useSuggestedUsers'
+import {atoms as a, tokens, useBreakpoints, useTheme} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwiseIcon} from '#/components/icons/ArrowRotateCounterClockwise'
+import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
+import {boostInterests, InterestTabs} from '#/components/InterestTabs'
+import {Loader} from '#/components/Loader'
+import * as ProfileCard from '#/components/ProfileCard'
+import * as toast from '#/components/Toast'
+import {Text} from '#/components/Typography'
+import type * as bsky from '#/types/bsky'
+import {bulkWriteFollows} from '../util'
+
+export function StepSuggestedAccounts() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {gtMobile} = useBreakpoints()
+  const moderationOpts = useModerationOpts()
+  const agent = useAgent()
+  const {currentAccount} = useSession()
+  const queryClient = useQueryClient()
+
+  const {state, dispatch} = useContext(Context)
+  const onboardDispatch = useOnboardingDispatch()
+
+  const [selectedInterest, setSelectedInterest] = useState<string | null>(null)
+  // keeping track of who was followed via the follow all button
+  // so we can enable/disable the button without having to dig through the shadow cache
+  const [followedUsers, setFollowedUsers] = useState<string[]>([])
+
+  /*
+   * Special language handling copied wholesale from the Explore screen
+   */
+  const {contentLanguages} = useLanguagePrefs()
+  const useFullExperience = useMemo(() => {
+    if (contentLanguages.length === 0) return true
+    return bcp47Match.basicFilter('en', contentLanguages).length > 0
+  }, [contentLanguages])
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const interests = Object.keys(interestsDisplayNames)
+    .sort(boostInterests(popularInterests))
+    .sort(boostInterests(state.interestsStepResults.selectedInterests))
+  const {
+    data: suggestedUsers,
+    isLoading,
+    error,
+    isRefetching,
+    refetch,
+  } = useSuggestedUsers({
+    category: selectedInterest || (useFullExperience ? null : interests[0]),
+    search: !useFullExperience,
+    overrideInterests: state.interestsStepResults.selectedInterests,
+  })
+
+  const isError = !!error
+
+  const skipOnboarding = useCallback(() => {
+    onboardDispatch({type: 'finish'})
+    dispatch({type: 'finish'})
+  }, [onboardDispatch, dispatch])
+
+  const followableDids =
+    suggestedUsers?.actors
+      .filter(
+        user =>
+          user.did !== currentAccount?.did &&
+          !isBlockedOrBlocking(user) &&
+          !isMuted(user) &&
+          !user.viewer?.following &&
+          !followedUsers.includes(user.did),
+      )
+      .map(user => user.did) ?? []
+
+  const {mutate: followAll, isPending: isFollowingAll} = useMutation({
+    onMutate: () => {
+      logger.metric('onboarding:suggestedAccounts:followAllPressed', {
+        tab: selectedInterest ?? 'all',
+        numAccounts: followableDids.length,
+      })
+    },
+    mutationFn: async () => {
+      for (const did of followableDids) {
+        updateProfileShadow(queryClient, did, {
+          followingUri: 'pending',
+        })
+      }
+      const uris = await wait(1e3, bulkWriteFollows(agent, followableDids))
+      for (const did of followableDids) {
+        const uri = uris.get(did)
+        updateProfileShadow(queryClient, did, {
+          followingUri: uri,
+        })
+      }
+      return followableDids
+    },
+    onSuccess: newlyFollowed => {
+      toast.show(_(msg`Followed all accounts!`), {type: 'success'})
+      setFollowedUsers(followed => [...followed, ...newlyFollowed])
+    },
+    onError: () => {
+      toast.show(
+        _(msg`Failed to follow all suggested accounts, please try again`),
+        {type: 'error'},
+      )
+    },
+  })
+
+  const canFollowAll = followableDids.length > 0 && !isFollowingAll
+
+  return (
+    <View style={[a.align_start]} testID="onboardingInterests">
+      <Text style={[a.font_heavy, a.text_3xl]}>
+        <Trans comment="Accounts suggested to the user for them to follow">
+          Suggested for you
+        </Trans>
+      </Text>
+
+      <View
+        style={[
+          a.overflow_hidden,
+          a.mt_lg,
+          isWeb ? a.max_w_full : {marginHorizontal: tokens.space.xl * -1},
+          a.flex_1,
+          a.justify_start,
+        ]}>
+        <TabBar
+          selectedInterest={selectedInterest}
+          onSelectInterest={setSelectedInterest}
+          defaultTabLabel={_(
+            msg({
+              message: 'All',
+              comment: 'the default tab in the interests tab bar',
+            }),
+          )}
+          selectedInterests={state.interestsStepResults.selectedInterests}
+        />
+
+        {isLoading || !moderationOpts ? (
+          <View
+            style={[
+              a.flex_1,
+              a.mt_md,
+              a.align_center,
+              a.justify_center,
+              {minHeight: 400},
+            ]}>
+            <Loader size="xl" />
+          </View>
+        ) : isError ? (
+          <View style={[a.flex_1, a.px_xl, a.pt_5xl]}>
+            <Admonition type="error">
+              <Trans>
+                An error occurred while fetching suggested accounts.
+              </Trans>
+            </Admonition>
+          </View>
+        ) : (
+          <View
+            style={[
+              a.flex_1,
+              a.mt_md,
+              a.border_y,
+              t.atoms.border_contrast_low,
+              isWeb && [a.border_x, a.rounded_sm, a.overflow_hidden],
+            ]}>
+            {suggestedUsers?.actors.map((user, index) => (
+              <SuggestedProfileCard
+                key={user.did}
+                profile={user}
+                moderationOpts={moderationOpts}
+                position={index}
+              />
+            ))}
+          </View>
+        )}
+      </View>
+
+      <OnboardingControls.Portal>
+        {isError ? (
+          <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
+            <Button
+              disabled={isRefetching}
+              color="secondary"
+              size="large"
+              label={_(msg`Retry`)}
+              onPress={() => refetch()}>
+              <ButtonText>
+                <Trans>Retry</Trans>
+              </ButtonText>
+              <ButtonIcon icon={ArrowRotateCounterClockwiseIcon} />
+            </Button>
+            <Button
+              color="secondary"
+              size="large"
+              label={_(msg`Skip this flow`)}
+              onPress={skipOnboarding}>
+              <ButtonText>
+                <Trans>Skip</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        ) : (
+          <View style={[a.gap_md, gtMobile ? a.flex_row : a.flex_col]}>
+            <Button
+              disabled={!canFollowAll}
+              color="secondary"
+              size="large"
+              label={_(msg`Follow all accounts`)}
+              onPress={() => followAll()}>
+              <ButtonText>
+                <Trans>Follow all</Trans>
+              </ButtonText>
+              <ButtonIcon icon={isFollowingAll ? Loader : PlusIcon} />
+            </Button>
+            <Button
+              disabled={isFollowingAll}
+              color="primary"
+              size="large"
+              label={_(msg`Continue to next step`)}
+              onPress={() => dispatch({type: 'next'})}>
+              <ButtonText>
+                <Trans>Continue</Trans>
+              </ButtonText>
+            </Button>
+          </View>
+        )}
+      </OnboardingControls.Portal>
+    </View>
+  )
+}
+
+function TabBar({
+  selectedInterest,
+  onSelectInterest,
+  selectedInterests,
+  hideDefaultTab,
+  defaultTabLabel,
+}: {
+  selectedInterest: string | null
+  onSelectInterest: (interest: string | null) => void
+  selectedInterests: string[]
+  hideDefaultTab?: boolean
+  defaultTabLabel?: string
+}) {
+  const {_} = useLingui()
+  const interestsDisplayNames = useInterestsDisplayNames()
+  const interests = Object.keys(interestsDisplayNames)
+    .sort(boostInterests(popularInterests))
+    .sort(boostInterests(selectedInterests))
+
+  return (
+    <InterestTabs
+      interests={hideDefaultTab ? interests : ['all', ...interests]}
+      selectedInterest={
+        selectedInterest || (hideDefaultTab ? interests[0] : 'all')
+      }
+      onSelectTab={tab => {
+        logger.metric(
+          'onboarding:suggestedAccounts:tabPressed',
+          {tab: tab},
+          {statsig: true},
+        )
+        onSelectInterest(tab === 'all' ? null : tab)
+      }}
+      interestsDisplayNames={
+        hideDefaultTab
+          ? interestsDisplayNames
+          : {
+              all: defaultTabLabel || _(msg`For You`),
+              ...interestsDisplayNames,
+            }
+      }
+      gutterWidth={isWeb ? 0 : tokens.space.xl}
+    />
+  )
+}
+
+function SuggestedProfileCard({
+  profile,
+  moderationOpts,
+  position,
+}: {
+  profile: bsky.profile.AnyProfileView
+  moderationOpts: ModerationOpts
+  position: number
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.flex_1,
+        a.w_full,
+        a.py_lg,
+        a.px_xl,
+        position !== 0 && a.border_t,
+        t.atoms.border_contrast_low,
+      ]}>
+      <ProfileCard.Outer>
+        <ProfileCard.Header>
+          <ProfileCard.Avatar
+            profile={profile}
+            moderationOpts={moderationOpts}
+            disabledPreview
+          />
+          <ProfileCard.NameAndHandle
+            profile={profile}
+            moderationOpts={moderationOpts}
+          />
+          <ProfileCard.FollowButton
+            profile={profile}
+            moderationOpts={moderationOpts}
+            withIcon={false}
+            logContext="OnboardingSuggestedAccounts"
+            onFollow={() => {
+              logger.metric(
+                'suggestedUser:follow',
+                {
+                  logContext: 'Onboarding',
+                  location: 'Card',
+                  recId: undefined,
+                  position,
+                },
+                {statsig: true},
+              )
+            }}
+          />
+        </ProfileCard.Header>
+        <ProfileCard.Description profile={profile} numberOfLines={3} />
+      </ProfileCard.Outer>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/index.tsx b/src/screens/Onboarding/index.tsx
index a5c423ca1..f13402ece 100644
--- a/src/screens/Onboarding/index.tsx
+++ b/src/screens/Onboarding/index.tsx
@@ -1,21 +1,37 @@
-import React from 'react'
+import {useMemo, useReducer} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {Layout, OnboardingControls} from '#/screens/Onboarding/Layout'
+import {useGate} from '#/lib/statsig/statsig'
+import {
+  Layout,
+  OnboardingControls,
+  OnboardingHeaderSlot,
+} from '#/screens/Onboarding/Layout'
 import {Context, initialState, reducer} from '#/screens/Onboarding/state'
 import {StepFinished} from '#/screens/Onboarding/StepFinished'
 import {StepInterests} from '#/screens/Onboarding/StepInterests'
 import {StepProfile} from '#/screens/Onboarding/StepProfile'
 import {Portal} from '#/components/Portal'
+import {ENV} from '#/env'
+import {StepSuggestedAccounts} from './StepSuggestedAccounts'
 
 export function Onboarding() {
   const {_} = useLingui()
-  const [state, dispatch] = React.useReducer(reducer, {
+  const gate = useGate()
+  const showValueProp = ENV !== 'e2e' && gate('onboarding_value_prop')
+  const showSuggestedAccounts =
+    ENV !== 'e2e' && gate('onboarding_suggested_accounts')
+  const [state, dispatch] = useReducer(reducer, {
     ...initialState,
+    totalSteps: showSuggestedAccounts ? 4 : 3,
+    experiments: {
+      onboarding_suggested_accounts: showSuggestedAccounts,
+      onboarding_value_prop: showValueProp,
+    },
   })
 
-  const interestsDisplayNames = React.useMemo(() => {
+  const interestsDisplayNames = useMemo(() => {
     return {
       news: _(msg`News`),
       journalism: _(msg`Journalism`),
@@ -45,17 +61,22 @@ export function Onboarding() {
   return (
     <Portal>
       <OnboardingControls.Provider>
-        <Context.Provider
-          value={React.useMemo(
-            () => ({state, dispatch, interestsDisplayNames}),
-            [state, dispatch, interestsDisplayNames],
-          )}>
-          <Layout>
-            {state.activeStep === 'profile' && <StepProfile />}
-            {state.activeStep === 'interests' && <StepInterests />}
-            {state.activeStep === 'finished' && <StepFinished />}
-          </Layout>
-        </Context.Provider>
+        <OnboardingHeaderSlot.Provider>
+          <Context.Provider
+            value={useMemo(
+              () => ({state, dispatch, interestsDisplayNames}),
+              [state, dispatch, interestsDisplayNames],
+            )}>
+            <Layout>
+              {state.activeStep === 'profile' && <StepProfile />}
+              {state.activeStep === 'interests' && <StepInterests />}
+              {state.activeStep === 'suggested-accounts' && (
+                <StepSuggestedAccounts />
+              )}
+              {state.activeStep === 'finished' && <StepFinished />}
+            </Layout>
+          </Context.Provider>
+        </OnboardingHeaderSlot.Provider>
       </OnboardingControls.Provider>
     </Portal>
   )
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
index cbb466245..31f6eb039 100644
--- a/src/screens/Onboarding/state.ts
+++ b/src/screens/Onboarding/state.ts
@@ -11,7 +11,7 @@ import {
 export type OnboardingState = {
   hasPrev: boolean
   totalSteps: number
-  activeStep: 'profile' | 'interests' | 'finished'
+  activeStep: 'profile' | 'interests' | 'suggested-accounts' | 'finished'
   activeStepIndex: number
 
   interestsStepResults: {
@@ -34,6 +34,11 @@ export type OnboardingState = {
       backgroundColor: AvatarColor
     }
   }
+
+  experiments?: {
+    onboarding_suggested_accounts?: boolean
+    onboarding_value_prop?: boolean
+  }
 }
 
 export type OnboardingAction =
@@ -160,22 +165,49 @@ export function reducer(
 
   switch (a.type) {
     case 'next': {
-      if (s.activeStep === 'profile') {
-        next.activeStep = 'interests'
-        next.activeStepIndex = 2
-      } else if (s.activeStep === 'interests') {
-        next.activeStep = 'finished'
-        next.activeStepIndex = 3
+      if (s.experiments?.onboarding_suggested_accounts) {
+        if (s.activeStep === 'profile') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        } else if (s.activeStep === 'interests') {
+          next.activeStep = 'suggested-accounts'
+          next.activeStepIndex = 3
+        }
+        if (s.activeStep === 'suggested-accounts') {
+          next.activeStep = 'finished'
+          next.activeStepIndex = 4
+        }
+      } else {
+        if (s.activeStep === 'profile') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        } else if (s.activeStep === 'interests') {
+          next.activeStep = 'finished'
+          next.activeStepIndex = 3
+        }
       }
       break
     }
     case 'prev': {
-      if (s.activeStep === 'interests') {
-        next.activeStep = 'profile'
-        next.activeStepIndex = 1
-      } else if (s.activeStep === 'finished') {
-        next.activeStep = 'interests'
-        next.activeStepIndex = 2
+      if (s.experiments?.onboarding_suggested_accounts) {
+        if (s.activeStep === 'interests') {
+          next.activeStep = 'profile'
+          next.activeStepIndex = 1
+        } else if (s.activeStep === 'suggested-accounts') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        } else if (s.activeStep === 'finished') {
+          next.activeStep = 'suggested-accounts'
+          next.activeStepIndex = 3
+        }
+      } else {
+        if (s.activeStep === 'interests') {
+          next.activeStep = 'profile'
+          next.activeStepIndex = 1
+        } else if (s.activeStep === 'finished') {
+          next.activeStep = 'interests'
+          next.activeStepIndex = 2
+        }
       }
       break
     }
diff --git a/src/screens/Onboarding/util.ts b/src/screens/Onboarding/util.ts
index d14c9562e..b08f0408e 100644
--- a/src/screens/Onboarding/util.ts
+++ b/src/screens/Onboarding/util.ts
@@ -1,9 +1,9 @@
 import {
-  $Typed,
-  AppBskyGraphFollow,
-  AppBskyGraphGetFollows,
-  BskyAgent,
-  ComAtprotoRepoApplyWrites,
+  type $Typed,
+  type AppBskyGraphFollow,
+  type AppBskyGraphGetFollows,
+  type BskyAgent,
+  type ComAtprotoRepoApplyWrites,
 } from '@atproto/api'
 import {TID} from '@atproto/common-web'
 import chunk from 'lodash.chunk'
@@ -42,10 +42,10 @@ export async function bulkWriteFollows(agent: BskyAgent, dids: string[]) {
   }
   await whenFollowsIndexed(agent, session.did, res => !!res.data.follows.length)
 
-  const followUris = new Map()
+  const followUris = new Map<string, string>()
   for (const r of followWrites) {
     followUris.set(
-      r.value.subject,
+      r.value.subject as string,
       `at://${session.did}/app.bsky.graph.follow/${r.rkey}`,
     )
   }
diff --git a/src/screens/PostThread/components/ThreadComposePrompt.tsx b/src/screens/PostThread/components/ThreadComposePrompt.tsx
new file mode 100644
index 000000000..e12c7e766
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadComposePrompt.tsx
@@ -0,0 +1,95 @@
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+import {LinearGradient} from 'expo-linear-gradient'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {PressableScale} from '#/lib/custom-animations/PressableScale'
+import {useHaptics} from '#/lib/haptics'
+import {useHideBottomBarBorderForScreen} from '#/lib/hooks/useHideBottomBarBorder'
+import {useProfileQuery} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, ios, native, useBreakpoints, useTheme} from '#/alf'
+import {transparentifyColor} from '#/alf/util/colorGeneration'
+import {useInteractionState} from '#/components/hooks/useInteractionState'
+import {Text} from '#/components/Typography'
+
+export function ThreadComposePrompt({
+  onPressCompose,
+  style,
+}: {
+  onPressCompose: () => void
+  style?: StyleProp<ViewStyle>
+}) {
+  const {currentAccount} = useSession()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const t = useTheme()
+  const playHaptic = useHaptics()
+  const {
+    state: hovered,
+    onIn: onHoverIn,
+    onOut: onHoverOut,
+  } = useInteractionState()
+
+  useHideBottomBarBorderForScreen()
+
+  return (
+    <View
+      style={[
+        a.px_sm,
+        gtMobile
+          ? [a.py_xs, a.border_t, t.atoms.border_contrast_low, t.atoms.bg]
+          : [a.pb_2xs],
+        style,
+      ]}>
+      {!gtMobile && (
+        <LinearGradient
+          key={t.name} // android does not update when you change the colors. sigh.
+          start={[0.5, 0]}
+          end={[0.5, 1]}
+          colors={[
+            transparentifyColor(t.atoms.bg.backgroundColor, 0),
+            t.atoms.bg.backgroundColor,
+          ]}
+          locations={[0.15, 0.4]}
+          style={[a.absolute, a.inset_0]}
+        />
+      )}
+      <PressableScale
+        accessibilityRole="button"
+        accessibilityLabel={_(msg`Compose reply`)}
+        accessibilityHint={_(msg`Opens composer`)}
+        onPress={() => {
+          onPressCompose()
+          playHaptic('Light')
+        }}
+        onLongPress={ios(() => {
+          onPressCompose()
+          playHaptic('Heavy')
+        })}
+        onHoverIn={onHoverIn}
+        onHoverOut={onHoverOut}
+        style={[
+          a.flex_row,
+          a.align_center,
+          a.p_sm,
+          a.gap_sm,
+          a.rounded_full,
+          (!gtMobile || hovered) && t.atoms.bg_contrast_25,
+          native([a.border, t.atoms.border_contrast_low]),
+          a.transition_color,
+        ]}>
+        <UserAvatar
+          size={24}
+          avatar={profile?.avatar}
+          type={profile?.associated?.labeler ? 'labeler' : 'user'}
+        />
+        <Text style={[a.text_md, t.atoms.text_contrast_medium]}>
+          <Trans>Write your reply</Trans>
+        </Text>
+      </PressableScale>
+    </View>
+  )
+}
diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx
index fc1f1caeb..b59397b0b 100644
--- a/src/screens/PostThread/components/ThreadItemAnchor.tsx
+++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx
@@ -32,20 +32,21 @@ import {useSession} from '#/state/session'
 import {type OnPostSuccessData} from '#/state/shell/composer'
 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
 import {type PostSource} from '#/state/unstable-post-source'
-import {PostThreadFollowBtn} from '#/view/com/post-thread/PostThreadFollowBtn'
 import {formatCount} from '#/view/com/util/numeric/format'
 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
+import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton'
 import {
   LINEAR_AVI_WIDTH,
   OUTER_SPACE,
   REPLY_LINE_WIDTH,
 } from '#/screens/PostThread/const'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
 import {colors} from '#/components/Admonition'
 import {Button} from '#/components/Button'
 import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
 import {InlineLinkText, Link} from '#/components/Link'
+import {LoggedOutCTA} from '#/components/LoggedOutCTA'
 import {ContentHider} from '#/components/moderation/ContentHider'
 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
 import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -178,7 +179,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
   const {_, i18n} = useLingui()
   const {openComposer} = useOpenComposer()
   const {currentAccount, hasSession} = useSession()
-  const feedFeedback = useFeedFeedback(postSource?.feed, hasSession)
+  const {gtTablet} = useBreakpoints()
+  const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession)
 
   const post = postShadow
   const record = item.value.post.record
@@ -311,6 +313,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
           },
           isRoot && [a.pt_lg],
         ]}>
+        {/* Show CTA for logged-out visitors - hide on desktop and check gate */}
+        {!gtTablet && <LoggedOutCTA gateName="cta_above_post_heading" />}
         <View style={[a.flex_row, a.gap_md, a.pb_md]}>
           <View collapsable={false}>
             <PreviewableUserAvatar
@@ -367,7 +371,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
           </Link>
           {showFollowButton && (
             <View collapsable={false}>
-              <PostThreadFollowBtn did={post.author.did} />
+              <ThreadItemAnchorFollowButton did={post.author.did} />
             </View>
           )}
         </View>
diff --git a/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx
new file mode 100644
index 000000000..d4cf120cf
--- /dev/null
+++ b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx
@@ -0,0 +1,139 @@
+import React from 'react'
+import {type AppBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {logger} from '#/logger'
+import {useProfileShadow} from '#/state/cache/profile-shadow'
+import {
+  useProfileFollowMutationQueue,
+  useProfileQuery,
+} from '#/state/queries/profile'
+import {useRequireAuth} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+
+export function ThreadItemAnchorFollowButton({did}: {did: string}) {
+  const {data: profile, isLoading} = useProfileQuery({did})
+
+  // We will never hit this - the profile will always be cached or loaded above
+  // but it keeps the typechecker happy
+  if (isLoading || !profile) return null
+
+  return <PostThreadFollowBtnLoaded profile={profile} />
+}
+
+function PostThreadFollowBtnLoaded({
+  profile: profileUnshadowed,
+}: {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+}) {
+  const navigation = useNavigation()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+  const profile = useProfileShadow(profileUnshadowed)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+    profile,
+    'PostThreadItem',
+  )
+  const requireAuth = useRequireAuth()
+
+  const isFollowing = !!profile.viewer?.following
+  const isFollowedBy = !!profile.viewer?.followedBy
+  const [wasFollowing, setWasFollowing] = React.useState<boolean>(isFollowing)
+
+  // This prevents the button from disappearing as soon as we follow.
+  const showFollowBtn = React.useMemo(
+    () => !isFollowing || !wasFollowing,
+    [isFollowing, wasFollowing],
+  )
+
+  /**
+   * We want this button to stay visible even after following, so that the user can unfollow if they want.
+   * However, we need it to disappear after we push to a screen and then come back. We also need it to
+   * show up if we view the post while following, go to the profile and unfollow, then come back to the
+   * post.
+   *
+   * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
+   * we could do this only on focus because the transition animation gives us time to not notice the
+   * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
+   * button renders. So, we update the state in both cases.
+   */
+  React.useEffect(() => {
+    const updateWasFollowing = () => {
+      if (wasFollowing !== isFollowing) {
+        setWasFollowing(isFollowing)
+      }
+    }
+
+    const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
+    const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
+
+    return () => {
+      unsubscribeFocus()
+      unsubscribeBlur()
+    }
+  }, [isFollowing, wasFollowing, navigation])
+
+  const onPress = React.useCallback(() => {
+    if (!isFollowing) {
+      requireAuth(async () => {
+        try {
+          await queueFollow()
+        } catch (e: any) {
+          if (e?.name !== 'AbortError') {
+            logger.error('Failed to follow', {message: String(e)})
+            Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
+          }
+        }
+      })
+    } else {
+      requireAuth(async () => {
+        try {
+          await queueUnfollow()
+        } catch (e: any) {
+          if (e?.name !== 'AbortError') {
+            logger.error('Failed to unfollow', {message: String(e)})
+            Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark')
+          }
+        }
+      })
+    }
+  }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
+
+  if (!showFollowBtn) return null
+
+  return (
+    <Button
+      testID="followBtn"
+      label={_(msg`Follow ${profile.handle}`)}
+      onPress={onPress}
+      size="small"
+      variant="solid"
+      color={isFollowing ? 'secondary' : 'secondary_inverted'}
+      style={[a.rounded_full]}>
+      {gtMobile && (
+        <ButtonIcon
+          icon={isFollowing ? Check : Plus}
+          position="left"
+          size="sm"
+        />
+      )}
+      <ButtonText>
+        {!isFollowing ? (
+          isFollowedBy ? (
+            <Trans>Follow back</Trans>
+          ) : (
+            <Trans>Follow</Trans>
+          )
+        ) : (
+          <Trans>Following</Trans>
+        )}
+      </ButtonText>
+    </Button>
+  )
+}
diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx
index f91daf54b..c27f2c322 100644
--- a/src/screens/PostThread/index.tsx
+++ b/src/screens/PostThread/index.tsx
@@ -12,9 +12,9 @@ import {useSession} from '#/state/session'
 import {type OnPostSuccessData} from '#/state/shell/composer'
 import {useShellLayout} from '#/state/shell/shell-layout'
 import {useUnstablePostSource} from '#/state/unstable-post-source'
-import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
 import {List, type ListMethods} from '#/view/com/util/List'
 import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown'
+import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
 import {ThreadError} from '#/screens/PostThread/components/ThreadError'
 import {
   ThreadItemAnchor,
@@ -38,6 +38,7 @@ import {
 import {atoms as a, native, platform, useBreakpoints, web} from '#/alf'
 import * as Layout from '#/components/Layout'
 import {ListFooter} from '#/components/Lists'
+import {LoggedOutCTA} from '#/components/LoggedOutCTA'
 
 const PARENT_CHUNK_SIZE = 5
 const CHILDREN_CHUNK_SIZE = 50
@@ -48,7 +49,10 @@ export function PostThread({uri}: {uri: string}) {
   const initialNumToRender = useInitialNumToRender()
   const {height: windowHeight} = useWindowDimensions()
   const anchorPostSource = useUnstablePostSource(uri)
-  const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
+  const feedFeedback = useFeedFeedback(
+    anchorPostSource?.feedSourceInfo,
+    hasSession,
+  )
 
   /*
    * One query to rule them all
@@ -405,6 +409,8 @@ export function PostThread({uri}: {uri: string}) {
                 onPostSuccess={optimisticOnPostReply}
                 postSource={anchorPostSource}
               />
+              {/* Show CTA for logged-out visitors */}
+              <LoggedOutCTA style={a.px_lg} gateName="cta_above_post_replies" />
             </View>
           )
         } else {
@@ -455,7 +461,7 @@ export function PostThread({uri}: {uri: string}) {
         return (
           <View>
             {gtMobile && (
-              <PostThreadComposePrompt onPressCompose={onReplyToAnchor} />
+              <ThreadComposePrompt onPressCompose={onReplyToAnchor} />
             )}
           </View>
         )
@@ -586,7 +592,7 @@ function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
 
   return (
     <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}>
-      <PostThreadComposePrompt onPressCompose={onPressReply} />
+      <ThreadComposePrompt onPressCompose={onPressReply} />
     </Animated.View>
   )
 }
diff --git a/src/screens/Profile/Header/EditProfileDialog.tsx b/src/screens/Profile/Header/EditProfileDialog.tsx
index 95160ce86..eb9e9179d 100644
--- a/src/screens/Profile/Header/EditProfileDialog.tsx
+++ b/src/screens/Profile/Header/EditProfileDialog.tsx
@@ -8,7 +8,6 @@ import {urls} from '#/lib/constants'
 import {cleanError} from '#/lib/strings/errors'
 import {useWarnMaxGraphemeCount} from '#/lib/strings/helpers'
 import {logger} from '#/logger'
-import {isWeb} from '#/platform/detection'
 import {type ImageMeta} from '#/state/gallery'
 import {useProfileUpdateMutation} from '#/state/queries/profile'
 import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
@@ -44,20 +43,6 @@ export function EditProfileDialog({
   const cancelControl = Dialog.useDialogControl()
   const [dirty, setDirty] = useState(false)
 
-  // 'You might lose unsaved changes' warning
-  useEffect(() => {
-    if (isWeb && dirty) {
-      const abortController = new AbortController()
-      const {signal} = abortController
-      window.addEventListener('beforeunload', evt => evt.preventDefault(), {
-        signal,
-      })
-      return () => {
-        abortController.abort()
-      }
-    }
-  }, [dirty])
-
   const onPressCancel = useCallback(() => {
     if (dirty) {
       cancelControl.open()
@@ -73,6 +58,15 @@ export function EditProfileDialog({
         preventDismiss: dirty,
         minHeight: SCREEN_HEIGHT,
       }}
+      webOptions={{
+        onBackgroundPress: () => {
+          if (dirty) {
+            cancelControl.open()
+          } else {
+            control.close()
+          }
+        },
+      }}
       testID="editProfileModal">
       <DialogInner
         profile={profile}
@@ -353,9 +347,14 @@ function DialogInner({
                 You are verified. You will lose your verification status if you
                 change your display name.{' '}
                 <InlineLinkText
-                  label={_(msg`Learn more`)}
+                  label={_(
+                    msg({
+                      message: `Learn more`,
+                      context: `english-only-resource`,
+                    }),
+                  )}
                   to={urls.website.blog.initialVerificationAnnouncement}>
-                  <Trans>Learn more.</Trans>
+                  <Trans context="english-only-resource">Learn more.</Trans>
                 </InlineLinkText>
               </Trans>
             </Admonition>
diff --git a/src/screens/Profile/Header/SuggestedFollows.tsx b/src/screens/Profile/Header/SuggestedFollows.tsx
index d005d888e..58a507e08 100644
--- a/src/screens/Profile/Header/SuggestedFollows.tsx
+++ b/src/screens/Profile/Header/SuggestedFollows.tsx
@@ -28,7 +28,6 @@ export function AnimatedProfileHeaderSuggestedFollows({
   actorDid: string
 }) {
   const gate = useGate()
-  if (!gate('post_follow_profile_suggested_accounts')) return null
 
   /* NOTE (caidanw):
    * Android does not work well with this feature yet.
@@ -37,6 +36,8 @@ export function AnimatedProfileHeaderSuggestedFollows({
    **/
   if (isAndroid) return null
 
+  if (!gate('post_follow_profile_suggested_accounts')) return null
+
   return (
     <AccordionAnimation isExpanded={isExpanded}>
       <ProfileHeaderSuggestedFollows actorDid={actorDid} />
diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx
index 2f4b87015..b97fc4ed5 100644
--- a/src/screens/Profile/ProfileFeed/index.tsx
+++ b/src/screens/Profile/ProfileFeed/index.tsx
@@ -169,7 +169,7 @@ export function ProfileFeedScreenInner({
   const [hasNew, setHasNew] = React.useState(false)
   const [isScrolledDown, setIsScrolledDown] = React.useState(false)
   const queryClient = useQueryClient()
-  const feedFeedback = useFeedFeedback(feed, hasSession)
+  const feedFeedback = useFeedFeedback(feedInfo, hasSession)
   const scrollElRef = useAnimatedRef() as ListRef
 
   const onScrollToTop = useCallback(() => {
diff --git a/src/screens/Search/Explore.tsx b/src/screens/Search/Explore.tsx
index 9c4dd9d56..f8bb947a8 100644
--- a/src/screens/Search/Explore.tsx
+++ b/src/screens/Search/Explore.tsx
@@ -14,6 +14,7 @@ import {cleanError} from '#/lib/strings/errors'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {logger} from '#/logger'
 import {type MetricEvents} from '#/logger/metrics'
+import {isNative} from '#/platform/detection'
 import {useLanguagePrefs} from '#/state/preferences/languages'
 import {useModerationOpts} from '#/state/preferences/moderation-opts'
 import {RQKEY_ROOT_PAGINATED as useActorSearchPaginatedQueryKeyRoot} from '#/state/queries/actor-search'
@@ -66,9 +67,9 @@ import {
 import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
 import {StarterPack} from '#/components/icons/StarterPack'
 import {UserCircle_Stroke2_Corner0_Rounded as Person} from '#/components/icons/UserCircle'
+import {boostInterests} from '#/components/InterestTabs'
 import {Loader} from '#/components/Loader'
 import * as ProfileCard from '#/components/ProfileCard'
-import {boostInterests} from '#/components/ProgressGuide/FollowDialog'
 import {SubtleHover} from '#/components/SubtleHover'
 import {Text} from '#/components/Typography'
 import * as ModuleHeader from './components/ModuleHeader'
@@ -431,7 +432,7 @@ export function Explore({
     i.push({
       type: 'header',
       key: 'suggested-feeds-header',
-      title: _(msg`Discover Feeds`),
+      title: _(msg`Discover New Feeds`),
       icon: ListSparkle,
       searchButton: {
         label: _(msg`Search for more feeds`),
@@ -916,8 +917,7 @@ export function Explore({
         }
         case 'preview:header': {
           return (
-            <ModuleHeader.Container
-              style={[a.pt_xs, t.atoms.border_contrast_low, a.border_b]}>
+            <ModuleHeader.Container style={[a.pt_xs]} bottomBorder={isNative}>
               {/* Very non-scientific way to avoid small gap on scroll */}
               <View style={[a.absolute, a.inset_0, t.atoms.bg, {top: -2}]} />
               <ModuleHeader.FeedLink feed={item.feed}>
diff --git a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
index fd37544f4..71bfd6547 100644
--- a/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
+++ b/src/screens/Search/modules/ExploreSuggestedAccounts.tsx
@@ -14,11 +14,9 @@ import {
 } from '#/screens/Onboarding/state'
 import {useTheme} from '#/alf'
 import {atoms as a} from '#/alf'
-import {Button} from '#/components/Button'
+import {boostInterests, InterestTabs} from '#/components/InterestTabs'
 import * as ProfileCard from '#/components/ProfileCard'
-import {boostInterests, Tabs} from '#/components/ProgressGuide/FollowDialog'
 import {SubtleHover} from '#/components/SubtleHover'
-import {Text} from '#/components/Typography'
 import type * as bsky from '#/types/bsky'
 
 export function useLoadEnoughProfiles({
@@ -59,10 +57,12 @@ export function SuggestedAccountsTabBar({
   selectedInterest,
   onSelectInterest,
   hideDefaultTab,
+  defaultTabLabel,
 }: {
   selectedInterest: string | null
   onSelectInterest: (interest: string | null) => void
   hideDefaultTab?: boolean
+  defaultTabLabel?: string
 }) {
   const {_} = useLingui()
   const interestsDisplayNames = useInterestsDisplayNames()
@@ -71,9 +71,10 @@ export function SuggestedAccountsTabBar({
   const interests = Object.keys(interestsDisplayNames)
     .sort(boostInterests(popularInterests))
     .sort(boostInterests(personalizedInterests))
+
   return (
     <BlockDrawerGesture>
-      <Tabs
+      <InterestTabs
         interests={hideDefaultTab ? interests : ['all', ...interests]}
         selectedInterest={
           selectedInterest || (hideDefaultTab ? interests[0] : 'all')
@@ -86,82 +87,19 @@ export function SuggestedAccountsTabBar({
           )
           onSelectInterest(tab === 'all' ? null : tab)
         }}
-        hasSearchText={false}
         interestsDisplayNames={
           hideDefaultTab
             ? interestsDisplayNames
             : {
-                all: _(msg`For You`),
+                all: defaultTabLabel || _(msg`For You`),
                 ...interestsDisplayNames,
               }
         }
-        TabComponent={Tab}
-        contentContainerStyle={[
-          {
-            // visual alignment
-            paddingLeft: a.px_md.paddingLeft,
-          },
-        ]}
       />
     </BlockDrawerGesture>
   )
 }
 
-let Tab = ({
-  onSelectTab,
-  interest,
-  active,
-  index,
-  interestsDisplayName,
-  onLayout,
-}: {
-  onSelectTab: (index: number) => void
-  interest: string
-  active: boolean
-  index: number
-  interestsDisplayName: string
-  onLayout: (index: number, x: number, width: number) => void
-}): React.ReactNode => {
-  const t = useTheme()
-  const {_} = useLingui()
-  const activeText = active ? _(msg` (active)`) : ''
-  return (
-    <View
-      key={interest}
-      onLayout={e =>
-        onLayout(index, e.nativeEvent.layout.x, e.nativeEvent.layout.width)
-      }>
-      <Button
-        label={_(msg`Search for "${interestsDisplayName}"${activeText}`)}
-        onPress={() => onSelectTab(index)}>
-        {({hovered, pressed, focused}) => (
-          <View
-            style={[
-              a.rounded_full,
-              a.px_lg,
-              a.py_sm,
-              a.border,
-              active || hovered || pressed || focused
-                ? [t.atoms.bg_contrast_25, t.atoms.border_contrast_medium]
-                : [t.atoms.bg, t.atoms.border_contrast_low],
-            ]}>
-            <Text
-              style={[
-                a.font_medium,
-                active || hovered || pressed || focused
-                  ? t.atoms.text
-                  : t.atoms.text_contrast_medium,
-              ]}>
-              {interestsDisplayName}
-            </Text>
-          </View>
-        )}
-      </Button>
-    </View>
-  )
-}
-Tab = memo(Tab)
-
 /**
  * Profile card for suggested accounts. Note: border is on the bottom edge
  */
diff --git a/src/screens/Search/util/useSuggestedUsers.ts b/src/screens/Search/util/useSuggestedUsers.ts
index aa29dad8c..9ca2c558a 100644
--- a/src/screens/Search/util/useSuggestedUsers.ts
+++ b/src/screens/Search/util/useSuggestedUsers.ts
@@ -11,6 +11,7 @@ import {useInterestsDisplayNames} from '#/screens/Onboarding/state'
 export function useSuggestedUsers({
   category = null,
   search = false,
+  overrideInterests,
 }: {
   category?: string | null
   /**
@@ -18,11 +19,17 @@ export function useSuggestedUsers({
    * based on the user's "app language setting
    */
   search?: boolean
+  /**
+   * In onboarding, interests haven't been saved to prefs yet, so we need to
+   * pass them down through here
+   */
+  overrideInterests?: string[]
 }) {
   const interestsDisplayNames = useInterestsDisplayNames()
   const curated = useGetSuggestedUsersQuery({
     enabled: !search,
     category,
+    overrideInterests,
   })
   const searched = useActorSearchPaginated({
     enabled: !!search,
@@ -43,6 +50,7 @@ export function useSuggestedUsers({
         isLoading: searched.isLoading,
         error: searched.error,
         isRefetching: searched.isRefetching,
+        refetch: searched.refetch,
       }
     } else {
       return {
@@ -50,6 +58,7 @@ export function useSuggestedUsers({
         isLoading: curated.isLoading,
         error: curated.error,
         isRefetching: curated.isRefetching,
+        refetch: curated.refetch,
       }
     }
   }, [curated, searched, search])
diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx
index 86652d277..8f320459c 100644
--- a/src/screens/Settings/AccountSettings.tsx
+++ b/src/screens/Settings/AccountSettings.tsx
@@ -25,6 +25,7 @@ import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/ic
 import {Trash_Stroke2_Corner2_Rounded} from '#/components/icons/Trash'
 import * as Layout from '#/components/Layout'
 import {ChangeHandleDialog} from './components/ChangeHandleDialog'
+import {ChangePasswordDialog} from './components/ChangePasswordDialog'
 import {DeactivateAccountDialog} from './components/DeactivateAccountDialog'
 import {ExportCarDialog} from './components/ExportCarDialog'
 
@@ -37,6 +38,7 @@ export function AccountSettingsScreen({}: Props) {
   const emailDialogControl = useEmailDialogControl()
   const birthdayControl = useDialogControl()
   const changeHandleControl = useDialogControl()
+  const changePasswordControl = useDialogControl()
   const exportCarControl = useDialogControl()
   const deactivateAccountControl = useDialogControl()
 
@@ -117,7 +119,7 @@ export function AccountSettingsScreen({}: Props) {
           <SettingsList.Divider />
           <SettingsList.PressableItem
             label={_(msg`Password`)}
-            onPress={() => openModal({name: 'change-password'})}>
+            onPress={() => changePasswordControl.open()}>
             <SettingsList.ItemIcon icon={LockIcon} />
             <SettingsList.ItemText>
               <Trans>Password</Trans>
@@ -180,6 +182,7 @@ export function AccountSettingsScreen({}: Props) {
 
       <BirthDateSettingsDialog control={birthdayControl} />
       <ChangeHandleDialog control={changeHandleControl} />
+      <ChangePasswordDialog control={changePasswordControl} />
       <ExportCarDialog control={exportCarControl} />
       <DeactivateAccountDialog control={deactivateAccountControl} />
     </Layout.Screen>
diff --git a/src/screens/Settings/ThreadPreferences.tsx b/src/screens/Settings/ThreadPreferences.tsx
index af3cf915f..cba896a76 100644
--- a/src/screens/Settings/ThreadPreferences.tsx
+++ b/src/screens/Settings/ThreadPreferences.tsx
@@ -6,11 +6,6 @@ import {
   type CommonNavigatorParams,
   type NativeStackScreenProps,
 } from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
-import {
-  usePreferencesQuery,
-  useSetThreadViewPreferencesMutation,
-} from '#/state/queries/preferences'
 import {
   normalizeSort,
   normalizeView,
@@ -18,7 +13,6 @@ import {
 } from '#/state/queries/preferences/useThreadPreferences'
 import {atoms as a, useTheme} from '#/alf'
 import * as Toggle from '#/components/forms/Toggle'
-import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
 import {Bubbles_Stroke2_Corner2_Rounded as BubblesIcon} from '#/components/icons/Bubble'
 import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person'
 import {Tree_Stroke2_Corner0_Rounded as TreeIcon} from '#/components/icons/Tree'
@@ -28,16 +22,6 @@ import * as SettingsList from './components/SettingsList'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PreferencesThreads'>
 export function ThreadPreferencesScreen({}: Props) {
-  const gate = useGate()
-
-  return gate('post_threads_v2_unspecced') ? (
-    <ThreadPreferencesV2 />
-  ) : (
-    <ThreadPreferencesV1 />
-  )
-}
-
-export function ThreadPreferencesV2() {
   const t = useTheme()
   const {_} = useLingui()
   const {
@@ -150,145 +134,3 @@ export function ThreadPreferencesV2() {
     </Layout.Screen>
   )
 }
-
-export function ThreadPreferencesV1() {
-  const {_} = useLingui()
-  const t = useTheme()
-
-  const {data: preferences} = usePreferencesQuery()
-  const {mutate: setThreadViewPrefs, variables} =
-    useSetThreadViewPreferencesMutation()
-
-  const sortReplies = variables?.sort ?? preferences?.threadViewPrefs?.sort
-
-  const prioritizeFollowedUsers = Boolean(
-    variables?.prioritizeFollowedUsers ??
-      preferences?.threadViewPrefs?.prioritizeFollowedUsers,
-  )
-  const treeViewEnabled = Boolean(
-    variables?.lab_treeViewEnabled ??
-      preferences?.threadViewPrefs?.lab_treeViewEnabled,
-  )
-
-  return (
-    <Layout.Screen testID="threadPreferencesScreen">
-      <Layout.Header.Outer>
-        <Layout.Header.BackButton />
-        <Layout.Header.Content>
-          <Layout.Header.TitleText>
-            <Trans>Thread Preferences</Trans>
-          </Layout.Header.TitleText>
-        </Layout.Header.Content>
-        <Layout.Header.Slot />
-      </Layout.Header.Outer>
-      <Layout.Content>
-        <SettingsList.Container>
-          <SettingsList.Group>
-            <SettingsList.ItemIcon icon={BubblesIcon} />
-            <SettingsList.ItemText>
-              <Trans>Sort replies</Trans>
-            </SettingsList.ItemText>
-            <View style={[a.w_full, a.gap_md]}>
-              <Text style={[a.flex_1, t.atoms.text_contrast_medium]}>
-                <Trans>Sort replies to the same post by:</Trans>
-              </Text>
-              <Toggle.Group
-                label={_(msg`Sort replies by`)}
-                type="radio"
-                values={sortReplies ? [sortReplies] : []}
-                onChange={values => setThreadViewPrefs({sort: values[0]})}>
-                <View style={[a.gap_sm, a.flex_1]}>
-                  <Toggle.Item name="hotness" label={_(msg`Hot replies first`)}>
-                    <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Hot replies first</Trans>
-                    </Toggle.LabelText>
-                  </Toggle.Item>
-                  <Toggle.Item
-                    name="oldest"
-                    label={_(msg`Oldest replies first`)}>
-                    <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Oldest replies first</Trans>
-                    </Toggle.LabelText>
-                  </Toggle.Item>
-                  <Toggle.Item
-                    name="newest"
-                    label={_(msg`Newest replies first`)}>
-                    <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Newest replies first</Trans>
-                    </Toggle.LabelText>
-                  </Toggle.Item>
-                  <Toggle.Item
-                    name="most-likes"
-                    label={_(msg`Most-liked replies first`)}>
-                    <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Most-liked first</Trans>
-                    </Toggle.LabelText>
-                  </Toggle.Item>
-                  <Toggle.Item
-                    name="random"
-                    label={_(msg`Random (aka "Poster's Roulette")`)}>
-                    <Toggle.Radio />
-                    <Toggle.LabelText>
-                      <Trans>Random (aka "Poster's Roulette")</Trans>
-                    </Toggle.LabelText>
-                  </Toggle.Item>
-                </View>
-              </Toggle.Group>
-            </View>
-          </SettingsList.Group>
-          <SettingsList.Group>
-            <SettingsList.ItemIcon icon={PersonGroupIcon} />
-            <SettingsList.ItemText>
-              <Trans>Prioritize your Follows</Trans>
-            </SettingsList.ItemText>
-            <Toggle.Item
-              type="checkbox"
-              name="prioritize-follows"
-              label={_(msg`Prioritize your Follows`)}
-              value={prioritizeFollowedUsers}
-              onChange={value =>
-                setThreadViewPrefs({
-                  prioritizeFollowedUsers: value,
-                })
-              }
-              style={[a.w_full, a.gap_md]}>
-              <Toggle.LabelText style={[a.flex_1]}>
-                <Trans>
-                  Show replies by people you follow before all other replies
-                </Trans>
-              </Toggle.LabelText>
-              <Toggle.Platform />
-            </Toggle.Item>
-          </SettingsList.Group>
-          <SettingsList.Divider />
-          <SettingsList.Group>
-            <SettingsList.ItemIcon icon={BeakerIcon} />
-            <SettingsList.ItemText>
-              <Trans>Experimental</Trans>
-            </SettingsList.ItemText>
-            <Toggle.Item
-              type="checkbox"
-              name="threaded-mode"
-              label={_(msg`Threaded mode`)}
-              value={treeViewEnabled}
-              onChange={value =>
-                setThreadViewPrefs({
-                  lab_treeViewEnabled: value,
-                })
-              }
-              style={[a.w_full, a.gap_md]}>
-              <Toggle.LabelText style={[a.flex_1]}>
-                <Trans>Show replies as threaded</Trans>
-              </Toggle.LabelText>
-              <Toggle.Platform />
-            </Toggle.Item>
-          </SettingsList.Group>
-        </SettingsList.Container>
-      </Layout.Content>
-    </Layout.Screen>
-  )
-}
diff --git a/src/screens/Settings/components/ChangeHandleDialog.tsx b/src/screens/Settings/components/ChangeHandleDialog.tsx
index 59e004252..8002c172f 100644
--- a/src/screens/Settings/components/ChangeHandleDialog.tsx
+++ b/src/screens/Settings/components/ChangeHandleDialog.tsx
@@ -209,9 +209,14 @@ function ProvidedHandlePage({
                 You are verified. You will lose your verification status if you
                 change your handle.{' '}
                 <InlineLinkText
-                  label={_(msg`Learn more`)}
+                  label={_(
+                    msg({
+                      message: `Learn more`,
+                      context: `english-only-resource`,
+                    }),
+                  )}
                   to={urls.website.blog.initialVerificationAnnouncement}>
-                  <Trans>Learn more.</Trans>
+                  <Trans context="english-only-resource">Learn more.</Trans>
                 </InlineLinkText>
               </Trans>
             </Admonition>
@@ -268,7 +273,12 @@ function ProvidedHandlePage({
               If you have your own domain, you can use that as your handle. This
               lets you self-verify your identity.{' '}
               <InlineLinkText
-                label={_(msg`learn more`)}
+                label={_(
+                  msg({
+                    message: `Learn more`,
+                    context: `english-only-resource`,
+                  }),
+                )}
                 to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial"
                 style={[a.font_bold]}
                 disableMismatchWarning>
diff --git a/src/screens/Settings/components/ChangePasswordDialog.tsx b/src/screens/Settings/components/ChangePasswordDialog.tsx
new file mode 100644
index 000000000..7e3e62eee
--- /dev/null
+++ b/src/screens/Settings/components/ChangePasswordDialog.tsx
@@ -0,0 +1,300 @@
+import {useState} from 'react'
+import {useWindowDimensions, View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import * as EmailValidator from 'email-validator'
+
+import {cleanError, isNetworkError} from '#/lib/strings/errors'
+import {checkAndFormatResetCode} from '#/lib/strings/password'
+import {logger} from '#/logger'
+import {isNative} from '#/platform/detection'
+import {useAgent, useSession} from '#/state/session'
+import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
+import {android, atoms as a, web} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import * as TextField from '#/components/forms/TextField'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+enum Stages {
+  RequestCode = 'RequestCode',
+  ChangePassword = 'ChangePassword',
+  Done = 'Done',
+}
+
+export function ChangePasswordDialog({
+  control,
+}: {
+  control: Dialog.DialogControlProps
+}) {
+  const {height} = useWindowDimensions()
+
+  return (
+    <Dialog.Outer
+      control={control}
+      nativeOptions={android({minHeight: height / 2})}>
+      <Dialog.Handle />
+      <Inner />
+    </Dialog.Outer>
+  )
+}
+
+function Inner() {
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const agent = useAgent()
+  const control = Dialog.useDialogContext()
+
+  const [stage, setStage] = useState(Stages.RequestCode)
+  const [isProcessing, setIsProcessing] = useState(false)
+  const [resetCode, setResetCode] = useState('')
+  const [newPassword, setNewPassword] = useState('')
+  const [error, setError] = useState('')
+
+  const uiStrings = {
+    RequestCode: {
+      title: _(msg`Change your password`),
+      message: _(
+        msg`If you want to change your password, we will send you a code to verify that this is your account.`,
+      ),
+    },
+    ChangePassword: {
+      title: _(msg`Enter code`),
+      message: _(
+        msg`Please enter the code you received and the new password you would like to use.`,
+      ),
+    },
+    Done: {
+      title: _(msg`Password changed`),
+      message: _(
+        msg`Your password has been changed successfully! Please use your new password when you sign in to Bluesky from now on.`,
+      ),
+    },
+  }
+
+  const onRequestCode = async () => {
+    if (
+      !currentAccount?.email ||
+      !EmailValidator.validate(currentAccount.email)
+    ) {
+      return setError(_(msg`Your email appears to be invalid.`))
+    }
+
+    setError('')
+    setIsProcessing(true)
+    try {
+      await agent.com.atproto.server.requestPasswordReset({
+        email: currentAccount.email,
+      })
+      setStage(Stages.ChangePassword)
+    } catch (e: any) {
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your internet connection and try again.`,
+          ),
+        )
+      } else {
+        logger.error('Failed to request password reset', {safeMessage: e})
+        setError(cleanError(e))
+      }
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onChangePassword = async () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    if (!formattedCode) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+    if (!newPassword) {
+      setError(
+        _(msg`Please enter a password. It must be at least 8 characters long.`),
+      )
+      return
+    }
+    if (newPassword.length < 8) {
+      setError(_(msg`Password must be at least 8 characters long.`))
+      return
+    }
+
+    setError('')
+    setIsProcessing(true)
+    try {
+      await agent.com.atproto.server.resetPassword({
+        token: formattedCode,
+        password: newPassword,
+      })
+      setStage(Stages.Done)
+    } catch (e: any) {
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your internet connection and try again.`,
+          ),
+        )
+      } else if (e?.toString().includes('Token is invalid')) {
+        setError(_(msg`This confirmation code is not valid. Please try again.`))
+      } else {
+        logger.error('Failed to set new password', {safeMessage: e})
+        setError(cleanError(e))
+      }
+    } finally {
+      setIsProcessing(false)
+    }
+  }
+
+  const onBlur = () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    if (!formattedCode) {
+      return
+    }
+    setResetCode(formattedCode)
+  }
+
+  return (
+    <Dialog.ScrollableInner
+      label={_(msg`Change password dialog`)}
+      style={web({maxWidth: 400})}>
+      <View style={[a.gap_xl]}>
+        <View style={[a.gap_sm]}>
+          <Text style={[a.font_heavy, a.text_2xl]}>
+            {uiStrings[stage].title}
+          </Text>
+          {error ? (
+            <View style={[a.rounded_sm, a.overflow_hidden]}>
+              <ErrorMessage message={error} />
+            </View>
+          ) : null}
+
+          <Text style={[a.text_md, a.leading_snug]}>
+            {uiStrings[stage].message}
+          </Text>
+        </View>
+
+        {stage === Stages.ChangePassword && (
+          <View style={[a.gap_md]}>
+            <View>
+              <TextField.LabelText>
+                <Trans>Confirmation code</Trans>
+              </TextField.LabelText>
+              <TextField.Root>
+                <TextField.Input
+                  label={_(msg`Confirmation code`)}
+                  placeholder="XXXXX-XXXXX"
+                  value={resetCode}
+                  onChangeText={setResetCode}
+                  onBlur={onBlur}
+                  autoCapitalize="none"
+                  autoCorrect={false}
+                  autoComplete="one-time-code"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <TextField.LabelText>
+                <Trans>New password</Trans>
+              </TextField.LabelText>
+              <TextField.Root>
+                <TextField.Input
+                  label={_(msg`New password`)}
+                  placeholder={_(msg`At least 8 characters`)}
+                  value={newPassword}
+                  onChangeText={setNewPassword}
+                  secureTextEntry
+                  autoCapitalize="none"
+                  autoComplete="new-password"
+                />
+              </TextField.Root>
+            </View>
+          </View>
+        )}
+
+        <View style={[a.gap_sm]}>
+          {stage === Stages.RequestCode ? (
+            <>
+              <Button
+                label={_(msg`Request code`)}
+                color="primary"
+                size="large"
+                disabled={isProcessing}
+                onPress={onRequestCode}>
+                <ButtonText>
+                  <Trans>Request code</Trans>
+                </ButtonText>
+                {isProcessing && <ButtonIcon icon={Loader} />}
+              </Button>
+              <Button
+                label={_(msg`Already have a code?`)}
+                onPress={() => setStage(Stages.ChangePassword)}
+                size="large"
+                color="primary_subtle"
+                disabled={isProcessing}>
+                <ButtonText>
+                  <Trans>Already have a code?</Trans>
+                </ButtonText>
+              </Button>
+              {isNative && (
+                <Button
+                  label={_(msg`Cancel`)}
+                  color="secondary"
+                  size="large"
+                  disabled={isProcessing}
+                  onPress={() => control.close()}>
+                  <ButtonText>
+                    <Trans>Cancel</Trans>
+                  </ButtonText>
+                </Button>
+              )}
+            </>
+          ) : stage === Stages.ChangePassword ? (
+            <>
+              <Button
+                label={_(msg`Change password`)}
+                color="primary"
+                size="large"
+                disabled={isProcessing}
+                onPress={onChangePassword}>
+                <ButtonText>
+                  <Trans>Change password</Trans>
+                </ButtonText>
+                {isProcessing && <ButtonIcon icon={Loader} />}
+              </Button>
+              <Button
+                label={_(msg`Back`)}
+                color="secondary"
+                size="large"
+                disabled={isProcessing}
+                onPress={() => {
+                  setResetCode('')
+                  setStage(Stages.RequestCode)
+                }}>
+                <ButtonText>
+                  <Trans>Back</Trans>
+                </ButtonText>
+              </Button>
+            </>
+          ) : stage === Stages.Done ? (
+            <Button
+              label={_(msg`Close`)}
+              color="primary"
+              size="large"
+              onPress={() => control.close()}>
+              <ButtonText>
+                <Trans>Close</Trans>
+              </ButtonText>
+            </Button>
+          ) : null}
+        </View>
+      </View>
+      <Dialog.Close />
+    </Dialog.ScrollableInner>
+  )
+}
diff --git a/src/screens/Signup/StepHandle/index.tsx b/src/screens/Signup/StepHandle/index.tsx
index 5bf6b2269..64333933c 100644
--- a/src/screens/Signup/StepHandle/index.tsx
+++ b/src/screens/Signup/StepHandle/index.tsx
@@ -168,7 +168,10 @@ export function StepHandle() {
               </TextField.GhostText>
             )}
             {isHandleAvailable?.available && (
-              <CheckIcon style={[{color: t.palette.positive_600}, a.z_20]} />
+              <CheckIcon
+                testID="handleAvailableCheck"
+                style={[{color: t.palette.positive_600}, a.z_20]}
+              />
             )}
           </TextField.Root>
         </View>
diff --git a/src/screens/StarterPack/Wizard/State.tsx b/src/screens/StarterPack/Wizard/State.tsx
index 7fae8ca6d..f34218219 100644
--- a/src/screens/StarterPack/Wizard/State.tsx
+++ b/src/screens/StarterPack/Wizard/State.tsx
@@ -7,7 +7,6 @@ import {
 import {msg, plural} from '@lingui/macro'
 
 import {STARTER_PACK_MAX_SIZE} from '#/lib/constants'
-import {useSession} from '#/state/session'
 import * as Toast from '#/view/com/util/Toast'
 import * as bsky from '#/types/bsky'
 
@@ -37,6 +36,7 @@ interface State {
   processing: boolean
   error?: string
   transitionDirection: 'Backward' | 'Forward'
+  targetDid?: string
 }
 
 type TStateContext = [State, (action: Action) => void]
@@ -118,15 +118,17 @@ function reducer(state: State, action: Action): State {
 export function Provider({
   starterPack,
   listItems,
+  targetProfile,
   children,
 }: {
   starterPack?: AppBskyGraphDefs.StarterPackView
   listItems?: AppBskyGraphDefs.ListItemView[]
+  targetProfile: bsky.profile.AnyProfileView
   children: React.ReactNode
 }) {
-  const {currentAccount} = useSession()
-
   const createInitialState = (): State => {
+    const targetDid = targetProfile?.did
+
     if (
       starterPack &&
       bsky.validate(starterPack.record, AppBskyGraphStarterpack.validateRecord)
@@ -136,23 +138,22 @@ export function Provider({
         currentStep: 'Details',
         name: starterPack.record.name,
         description: starterPack.record.description,
-        profiles:
-          listItems
-            ?.map(i => i.subject)
-            .filter(p => p.did !== currentAccount?.did) ?? [],
+        profiles: listItems?.map(i => i.subject) ?? [],
         feeds: starterPack.feeds ?? [],
         processing: false,
         transitionDirection: 'Forward',
+        targetDid,
       }
     }
 
     return {
       canNext: true,
       currentStep: 'Details',
-      profiles: [],
+      profiles: [targetProfile],
       feeds: [],
       processing: false,
       transitionDirection: 'Forward',
+      targetDid,
     }
   }
 
diff --git a/src/screens/StarterPack/Wizard/index.tsx b/src/screens/StarterPack/Wizard/index.tsx
index 8256349df..839faf9aa 100644
--- a/src/screens/StarterPack/Wizard/index.tsx
+++ b/src/screens/StarterPack/Wizard/index.tsx
@@ -68,12 +68,19 @@ export function Wizard({
   CommonNavigatorParams,
   'StarterPackEdit' | 'StarterPackWizard'
 >) {
-  const {rkey} = route.params ?? {}
+  const params = route.params ?? {}
+  const rkey = 'rkey' in params ? params.rkey : undefined
+  const fromDialog = 'fromDialog' in params ? params.fromDialog : false
+  const targetDid = 'targetDid' in params ? params.targetDid : undefined
+  const onSuccess = 'onSuccess' in params ? params.onSuccess : undefined
   const {currentAccount} = useSession()
   const moderationOpts = useModerationOpts()
 
   const {_} = useLingui()
 
+  // Use targetDid if provided (from dialog), otherwise use current account
+  const profileDid = targetDid || currentAccount!.did
+
   const {
     data: starterPack,
     isLoading: isLoadingStarterPack,
@@ -91,7 +98,7 @@ export function Wizard({
     data: profile,
     isLoading: isLoadingProfile,
     isError: isErrorProfile,
-  } = useProfileQuery({did: currentAccount?.did})
+  } = useProfileQuery({did: profileDid})
 
   const isEdit = Boolean(rkey)
   const isReady =
@@ -127,12 +134,17 @@ export function Wizard({
     <Layout.Screen
       testID="starterPackWizardScreen"
       style={web([{minHeight: 0}, a.flex_1])}>
-      <Provider starterPack={starterPack} listItems={listItems}>
+      <Provider
+        starterPack={starterPack}
+        listItems={listItems}
+        targetProfile={profile}>
         <WizardInner
           currentStarterPack={starterPack}
           currentListItems={listItems}
           profile={profile}
           moderationOpts={moderationOpts}
+          fromDialog={fromDialog}
+          onSuccess={onSuccess}
         />
       </Provider>
     </Layout.Screen>
@@ -144,17 +156,22 @@ function WizardInner({
   currentListItems,
   profile,
   moderationOpts,
+  fromDialog,
+  onSuccess,
 }: {
   currentStarterPack?: AppBskyGraphDefs.StarterPackView
   currentListItems?: AppBskyGraphDefs.ListItemView[]
   profile: AppBskyActorDefs.ProfileViewDetailed
   moderationOpts: ModerationOpts
+  fromDialog?: boolean
+  onSuccess?: () => void
 }) {
   const navigation = useNavigation<NavigationProp>()
   const {_} = useLingui()
   const setMinimalShellMode = useSetMinimalShellMode()
   const [state, dispatch] = useWizardState()
   const {currentAccount} = useSession()
+
   const {data: currentProfile} = useProfileQuery({
     did: currentAccount?.did,
     staleTime: 0,
@@ -213,11 +230,17 @@ function WizardInner({
     })
     Image.prefetch([getStarterPackOgCard(currentProfile!.did, rkey)])
     dispatch({type: 'SetProcessing', processing: false})
-    navigation.replace('StarterPack', {
-      name: currentAccount!.handle,
-      rkey,
-      new: true,
-    })
+
+    if (fromDialog) {
+      navigation.goBack()
+      onSuccess?.()
+    } else {
+      navigation.replace('StarterPack', {
+        name: profile!.handle,
+        rkey,
+        new: true,
+      })
+    }
   }
 
   const onSuccessEdit = () => {
@@ -285,10 +308,7 @@ function WizardInner({
     )
   }
 
-  const items =
-    state.currentStep === 'Profiles'
-      ? [profile, ...state.profiles]
-      : state.feeds
+  const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds
 
   const isEditEnabled =
     (state.currentStep === 'Profiles' && items.length > 1) ||
@@ -340,11 +360,7 @@ function WizardInner({
       </Container>
 
       {state.currentStep !== 'Details' && (
-        <Footer
-          onNext={onNext}
-          nextBtnText={currUiStrings.nextBtn}
-          profile={profile}
-        />
+        <Footer onNext={onNext} nextBtnText={currUiStrings.nextBtn} />
       )}
       <WizardEditListDialog
         control={editDialogControl}
@@ -392,20 +408,15 @@ function Container({children}: {children: React.ReactNode}) {
 function Footer({
   onNext,
   nextBtnText,
-  profile,
 }: {
   onNext: () => void
   nextBtnText: string
-  profile: AppBskyActorDefs.ProfileViewDetailed
 }) {
   const t = useTheme()
   const [state] = useWizardState()
   const {bottom: bottomInset} = useSafeAreaInsets()
-
-  const items =
-    state.currentStep === 'Profiles'
-      ? [profile, ...state.profiles]
-      : state.feeds
+  const {currentAccount} = useSession()
+  const items = state.currentStep === 'Profiles' ? state.profiles : state.feeds
 
   const minimumItems = state.currentStep === 'Profiles' ? 8 : 0
 
@@ -471,19 +482,44 @@ function Footer({
           <Text style={[a.text_center, textStyles]}>
             {
               items.length < 2 ? (
-                <Trans>
-                  It's just you right now! Add more people to your starter pack
-                  by searching above.
-                </Trans>
+                currentAccount?.did === items[0].did ? (
+                  <Trans>
+                    It's just you right now! Add more people to your starter
+                    pack by searching above.
+                  </Trans>
+                ) : (
+                  <Trans>
+                    It's just{' '}
+                    <Text style={[a.font_bold, textStyles]} emoji>
+                      {getName(items[0])}{' '}
+                    </Text>
+                    right now! Add more people to your starter pack by searching
+                    above.
+                  </Trans>
+                )
               ) : items.length === 2 ? (
-                <Trans>
-                  <Text style={[a.font_bold, textStyles]}>You</Text> and
-                  <Text> </Text>
-                  <Text style={[a.font_bold, textStyles]} emoji>
-                    {getName(items[1] /* [0] is self, skip it */)}{' '}
-                  </Text>
-                  are included in your starter pack
-                </Trans>
+                currentAccount?.did === items[0].did ? (
+                  <Trans>
+                    <Text style={[a.font_bold, textStyles]}>You</Text> and
+                    <Text> </Text>
+                    <Text style={[a.font_bold, textStyles]} emoji>
+                      {getName(items[1] /* [0] is self, skip it */)}{' '}
+                    </Text>
+                    are included in your starter pack
+                  </Trans>
+                ) : (
+                  <Trans>
+                    <Text style={[a.font_bold, textStyles]}>
+                      {getName(items[0])}
+                    </Text>{' '}
+                    and
+                    <Text> </Text>
+                    <Text style={[a.font_bold, textStyles]} emoji>
+                      {getName(items[1] /* [0] is self, skip it */)}{' '}
+                    </Text>
+                    are included in your starter pack
+                  </Trans>
+                )
               ) : items.length > 2 ? (
                 <Trans context="profiles">
                   <Text style={[a.font_bold, textStyles]} emoji>
diff --git a/src/screens/VideoFeed/index.tsx b/src/screens/VideoFeed/index.tsx
index b53593010..1d7c2dd53 100644
--- a/src/screens/VideoFeed/index.tsx
+++ b/src/screens/VideoFeed/index.tsx
@@ -70,6 +70,7 @@ import {
   useFeedFeedbackContext,
 } from '#/state/feed-feedback'
 import {useFeedFeedback} from '#/state/feed-feedback'
+import {useFeedInfo} from '#/state/queries/feed'
 import {usePostLikeMutationQueue} from '#/state/queries/post'
 import {
   type AuthorFilter,
@@ -80,9 +81,9 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
 import {useSession} from '#/state/session'
 import {useSetMinimalShellMode} from '#/state/shell'
 import {useSetLightStatusBar} from '#/state/shell/light-status-bar'
-import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt'
 import {List} from '#/view/com/util/List'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt'
 import {Header} from '#/screens/VideoFeed/components/Header'
 import {atoms as a, ios, platform, ThemeProvider, useTheme} from '#/alf'
 import {setSystemUITheme} from '#/alf/util/systemUI'
@@ -199,7 +200,9 @@ function Feed() {
         throw new Error(`Invalid video feed params ${JSON.stringify(params)}`)
     }
   }, [params])
-  const feedFeedback = useFeedFeedback(feedDesc, hasSession)
+  const feedUri = params.type === 'feedgen' ? params.uri : undefined
+  const {data: feedInfo} = useFeedInfo(feedUri)
+  const feedFeedback = useFeedFeedback(feedInfo, hasSession)
   const {data, error, hasNextPage, isFetchingNextPage, fetchNextPage} =
     usePostFeedQuery(
       feedDesc,
@@ -883,7 +886,7 @@ function Overlay({
               player={player}
               seekingAnimationSV={seekingAnimationSV}
               scrollGesture={scrollGesture}>
-              <PostThreadComposePrompt
+              <ThreadComposePrompt
                 onPressCompose={onPressReply}
                 style={[a.pt_md, a.pb_sm]}
               />