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/Deactivated.tsx4
-rw-r--r--src/screens/Hashtag.tsx165
-rw-r--r--src/screens/Login/ChooseAccountForm.tsx188
-rw-r--r--src/screens/Login/ForgotPasswordForm.tsx184
-rw-r--r--src/screens/Login/FormContainer.tsx32
-rw-r--r--src/screens/Login/LoginForm.tsx266
-rw-r--r--src/screens/Login/PasswordUpdatedForm.tsx50
-rw-r--r--src/screens/Login/ScreenTransition.tsx10
-rw-r--r--src/screens/Login/ScreenTransition.web.tsx1
-rw-r--r--src/screens/Login/SetNewPasswordForm.tsx192
-rw-r--r--src/screens/Login/index.tsx178
-rw-r--r--src/screens/Moderation/index.tsx554
-rw-r--r--src/screens/Onboarding/IconCircle.tsx51
-rw-r--r--src/screens/Onboarding/Layout.tsx8
-rw-r--r--src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx5
-rw-r--r--src/screens/Onboarding/StepAlgoFeeds/index.tsx32
-rw-r--r--src/screens/Onboarding/StepFinished.tsx31
-rw-r--r--src/screens/Onboarding/StepFollowingFeed.tsx33
-rw-r--r--src/screens/Onboarding/StepInterests/index.tsx36
-rw-r--r--src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx29
-rw-r--r--src/screens/Onboarding/StepModeration/ModerationOption.tsx91
-rw-r--r--src/screens/Onboarding/StepModeration/index.tsx71
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx2
-rw-r--r--src/screens/Onboarding/StepSuggestedAccounts/index.tsx38
-rw-r--r--src/screens/Onboarding/StepTopicalFeeds.tsx40
-rw-r--r--src/screens/Onboarding/state.ts2
-rw-r--r--src/screens/Profile/ErrorState.tsx72
-rw-r--r--src/screens/Profile/Header/DisplayName.tsx31
-rw-r--r--src/screens/Profile/Header/Handle.tsx46
-rw-r--r--src/screens/Profile/Header/Metrics.tsx61
-rw-r--r--src/screens/Profile/Header/ProfileHeaderLabeler.tsx329
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx286
-rw-r--r--src/screens/Profile/Header/Shell.tsx164
-rw-r--r--src/screens/Profile/Header/index.tsx78
-rw-r--r--src/screens/Profile/ProfileLabelerLikedBy.tsx46
-rw-r--r--src/screens/Profile/Sections/Feed.tsx88
-rw-r--r--src/screens/Profile/Sections/Labels.tsx214
-rw-r--r--src/screens/Profile/Sections/types.ts3
-rw-r--r--src/screens/Signup/StepCaptcha/CaptchaWebView.tsx87
-rw-r--r--src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx61
-rw-r--r--src/screens/Signup/StepCaptcha/index.tsx80
-rw-r--r--src/screens/Signup/StepHandle.tsx134
-rw-r--r--src/screens/Signup/StepInfo/Policies.tsx97
-rw-r--r--src/screens/Signup/StepInfo/index.tsx146
-rw-r--r--src/screens/Signup/index.tsx228
-rw-r--r--src/screens/Signup/state.ts320
46 files changed, 4623 insertions, 241 deletions
diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx
index f4c201475..7e87973cb 100644
--- a/src/screens/Deactivated.tsx
+++ b/src/screens/Deactivated.tsx
@@ -147,7 +147,7 @@ export function Deactivated() {
                   variant="ghost"
                   size="large"
                   label={_(msg`Log out`)}
-                  onPress={logout}>
+                  onPress={() => logout('Deactivated')}>
                   <ButtonText style={[{color: t.palette.primary_500}]}>
                     <Trans>Log out</Trans>
                   </ButtonText>
@@ -176,7 +176,7 @@ export function Deactivated() {
               variant="ghost"
               size="large"
               label={_(msg`Log out`)}
-              onPress={logout}>
+              onPress={() => logout('Deactivated')}>
               <ButtonText style={[{color: t.palette.primary_500}]}>
                 <Trans>Log out</Trans>
               </ButtonText>
diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx
new file mode 100644
index 000000000..46452f087
--- /dev/null
+++ b/src/screens/Hashtag.tsx
@@ -0,0 +1,165 @@
+import React from 'react'
+import {ListRenderItemInfo, Pressable} from 'react-native'
+import {useFocusEffect} from '@react-navigation/native'
+import {useSetMinimalShellMode} from 'state/shell'
+import {ViewHeader} from 'view/com/util/ViewHeader'
+import {NativeStackScreenProps} from '@react-navigation/native-stack'
+import {CommonNavigatorParams} from 'lib/routes/types'
+import {useSearchPostsQuery} from 'state/queries/search-posts'
+import {Post} from 'view/com/post/Post'
+import {PostView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
+import {enforceLen} from 'lib/strings/helpers'
+import {
+  ListFooter,
+  ListHeaderDesktop,
+  ListMaybePlaceholder,
+} from '#/components/Lists'
+import {List} from 'view/com/util/List'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded} from '#/components/icons/ArrowOutOfBox'
+import {shareUrl} from 'lib/sharing'
+import {HITSLOP_10} from 'lib/constants'
+import {isNative} from 'platform/detection'
+import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
+
+const renderItem = ({item}: ListRenderItemInfo<PostView>) => {
+  return <Post post={item} />
+}
+
+const keyExtractor = (item: PostView, index: number) => {
+  return `${item.uri}-${index}`
+}
+
+export default function HashtagScreen({
+  route,
+}: NativeStackScreenProps<CommonNavigatorParams, 'Hashtag'>) {
+  const {tag, author} = route.params
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {_} = useLingui()
+  const initialNumToRender = useInitialNumToRender()
+  const [isPTR, setIsPTR] = React.useState(false)
+
+  const fullTag = React.useMemo(() => {
+    return `#${decodeURIComponent(tag)}`
+  }, [tag])
+
+  const queryParam = React.useMemo(() => {
+    if (!author) return fullTag
+    return `${fullTag} from:${sanitizeHandle(author)}`
+  }, [fullTag, author])
+
+  const headerTitle = React.useMemo(() => {
+    return enforceLen(fullTag.toLowerCase(), 24, true, 'middle')
+  }, [fullTag])
+
+  const sanitizedAuthor = React.useMemo(() => {
+    if (!author) return
+    return sanitizeHandle(author)
+  }, [author])
+
+  const {
+    data,
+    isFetching,
+    isLoading,
+    isRefetching,
+    isError,
+    error,
+    refetch,
+    fetchNextPage,
+    hasNextPage,
+  } = useSearchPostsQuery({query: queryParam})
+
+  const posts = React.useMemo(() => {
+    return data?.pages.flatMap(page => page.posts) || []
+  }, [data])
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  const onShare = React.useCallback(() => {
+    const url = new URL('https://bsky.app')
+    url.pathname = `/hashtag/${decodeURIComponent(tag)}`
+    if (author) {
+      url.searchParams.set('author', author)
+    }
+    shareUrl(url.toString())
+  }, [tag, author])
+
+  const onRefresh = React.useCallback(async () => {
+    setIsPTR(true)
+    await refetch()
+    setIsPTR(false)
+  }, [refetch])
+
+  const onEndReached = React.useCallback(() => {
+    if (isFetching || !hasNextPage || error) return
+    fetchNextPage()
+  }, [isFetching, hasNextPage, error, fetchNextPage])
+
+  return (
+    <>
+      <ViewHeader
+        title={headerTitle}
+        subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
+        canGoBack
+        renderButton={
+          isNative
+            ? () => (
+                <Pressable
+                  accessibilityRole="button"
+                  onPress={onShare}
+                  hitSlop={HITSLOP_10}>
+                  <ArrowOutOfBox_Stroke2_Corner0_Rounded
+                    size="lg"
+                    onPress={onShare}
+                  />
+                </Pressable>
+              )
+            : undefined
+        }
+      />
+      <ListMaybePlaceholder
+        isLoading={isLoading || isRefetching}
+        isError={isError}
+        isEmpty={posts.length < 1}
+        onRetry={refetch}
+        emptyTitle="results"
+        emptyMessage={_(msg`We couldn't find any results for that hashtag.`)}
+      />
+      {!isLoading && posts.length > 0 && (
+        <List<PostView>
+          data={posts}
+          renderItem={renderItem}
+          keyExtractor={keyExtractor}
+          refreshing={isPTR}
+          onRefresh={onRefresh}
+          onEndReached={onEndReached}
+          onEndReachedThreshold={4}
+          // @ts-ignore web only -prf
+          desktopFixedHeight
+          ListHeaderComponent={
+            <ListHeaderDesktop
+              title={headerTitle}
+              subtitle={author ? _(msg`From @${sanitizedAuthor}`) : undefined}
+            />
+          }
+          ListFooterComponent={
+            <ListFooter
+              isFetching={isFetching && !isRefetching}
+              isError={isError}
+              error={error?.name}
+              onRetry={fetchNextPage}
+            />
+          }
+          initialNumToRender={initialNumToRender}
+          windowSize={11}
+        />
+      )}
+    </>
+  )
+}
diff --git a/src/screens/Login/ChooseAccountForm.tsx b/src/screens/Login/ChooseAccountForm.tsx
new file mode 100644
index 000000000..d0d4c784d
--- /dev/null
+++ b/src/screens/Login/ChooseAccountForm.tsx
@@ -0,0 +1,188 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
+import {colors} from '#/lib/styles'
+import {useProfileQuery} from '#/state/queries/profile'
+import {SessionAccount, useSession, useSessionApi} from '#/state/session'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import * as Toast from '#/view/com/util/Toast'
+import {UserAvatar} from '#/view/com/util/UserAvatar'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import * as TextField from '#/components/forms/TextField'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {ChevronRight_Stroke2_Corner0_Rounded as Chevron} from '#/components/icons/Chevron'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+function AccountItem({
+  account,
+  onSelect,
+  isCurrentAccount,
+}: {
+  account: SessionAccount
+  onSelect: (account: SessionAccount) => void
+  isCurrentAccount: boolean
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {data: profile} = useProfileQuery({did: account.did})
+
+  const onPress = React.useCallback(() => {
+    onSelect(account)
+  }, [account, onSelect])
+
+  return (
+    <Button
+      testID={`chooseAccountBtn-${account.handle}`}
+      key={account.did}
+      style={[a.flex_1]}
+      onPress={onPress}
+      label={
+        isCurrentAccount
+          ? _(msg`Continue as ${account.handle} (currently signed in)`)
+          : _(msg`Sign in as ${account.handle}`)
+      }>
+      {({hovered, pressed}) => (
+        <View
+          style={[
+            a.flex_1,
+            a.flex_row,
+            a.align_center,
+            {height: 48},
+            (hovered || pressed) && t.atoms.bg_contrast_25,
+          ]}>
+          <View style={a.p_md}>
+            <UserAvatar avatar={profile?.avatar} size={24} />
+          </View>
+          <Text style={[a.align_baseline, a.flex_1, a.flex_row, a.py_sm]}>
+            <Text style={[a.font_bold]}>
+              {profile?.displayName || account.handle}{' '}
+            </Text>
+            <Text style={[t.atoms.text_contrast_medium]}>{account.handle}</Text>
+          </Text>
+          {isCurrentAccount ? (
+            <Check size="sm" style={[{color: colors.green3}, a.mr_md]} />
+          ) : (
+            <Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
+          )}
+        </View>
+      )}
+    </Button>
+  )
+}
+export const ChooseAccountForm = ({
+  onSelectAccount,
+  onPressBack,
+}: {
+  onSelectAccount: (account?: SessionAccount) => void
+  onPressBack: () => void
+}) => {
+  const {track, screen} = useAnalytics()
+  const {_} = useLingui()
+  const t = useTheme()
+  const {accounts, currentAccount} = useSession()
+  const {initSession} = useSessionApi()
+  const {setShowLoggedOut} = useLoggedOutViewControls()
+
+  React.useEffect(() => {
+    screen('Choose Account')
+  }, [screen])
+
+  const onSelect = React.useCallback(
+    async (account: SessionAccount) => {
+      if (account.accessJwt) {
+        if (account.did === currentAccount?.did) {
+          setShowLoggedOut(false)
+          Toast.show(_(msg`Already signed in as @${account.handle}`))
+        } else {
+          await initSession(account)
+          logEvent('account:loggedIn', {
+            logContext: 'ChooseAccountForm',
+            withPassword: false,
+          })
+          track('Sign In', {resumedSession: true})
+          setTimeout(() => {
+            Toast.show(_(msg`Signed in as @${account.handle}`))
+          }, 100)
+        }
+      } else {
+        onSelectAccount(account)
+      }
+    },
+    [currentAccount, track, initSession, onSelectAccount, setShowLoggedOut, _],
+  )
+
+  return (
+    <FormContainer
+      testID="chooseAccountForm"
+      title={<Trans>Select account</Trans>}>
+      <View>
+        <TextField.Label>
+          <Trans>Sign in as...</Trans>
+        </TextField.Label>
+        <View
+          style={[
+            a.rounded_md,
+            a.overflow_hidden,
+            a.border,
+            t.atoms.border_contrast_low,
+          ]}>
+          {accounts.map(account => (
+            <React.Fragment key={account.did}>
+              <AccountItem
+                account={account}
+                onSelect={onSelect}
+                isCurrentAccount={account.did === currentAccount?.did}
+              />
+              <View style={[a.border_b, t.atoms.border_contrast_low]} />
+            </React.Fragment>
+          ))}
+          <Button
+            testID="chooseNewAccountBtn"
+            style={[a.flex_1]}
+            onPress={() => onSelectAccount(undefined)}
+            label={_(msg`Login to account that is not listed`)}>
+            {({hovered, pressed}) => (
+              <View
+                style={[
+                  a.flex_1,
+                  a.flex_row,
+                  a.align_center,
+                  {height: 48},
+                  (hovered || pressed) && t.atoms.bg_contrast_25,
+                ]}>
+                <Text
+                  style={[
+                    a.align_baseline,
+                    a.flex_1,
+                    a.flex_row,
+                    a.py_sm,
+                    {paddingLeft: 48},
+                  ]}>
+                  <Trans>Other account</Trans>
+                </Text>
+                <Chevron size="sm" style={[t.atoms.text, a.mr_md]} />
+              </View>
+            )}
+          </Button>
+        </View>
+      </View>
+      <View style={[a.flex_row]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="medium"
+          onPress={onPressBack}>
+          {_(msg`Back`)}
+        </Button>
+        <View style={[a.flex_1]} />
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/ForgotPasswordForm.tsx b/src/screens/Login/ForgotPasswordForm.tsx
new file mode 100644
index 000000000..580452e75
--- /dev/null
+++ b/src/screens/Login/ForgotPasswordForm.tsx
@@ -0,0 +1,184 @@
+import React, {useEffect, useState} from 'react'
+import {ActivityIndicator, Keyboard, View} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {BskyAgent} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import * as EmailValidator from 'email-validator'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {isNetworkError} from '#/lib/strings/errors'
+import {cleanError} from '#/lib/strings/errors'
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {FormError} from '#/components/forms/FormError'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import * as TextField from '#/components/forms/TextField'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const ForgotPasswordForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  setError,
+  setServiceUrl,
+  onPressBack,
+  onEmailSent,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressBack: () => void
+  onEmailSent: () => void
+}) => {
+  const t = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [email, setEmail] = useState<string>('')
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+
+  useEffect(() => {
+    screen('Signin:ForgotPassword')
+  }, [screen])
+
+  const onPressSelectService = React.useCallback(() => {
+    Keyboard.dismiss()
+  }, [])
+
+  const onPressNext = async () => {
+    if (!EmailValidator.validate(email)) {
+      return setError(_(msg`Your email appears to be invalid.`))
+    }
+
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.requestPasswordReset({email})
+      onEmailSent()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to request password reset', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  return (
+    <FormContainer
+      testID="forgotPasswordForm"
+      title={<Trans>Reset password</Trans>}>
+      <View>
+        <TextField.Label>
+          <Trans>Hosting provider</Trans>
+        </TextField.Label>
+        <HostingProvider
+          serviceUrl={serviceUrl}
+          onSelectServiceUrl={setServiceUrl}
+          onOpenDialog={onPressSelectService}
+        />
+      </View>
+      <View>
+        <TextField.Label>
+          <Trans>Email address</Trans>
+        </TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={At} />
+          <TextField.Input
+            testID="forgotPasswordEmail"
+            label={_(msg`Enter your email address`)}
+            autoCapitalize="none"
+            autoFocus
+            autoCorrect={false}
+            autoComplete="email"
+            value={email}
+            onChangeText={setEmail}
+            editable={!isProcessing}
+            accessibilityHint={_(msg`Sets email for password reset`)}
+          />
+        </TextField.Root>
+      </View>
+
+      <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
+        <Trans>
+          Enter the email you used to create your account. We'll send you a
+          "reset code" so you can set a new password.
+        </Trans>
+      </Text>
+
+      <FormError error={error} />
+
+      <View style={[a.flex_row, a.align_center, a.pt_md]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="medium"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Back</Trans>
+          </ButtonText>
+        </Button>
+        <View style={a.flex_1} />
+        {!serviceDescription || isProcessing ? (
+          <ActivityIndicator />
+        ) : (
+          <Button
+            label={_(msg`Next`)}
+            variant="solid"
+            color={'primary'}
+            size="medium"
+            onPress={onPressNext}
+            disabled={!email}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+          </Button>
+        )}
+        {!serviceDescription || isProcessing ? (
+          <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+            <Trans>Processing...</Trans>
+          </Text>
+        ) : undefined}
+      </View>
+      <View
+        style={[
+          t.atoms.border_contrast_medium,
+          a.border_t,
+          a.pt_2xl,
+          a.mt_md,
+          a.flex_row,
+          a.justify_center,
+        ]}>
+        <Button
+          testID="skipSendEmailButton"
+          onPress={onEmailSent}
+          label={_(msg`Go to next`)}
+          accessibilityHint={_(msg`Navigates to the next screen`)}
+          size="medium"
+          variant="ghost"
+          color="secondary">
+          <ButtonText>
+            <Trans>Already have a code?</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/FormContainer.tsx b/src/screens/Login/FormContainer.tsx
new file mode 100644
index 000000000..0144a8b5b
--- /dev/null
+++ b/src/screens/Login/FormContainer.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import {type StyleProp, View, type ViewStyle} from 'react-native'
+
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function FormContainer({
+  testID,
+  title,
+  children,
+  style,
+}: {
+  testID?: string
+  title?: React.ReactNode
+  children: React.ReactNode
+  style?: StyleProp<ViewStyle>
+}) {
+  const {gtMobile} = useBreakpoints()
+  const t = useTheme()
+  return (
+    <View
+      testID={testID}
+      style={[a.gap_md, a.flex_1, !gtMobile && [a.px_lg, a.py_md], style]}>
+      {title && !gtMobile && (
+        <Text style={[a.text_xl, a.font_bold, t.atoms.text_contrast_high]}>
+          {title}
+        </Text>
+      )}
+      {children}
+    </View>
+  )
+}
diff --git a/src/screens/Login/LoginForm.tsx b/src/screens/Login/LoginForm.tsx
new file mode 100644
index 000000000..6bf215ee5
--- /dev/null
+++ b/src/screens/Login/LoginForm.tsx
@@ -0,0 +1,266 @@
+import React, {useRef, useState} from 'react'
+import {
+  ActivityIndicator,
+  Keyboard,
+  LayoutAnimation,
+  TextInput,
+  View,
+} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {isNetworkError} from '#/lib/strings/errors'
+import {cleanError} from '#/lib/strings/errors'
+import {createFullHandle} from '#/lib/strings/handles'
+import {logger} from '#/logger'
+import {useSessionApi} from '#/state/session'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {FormError} from '#/components/forms/FormError'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import * as TextField from '#/components/forms/TextField'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+export const LoginForm = ({
+  error,
+  serviceUrl,
+  serviceDescription,
+  initialHandle,
+  setError,
+  setServiceUrl,
+  onPressRetryConnect,
+  onPressBack,
+  onPressForgotPassword,
+}: {
+  error: string
+  serviceUrl: string
+  serviceDescription: ServiceDescription | undefined
+  initialHandle: string
+  setError: (v: string) => void
+  setServiceUrl: (v: string) => void
+  onPressRetryConnect: () => void
+  onPressBack: () => void
+  onPressForgotPassword: () => void
+}) => {
+  const {track} = useAnalytics()
+  const t = useTheme()
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [identifier, setIdentifier] = useState<string>(initialHandle)
+  const [password, setPassword] = useState<string>('')
+  const passwordInputRef = useRef<TextInput>(null)
+  const {_} = useLingui()
+  const {login} = useSessionApi()
+
+  const onPressSelectService = React.useCallback(() => {
+    Keyboard.dismiss()
+    track('Signin:PressedSelectService')
+  }, [track])
+
+  const onPressNext = async () => {
+    if (isProcessing) return
+    Keyboard.dismiss()
+    LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      // try to guess the handle if the user just gave their own username
+      let fullIdent = identifier
+      if (
+        !identifier.includes('@') && // not an email
+        !identifier.includes('.') && // not a domain
+        serviceDescription &&
+        serviceDescription.availableUserDomains.length > 0
+      ) {
+        let matched = false
+        for (const domain of serviceDescription.availableUserDomains) {
+          if (fullIdent.endsWith(domain)) {
+            matched = true
+          }
+        }
+        if (!matched) {
+          fullIdent = createFullHandle(
+            identifier,
+            serviceDescription.availableUserDomains[0],
+          )
+        }
+      }
+
+      // TODO remove double login
+      await login(
+        {
+          service: serviceUrl,
+          identifier: fullIdent,
+          password,
+        },
+        'LoginForm',
+      )
+    } catch (e: any) {
+      const errMsg = e.toString()
+      LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+      setIsProcessing(false)
+      if (errMsg.includes('Authentication Required')) {
+        logger.debug('Failed to login due to invalid credentials', {
+          error: errMsg,
+        })
+        setError(_(msg`Invalid username or password`))
+      } else if (isNetworkError(e)) {
+        logger.warn('Failed to login due to network error', {error: errMsg})
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        logger.warn('Failed to login', {error: errMsg})
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  const isReady = !!serviceDescription && !!identifier && !!password
+  return (
+    <FormContainer testID="loginForm" title={<Trans>Sign in</Trans>}>
+      <View>
+        <TextField.Label>
+          <Trans>Hosting provider</Trans>
+        </TextField.Label>
+        <HostingProvider
+          serviceUrl={serviceUrl}
+          onSelectServiceUrl={setServiceUrl}
+          onOpenDialog={onPressSelectService}
+        />
+      </View>
+      <View>
+        <TextField.Label>
+          <Trans>Account</Trans>
+        </TextField.Label>
+        <View style={[a.gap_sm]}>
+          <TextField.Root>
+            <TextField.Icon icon={At} />
+            <TextField.Input
+              testID="loginUsernameInput"
+              label={_(msg`Username or email address`)}
+              autoCapitalize="none"
+              autoFocus
+              autoCorrect={false}
+              autoComplete="username"
+              returnKeyType="next"
+              textContentType="username"
+              onSubmitEditing={() => {
+                passwordInputRef.current?.focus()
+              }}
+              blurOnSubmit={false} // prevents flickering due to onSubmitEditing going to next field
+              value={identifier}
+              onChangeText={str =>
+                setIdentifier((str || '').toLowerCase().trim())
+              }
+              editable={!isProcessing}
+              accessibilityHint={_(
+                msg`Input the username or email address you used at signup`,
+              )}
+            />
+          </TextField.Root>
+
+          <TextField.Root>
+            <TextField.Icon icon={Lock} />
+            <TextField.Input
+              testID="loginPasswordInput"
+              inputRef={passwordInputRef}
+              label={_(msg`Password`)}
+              autoCapitalize="none"
+              autoCorrect={false}
+              autoComplete="password"
+              returnKeyType="done"
+              enablesReturnKeyAutomatically={true}
+              secureTextEntry={true}
+              textContentType="password"
+              clearButtonMode="while-editing"
+              value={password}
+              onChangeText={setPassword}
+              onSubmitEditing={onPressNext}
+              blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
+              editable={!isProcessing}
+              accessibilityHint={
+                identifier === ''
+                  ? _(msg`Input your password`)
+                  : _(msg`Input the password tied to ${identifier}`)
+              }
+            />
+            <Button
+              testID="forgotPasswordButton"
+              onPress={onPressForgotPassword}
+              label={_(msg`Forgot password?`)}
+              accessibilityHint={_(msg`Opens password reset form`)}
+              variant="solid"
+              color="secondary"
+              style={[
+                a.rounded_sm,
+                // t.atoms.bg_contrast_100,
+                {marginLeft: 'auto', left: 6, padding: 6},
+                a.z_10,
+              ]}>
+              <ButtonText>
+                <Trans>Forgot?</Trans>
+              </ButtonText>
+            </Button>
+          </TextField.Root>
+        </View>
+      </View>
+      <FormError error={error} />
+      <View style={[a.flex_row, a.align_center, a.pt_md]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="medium"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Back</Trans>
+          </ButtonText>
+        </Button>
+        <View style={a.flex_1} />
+        {!serviceDescription && error ? (
+          <Button
+            testID="loginRetryButton"
+            label={_(msg`Retry`)}
+            accessibilityHint={_(msg`Retries login`)}
+            variant="solid"
+            color="secondary"
+            size="medium"
+            onPress={onPressRetryConnect}>
+            {_(msg`Retry`)}
+          </Button>
+        ) : !serviceDescription ? (
+          <>
+            <ActivityIndicator />
+            <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+              <Trans>Connecting...</Trans>
+            </Text>
+          </>
+        ) : isReady ? (
+          <Button
+            label={_(msg`Next`)}
+            accessibilityHint={_(msg`Navigates to the next screen`)}
+            variant="solid"
+            color="primary"
+            size="medium"
+            onPress={onPressNext}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+            {isProcessing && <ButtonIcon icon={Loader} />}
+          </Button>
+        ) : undefined}
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/PasswordUpdatedForm.tsx b/src/screens/Login/PasswordUpdatedForm.tsx
new file mode 100644
index 000000000..5407f3f1e
--- /dev/null
+++ b/src/screens/Login/PasswordUpdatedForm.tsx
@@ -0,0 +1,50 @@
+import React, {useEffect} from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+export const PasswordUpdatedForm = ({
+  onPressNext,
+}: {
+  onPressNext: () => void
+}) => {
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  useEffect(() => {
+    screen('Signin:PasswordUpdatedForm')
+  }, [screen])
+
+  return (
+    <FormContainer
+      testID="passwordUpdatedForm"
+      style={[a.gap_2xl, !gtMobile && a.mt_5xl]}>
+      <Text style={[a.text_3xl, a.font_bold, a.text_center]}>
+        <Trans>Password updated!</Trans>
+      </Text>
+      <Text style={[a.text_center, a.mx_auto, {maxWidth: '80%'}]}>
+        <Trans>You can now sign in with your new password.</Trans>
+      </Text>
+      <View style={[a.flex_row, a.justify_center]}>
+        <Button
+          onPress={onPressNext}
+          label={_(msg`Close alert`)}
+          accessibilityHint={_(msg`Closes password update alert`)}
+          variant="solid"
+          color="primary"
+          size="medium">
+          <ButtonText>
+            <Trans>Okay</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/ScreenTransition.tsx b/src/screens/Login/ScreenTransition.tsx
new file mode 100644
index 000000000..ab0a22367
--- /dev/null
+++ b/src/screens/Login/ScreenTransition.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import Animated, {FadeInRight, FadeOutLeft} from 'react-native-reanimated'
+
+export function ScreenTransition({children}: {children: React.ReactNode}) {
+  return (
+    <Animated.View entering={FadeInRight} exiting={FadeOutLeft}>
+      {children}
+    </Animated.View>
+  )
+}
diff --git a/src/screens/Login/ScreenTransition.web.tsx b/src/screens/Login/ScreenTransition.web.tsx
new file mode 100644
index 000000000..4583720aa
--- /dev/null
+++ b/src/screens/Login/ScreenTransition.web.tsx
@@ -0,0 +1 @@
+export {Fragment as ScreenTransition} from 'react'
diff --git a/src/screens/Login/SetNewPasswordForm.tsx b/src/screens/Login/SetNewPasswordForm.tsx
new file mode 100644
index 000000000..e7b488655
--- /dev/null
+++ b/src/screens/Login/SetNewPasswordForm.tsx
@@ -0,0 +1,192 @@
+import React, {useEffect, 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'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {isNetworkError} from '#/lib/strings/errors'
+import {cleanError} from '#/lib/strings/errors'
+import {checkAndFormatResetCode} from '#/lib/strings/password'
+import {logger} from '#/logger'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {FormError} from '#/components/forms/FormError'
+import * as TextField from '#/components/forms/TextField'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {Text} from '#/components/Typography'
+import {FormContainer} from './FormContainer'
+
+export const SetNewPasswordForm = ({
+  error,
+  serviceUrl,
+  setError,
+  onPressBack,
+  onPasswordSet,
+}: {
+  error: string
+  serviceUrl: string
+  setError: (v: string) => void
+  onPressBack: () => void
+  onPasswordSet: () => void
+}) => {
+  const {screen} = useAnalytics()
+  const {_} = useLingui()
+  const t = useTheme()
+
+  useEffect(() => {
+    screen('Signin:SetNewPasswordForm')
+  }, [screen])
+
+  const [isProcessing, setIsProcessing] = useState<boolean>(false)
+  const [resetCode, setResetCode] = useState<string>('')
+  const [password, setPassword] = useState<string>('')
+
+  const onPressNext = async () => {
+    // Check that the code is correct. We do this again just incase the user enters the code after their pw and we
+    // don't get to call onBlur first
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    // TODO Better password strength check
+    if (!formattedCode || !password) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+
+    setError('')
+    setIsProcessing(true)
+
+    try {
+      const agent = new BskyAgent({service: serviceUrl})
+      await agent.com.atproto.server.resetPassword({
+        token: formattedCode,
+        password,
+      })
+      onPasswordSet()
+    } catch (e: any) {
+      const errMsg = e.toString()
+      logger.warn('Failed to set new password', {error: e})
+      setIsProcessing(false)
+      if (isNetworkError(e)) {
+        setError(
+          _(
+            msg`Unable to contact your service. Please check your Internet connection.`,
+          ),
+        )
+      } else {
+        setError(cleanError(errMsg))
+      }
+    }
+  }
+
+  const onBlur = () => {
+    const formattedCode = checkAndFormatResetCode(resetCode)
+    if (!formattedCode) {
+      setError(
+        _(
+          msg`You have entered an invalid code. It should look like XXXXX-XXXXX.`,
+        ),
+      )
+      return
+    }
+    setResetCode(formattedCode)
+  }
+
+  return (
+    <FormContainer
+      testID="setNewPasswordForm"
+      title={<Trans>Set new password</Trans>}>
+      <Text style={[a.leading_snug, a.mb_sm]}>
+        <Trans>
+          You will receive an email with a "reset code." Enter that code here,
+          then enter your new password.
+        </Trans>
+      </Text>
+
+      <View>
+        <TextField.Label>Reset code</TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={Ticket} />
+          <TextField.Input
+            testID="resetCodeInput"
+            label={_(msg`Looks like XXXXX-XXXXX`)}
+            autoCapitalize="none"
+            autoFocus={true}
+            autoCorrect={false}
+            autoComplete="off"
+            value={resetCode}
+            onChangeText={setResetCode}
+            onFocus={() => setError('')}
+            onBlur={onBlur}
+            editable={!isProcessing}
+            accessibilityHint={_(
+              msg`Input code sent to your email for password reset`,
+            )}
+          />
+        </TextField.Root>
+      </View>
+
+      <View>
+        <TextField.Label>New password</TextField.Label>
+        <TextField.Root>
+          <TextField.Icon icon={Lock} />
+          <TextField.Input
+            testID="newPasswordInput"
+            label={_(msg`Enter a password`)}
+            autoCapitalize="none"
+            autoCorrect={false}
+            autoComplete="password"
+            returnKeyType="done"
+            secureTextEntry={true}
+            textContentType="password"
+            clearButtonMode="while-editing"
+            value={password}
+            onChangeText={setPassword}
+            onSubmitEditing={onPressNext}
+            editable={!isProcessing}
+            accessibilityHint={_(msg`Input new password`)}
+          />
+        </TextField.Root>
+      </View>
+
+      <FormError error={error} />
+
+      <View style={[a.flex_row, a.align_center, a.pt_lg]}>
+        <Button
+          label={_(msg`Back`)}
+          variant="solid"
+          color="secondary"
+          size="medium"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Back</Trans>
+          </ButtonText>
+        </Button>
+        <View style={a.flex_1} />
+        {isProcessing ? (
+          <ActivityIndicator />
+        ) : (
+          <Button
+            label={_(msg`Next`)}
+            variant="solid"
+            color="primary"
+            size="medium"
+            onPress={onPressNext}>
+            <ButtonText>
+              <Trans>Next</Trans>
+            </ButtonText>
+          </Button>
+        )}
+        {isProcessing ? (
+          <Text style={[t.atoms.text_contrast_high, a.pl_md]}>
+            <Trans>Updating...</Trans>
+          </Text>
+        ) : undefined}
+      </View>
+    </FormContainer>
+  )
+}
diff --git a/src/screens/Login/index.tsx b/src/screens/Login/index.tsx
new file mode 100644
index 000000000..1fce63d29
--- /dev/null
+++ b/src/screens/Login/index.tsx
@@ -0,0 +1,178 @@
+import React from 'react'
+import {KeyboardAvoidingView} from 'react-native'
+import {LayoutAnimationConfig} from 'react-native-reanimated'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {DEFAULT_SERVICE} from '#/lib/constants'
+import {logger} from '#/logger'
+import {useServiceQuery} from '#/state/queries/service'
+import {SessionAccount, useSession} from '#/state/session'
+import {useLoggedOutView} from '#/state/shell/logged-out'
+import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
+import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
+import {LoginForm} from '#/screens/Login/LoginForm'
+import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm'
+import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm'
+import {atoms as a} from '#/alf'
+import {ChooseAccountForm} from './ChooseAccountForm'
+import {ScreenTransition} from './ScreenTransition'
+
+enum Forms {
+  Login,
+  ChooseAccount,
+  ForgotPassword,
+  SetNewPassword,
+  PasswordUpdated,
+}
+
+export const Login = ({onPressBack}: {onPressBack: () => void}) => {
+  const {_} = useLingui()
+
+  const {accounts} = useSession()
+  const {track} = useAnalytics()
+  const {requestedAccountSwitchTo} = useLoggedOutView()
+  const requestedAccount = accounts.find(
+    acc => acc.did === requestedAccountSwitchTo,
+  )
+
+  const [error, setError] = React.useState<string>('')
+  const [serviceUrl, setServiceUrl] = React.useState<string>(
+    requestedAccount?.service || DEFAULT_SERVICE,
+  )
+  const [initialHandle, setInitialHandle] = React.useState<string>(
+    requestedAccount?.handle || '',
+  )
+  const [currentForm, setCurrentForm] = React.useState<Forms>(
+    requestedAccount
+      ? Forms.Login
+      : accounts.length
+      ? Forms.ChooseAccount
+      : Forms.Login,
+  )
+
+  const {
+    data: serviceDescription,
+    error: serviceError,
+    refetch: refetchService,
+  } = useServiceQuery(serviceUrl)
+
+  const onSelectAccount = (account?: SessionAccount) => {
+    if (account?.service) {
+      setServiceUrl(account.service)
+    }
+    setInitialHandle(account?.handle || '')
+    setCurrentForm(Forms.Login)
+  }
+
+  const gotoForm = (form: Forms) => {
+    setError('')
+    setCurrentForm(form)
+  }
+
+  React.useEffect(() => {
+    if (serviceError) {
+      setError(
+        _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      )
+      logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
+        error: String(serviceError),
+      })
+    } else {
+      setError('')
+    }
+  }, [serviceError, serviceUrl, _])
+
+  const onPressForgotPassword = () => {
+    track('Signin:PressedForgotPassword')
+    setCurrentForm(Forms.ForgotPassword)
+  }
+
+  let content = null
+  let title = ''
+  let description = ''
+
+  switch (currentForm) {
+    case Forms.Login:
+      title = _(msg`Sign in`)
+      description = _(msg`Enter your username and password`)
+      content = (
+        <LoginForm
+          error={error}
+          serviceUrl={serviceUrl}
+          serviceDescription={serviceDescription}
+          initialHandle={initialHandle}
+          setError={setError}
+          setServiceUrl={setServiceUrl}
+          onPressBack={() =>
+            accounts.length ? gotoForm(Forms.ChooseAccount) : onPressBack()
+          }
+          onPressForgotPassword={onPressForgotPassword}
+          onPressRetryConnect={refetchService}
+        />
+      )
+      break
+    case Forms.ChooseAccount:
+      title = _(msg`Sign in`)
+      description = _(msg`Select from an existing account`)
+      content = (
+        <ChooseAccountForm
+          onSelectAccount={onSelectAccount}
+          onPressBack={onPressBack}
+        />
+      )
+      break
+    case Forms.ForgotPassword:
+      title = _(msg`Forgot Password`)
+      description = _(msg`Let's get your password reset!`)
+      content = (
+        <ForgotPasswordForm
+          error={error}
+          serviceUrl={serviceUrl}
+          serviceDescription={serviceDescription}
+          setError={setError}
+          setServiceUrl={setServiceUrl}
+          onPressBack={() => gotoForm(Forms.Login)}
+          onEmailSent={() => gotoForm(Forms.SetNewPassword)}
+        />
+      )
+      break
+    case Forms.SetNewPassword:
+      title = _(msg`Forgot Password`)
+      description = _(msg`Let's get your password reset!`)
+      content = (
+        <SetNewPasswordForm
+          error={error}
+          serviceUrl={serviceUrl}
+          setError={setError}
+          onPressBack={() => gotoForm(Forms.ForgotPassword)}
+          onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
+        />
+      )
+      break
+    case Forms.PasswordUpdated:
+      title = _(msg`Password updated`)
+      description = _(msg`You can now sign in with your new password.`)
+      content = (
+        <PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
+      )
+      break
+  }
+
+  return (
+    <KeyboardAvoidingView testID="signIn" behavior="padding" style={a.flex_1}>
+      <LoggedOutLayout
+        leadin=""
+        title={title}
+        description={description}
+        scrollable>
+        <LayoutAnimationConfig skipEntering skipExiting>
+          <ScreenTransition key={currentForm}>{content}</ScreenTransition>
+        </LayoutAnimationConfig>
+      </LoggedOutLayout>
+    </KeyboardAvoidingView>
+  )
+}
diff --git a/src/screens/Moderation/index.tsx b/src/screens/Moderation/index.tsx
new file mode 100644
index 000000000..9d51a6197
--- /dev/null
+++ b/src/screens/Moderation/index.tsx
@@ -0,0 +1,554 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+import {ComAtprotoLabelDefs} from '@atproto/api'
+import {LABELS} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {getLabelingServiceTitle} from '#/lib/moderation'
+import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {logger} from '#/logger'
+import {
+  useMyLabelersQuery,
+  usePreferencesQuery,
+  UsePreferencesQueryResponse,
+  usePreferencesSetAdultContentMutation,
+} from '#/state/queries/preferences'
+import {
+  useProfileQuery,
+  useProfileUpdateMutation,
+} from '#/state/queries/profile'
+import {useSession} from '#/state/session'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {CenteredView} from '#/view/com/util/Views'
+import {ScrollView} from '#/view/com/util/Views'
+import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
+import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
+import {Props as SVGIconProps} from '#/components/icons/common'
+import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
+import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
+import * as LabelingService from '#/components/LabelingServiceCard'
+import {InlineLink, Link} from '#/components/Link'
+import {Loader} from '#/components/Loader'
+import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
+import {Text} from '#/components/Typography'
+
+function ErrorState({error}: {error: string}) {
+  const t = useTheme()
+  return (
+    <View style={[a.p_xl]}>
+      <Text
+        style={[
+          a.text_md,
+          a.leading_normal,
+          a.pb_md,
+          t.atoms.text_contrast_medium,
+        ]}>
+        <Trans>
+          Hmmmm, it seems we're having trouble loading this data. See below for
+          more details. If this issue persists, please contact us.
+        </Trans>
+      </Text>
+      <View
+        style={[
+          a.relative,
+          a.py_md,
+          a.px_lg,
+          a.rounded_md,
+          a.mb_2xl,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text style={[a.text_md, a.leading_normal]}>{error}</Text>
+      </View>
+    </View>
+  )
+}
+
+export function ModerationScreen(
+  _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
+) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {
+    isLoading: isPreferencesLoading,
+    error: preferencesError,
+    data: preferences,
+  } = usePreferencesQuery()
+  const {gtMobile} = useBreakpoints()
+  const {height} = useSafeAreaFrame()
+
+  const isLoading = isPreferencesLoading
+  const error = preferencesError
+
+  return (
+    <CenteredView
+      testID="moderationScreen"
+      style={[
+        t.atoms.border_contrast_low,
+        t.atoms.bg,
+        {minHeight: height},
+        ...(gtMobile ? [a.border_l, a.border_r] : []),
+      ]}>
+      <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
+
+      {isLoading ? (
+        <View style={[a.w_full, a.align_center, a.pt_2xl]}>
+          <Loader size="xl" fill={t.atoms.text.color} />
+        </View>
+      ) : error || !preferences ? (
+        <ErrorState
+          error={
+            preferencesError?.toString() ||
+            _(msg`Something went wrong, please try again.`)
+          }
+        />
+      ) : (
+        <ModerationScreenInner preferences={preferences} />
+      )}
+    </CenteredView>
+  )
+}
+
+function SubItem({
+  title,
+  icon: Icon,
+  style,
+}: ViewStyleProp & {
+  title: string
+  icon: React.ComponentType<SVGIconProps>
+}) {
+  const t = useTheme()
+  return (
+    <View
+      style={[
+        a.w_full,
+        a.flex_row,
+        a.align_center,
+        a.justify_between,
+        a.p_lg,
+        a.gap_sm,
+        style,
+      ]}>
+      <View style={[a.flex_row, a.align_center, a.gap_md]}>
+        <Icon size="md" style={[t.atoms.text_contrast_medium]} />
+        <Text style={[a.text_sm, a.font_bold]}>{title}</Text>
+      </View>
+      <ChevronRight
+        size="sm"
+        style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
+      />
+    </View>
+  )
+}
+
+export function ModerationScreenInner({
+  preferences,
+}: {
+  preferences: UsePreferencesQueryResponse
+}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {screen} = useAnalytics()
+  const {gtMobile} = useBreakpoints()
+  const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
+  const birthdateDialogControl = Dialog.useDialogControl()
+  const {
+    isLoading: isLabelersLoading,
+    data: labelers,
+    error: labelersError,
+  } = useMyLabelersQuery()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      screen('Moderation')
+      setMinimalShellMode(false)
+    }, [screen, setMinimalShellMode]),
+  )
+
+  const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
+    usePreferencesSetAdultContentMutation()
+  const adultContentEnabled = !!(
+    (optimisticAdultContent && optimisticAdultContent.enabled) ||
+    (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
+  )
+  const ageNotSet = !preferences.userAge
+  const isUnderage = (preferences.userAge || 0) < 18
+
+  const onToggleAdultContentEnabled = React.useCallback(
+    async (selected: boolean) => {
+      try {
+        await setAdultContentPref({
+          enabled: selected,
+        })
+      } catch (e: any) {
+        logger.error(`Failed to set adult content pref`, {
+          message: e.message,
+        })
+      }
+    },
+    [setAdultContentPref],
+  )
+
+  return (
+    <ScrollView
+      contentContainerStyle={[
+        a.border_0,
+        a.pt_2xl,
+        a.px_lg,
+        gtMobile && a.px_2xl,
+      ]}>
+      <Text
+        style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
+        <Trans>Moderation tools</Trans>
+      </Text>
+
+      <View
+        style={[
+          a.w_full,
+          a.rounded_md,
+          a.overflow_hidden,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Button
+          testID="mutedWordsBtn"
+          label={_(msg`Open muted words and tags settings`)}
+          onPress={() => mutedWordsDialogControl.open()}>
+          {state => (
+            <SubItem
+              title={_(msg`Muted words & tags`)}
+              icon={Filter}
+              style={[
+                (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+              ]}
+            />
+          )}
+        </Button>
+        <Divider />
+        <Link testID="moderationlistsBtn" to="/moderation/modlists">
+          {state => (
+            <SubItem
+              title={_(msg`Moderation lists`)}
+              icon={Group}
+              style={[
+                (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+              ]}
+            />
+          )}
+        </Link>
+        <Divider />
+        <Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
+          {state => (
+            <SubItem
+              title={_(msg`Muted accounts`)}
+              icon={Person}
+              style={[
+                (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+              ]}
+            />
+          )}
+        </Link>
+        <Divider />
+        <Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
+          {state => (
+            <SubItem
+              title={_(msg`Blocked accounts`)}
+              icon={CircleBanSign}
+              style={[
+                (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
+              ]}
+            />
+          )}
+        </Link>
+      </View>
+
+      <Text
+        style={[
+          a.pt_2xl,
+          a.pb_md,
+          a.text_md,
+          a.font_bold,
+          t.atoms.text_contrast_high,
+        ]}>
+        <Trans>Content filters</Trans>
+      </Text>
+
+      <View style={[a.gap_md]}>
+        {ageNotSet && (
+          <>
+            <Button
+              label={_(msg`Confirm your birthdate`)}
+              size="small"
+              variant="solid"
+              color="secondary"
+              onPress={() => {
+                birthdateDialogControl.open()
+              }}
+              style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
+              <ButtonText>
+                <Trans>Confirm your age:</Trans>
+              </ButtonText>
+              <ButtonText>
+                <Trans>Set birthdate</Trans>
+              </ButtonText>
+            </Button>
+
+            <BirthDateSettingsDialog control={birthdateDialogControl} />
+          </>
+        )}
+        <View
+          style={[
+            a.w_full,
+            a.rounded_md,
+            a.overflow_hidden,
+            t.atoms.bg_contrast_25,
+          ]}>
+          {!ageNotSet && !isUnderage && (
+            <>
+              <View
+                style={[
+                  a.py_lg,
+                  a.px_lg,
+                  a.flex_row,
+                  a.align_center,
+                  a.justify_between,
+                ]}>
+                <Text style={[a.font_semibold, t.atoms.text_contrast_high]}>
+                  <Trans>Enable adult content</Trans>
+                </Text>
+                <Toggle.Item
+                  label={_(msg`Toggle to enable or disable adult content`)}
+                  name="adultContent"
+                  value={adultContentEnabled}
+                  onChange={onToggleAdultContentEnabled}>
+                  <View style={[a.flex_row, a.align_center, a.gap_sm]}>
+                    <Text style={[t.atoms.text_contrast_medium]}>
+                      {adultContentEnabled ? (
+                        <Trans>Enabled</Trans>
+                      ) : (
+                        <Trans>Disabled</Trans>
+                      )}
+                    </Text>
+                    <Toggle.Switch />
+                  </View>
+                </Toggle.Item>
+              </View>
+              <Divider />
+            </>
+          )}
+          {!isUnderage && adultContentEnabled && (
+            <>
+              <GlobalLabelPreference labelDefinition={LABELS.porn} />
+              <Divider />
+              <GlobalLabelPreference labelDefinition={LABELS.sexual} />
+              <Divider />
+              <GlobalLabelPreference
+                labelDefinition={LABELS['graphic-media']}
+              />
+              <Divider />
+            </>
+          )}
+          <GlobalLabelPreference labelDefinition={LABELS.nudity} />
+        </View>
+      </View>
+
+      <Text
+        style={[
+          a.text_md,
+          a.font_bold,
+          a.pt_2xl,
+          a.pb_md,
+          t.atoms.text_contrast_high,
+        ]}>
+        <Trans>Advanced</Trans>
+      </Text>
+
+      {isLabelersLoading ? (
+        <View style={[a.w_full, a.align_center, a.p_lg]}>
+          <Loader size="xl" />
+        </View>
+      ) : labelersError || !labelers ? (
+        <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
+          <Text>
+            <Trans>
+              We were unable to load your configured labelers at this time.
+            </Trans>
+          </Text>
+        </View>
+      ) : (
+        <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}>
+          {labelers.map((labeler, i) => {
+            return (
+              <React.Fragment key={labeler.creator.did}>
+                {i !== 0 && <Divider />}
+                <LabelingService.Link labeler={labeler}>
+                  {state => (
+                    <LabelingService.Outer
+                      style={[
+                        i === 0 && {
+                          borderTopLeftRadius: a.rounded_sm.borderRadius,
+                          borderTopRightRadius: a.rounded_sm.borderRadius,
+                        },
+                        i === labelers.length - 1 && {
+                          borderBottomLeftRadius: a.rounded_sm.borderRadius,
+                          borderBottomRightRadius: a.rounded_sm.borderRadius,
+                        },
+                        (state.hovered || state.pressed) && [
+                          t.atoms.bg_contrast_50,
+                        ],
+                      ]}>
+                      <LabelingService.Avatar avatar={labeler.creator.avatar} />
+                      <LabelingService.Content>
+                        <LabelingService.Title
+                          value={getLabelingServiceTitle({
+                            displayName: labeler.creator.displayName,
+                            handle: labeler.creator.handle,
+                          })}
+                        />
+                        <LabelingService.Description
+                          value={labeler.creator.description}
+                          handle={labeler.creator.handle}
+                        />
+                      </LabelingService.Content>
+                    </LabelingService.Outer>
+                  )}
+                </LabelingService.Link>
+              </React.Fragment>
+            )
+          })}
+        </View>
+      )}
+
+      <Text
+        style={[
+          a.text_md,
+          a.font_bold,
+          a.pt_2xl,
+          a.pb_md,
+          t.atoms.text_contrast_high,
+        ]}>
+        <Trans>Logged-out visibility</Trans>
+      </Text>
+
+      <PwiOptOut />
+
+      <View style={{height: 200}} />
+    </ScrollView>
+  )
+}
+
+function PwiOptOut() {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount} = useSession()
+  const {data: profile} = useProfileQuery({did: currentAccount?.did})
+  const updateProfile = useProfileUpdateMutation()
+
+  const isOptedOut =
+    profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
+  const canToggle = profile && !updateProfile.isPending
+
+  const onToggleOptOut = React.useCallback(() => {
+    if (!profile) {
+      return
+    }
+    let wasAdded = false
+    updateProfile.mutate({
+      profile,
+      updates: existing => {
+        // create labels attr if needed
+        existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
+          ? existing.labels
+          : {
+              $type: 'com.atproto.label.defs#selfLabels',
+              values: [],
+            }
+
+        // toggle the label
+        const hasLabel = existing.labels.values.some(
+          l => l.val === '!no-unauthenticated',
+        )
+        if (hasLabel) {
+          wasAdded = false
+          existing.labels.values = existing.labels.values.filter(
+            l => l.val !== '!no-unauthenticated',
+          )
+        } else {
+          wasAdded = true
+          existing.labels.values.push({val: '!no-unauthenticated'})
+        }
+
+        // delete if no longer needed
+        if (existing.labels.values.length === 0) {
+          delete existing.labels
+        }
+        return existing
+      },
+      checkCommitted: res => {
+        const exists = !!res.data.labels?.some(
+          l => l.val === '!no-unauthenticated',
+        )
+        return exists === wasAdded
+      },
+    })
+  }, [updateProfile, profile])
+
+  return (
+    <View style={[a.pt_sm]}>
+      <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
+        <Toggle.Item
+          disabled={!canToggle}
+          value={isOptedOut}
+          onChange={onToggleOptOut}
+          name="logged_out_visibility"
+          style={a.flex_1}
+          label={_(
+            msg`Discourage apps from showing my account to logged-out users`,
+          )}>
+          <Toggle.Switch />
+          <Toggle.Label style={[a.text_md, a.flex_1]}>
+            <Trans>
+              Discourage apps from showing my account to logged-out users
+            </Trans>
+          </Toggle.Label>
+        </Toggle.Item>
+
+        {updateProfile.isPending && <Loader />}
+      </View>
+
+      <View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
+        <Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
+          <Trans>
+            Bluesky will not show your profile and posts to logged-out users.
+            Other apps may not honor this request. This does not make your
+            account private.
+          </Trans>
+        </Text>
+        <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+          <Trans>
+            Note: Bluesky is an open and public network. This setting only
+            limits the visibility of your content on the Bluesky app and
+            website, and other apps may not respect this setting. Your content
+            may still be shown to logged-out users by other apps and websites.
+          </Trans>
+        </Text>
+
+        <InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
+          <Trans>Learn more about what is public on Bluesky.</Trans>
+        </InlineLink>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Onboarding/IconCircle.tsx b/src/screens/Onboarding/IconCircle.tsx
deleted file mode 100644
index a54c8b4e4..000000000
--- a/src/screens/Onboarding/IconCircle.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react'
-import {View} from 'react-native'
-
-import {
-  useTheme,
-  atoms as a,
-  ViewStyleProp,
-  TextStyleProp,
-  flatten,
-} from '#/alf'
-import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
-import {Props} from '#/components/icons/common'
-
-export function IconCircle({
-  icon: Icon,
-  size = 'xl',
-  style,
-  iconStyle,
-}: ViewStyleProp & {
-  icon: typeof Growth
-  size?: Props['size']
-  iconStyle?: TextStyleProp['style']
-}) {
-  const t = useTheme()
-
-  return (
-    <View
-      style={[
-        a.justify_center,
-        a.align_center,
-        a.rounded_full,
-        {
-          width: 64,
-          height: 64,
-          backgroundColor:
-            t.name === 'light' ? t.palette.primary_50 : t.palette.primary_950,
-        },
-        flatten(style),
-      ]}>
-      <Icon
-        size={size}
-        style={[
-          {
-            color: t.palette.primary_500,
-          },
-          flatten(iconStyle),
-        ]}
-      />
-    </View>
-  )
-}
diff --git a/src/screens/Onboarding/Layout.tsx b/src/screens/Onboarding/Layout.tsx
index d887c0820..6337cee09 100644
--- a/src/screens/Onboarding/Layout.tsx
+++ b/src/screens/Onboarding/Layout.tsx
@@ -17,7 +17,7 @@ import {
   flatten,
   TextStyleProp,
 } from '#/alf'
-import {H2, P, leading} from '#/components/Typography'
+import {P, leading, Text} from '#/components/Typography'
 import {ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft} from '#/components/icons/Chevron'
 import {Button, ButtonIcon} from '#/components/Button'
 import {ScrollView} from '#/view/com/util/Views'
@@ -209,16 +209,18 @@ export function Title({
   style,
 }: React.PropsWithChildren<TextStyleProp>) {
   return (
-    <H2
+    <Text
       style={[
         a.pb_sm,
+        a.text_4xl,
+        a.font_bold,
         {
           lineHeight: leading(a.text_4xl, a.leading_tight),
         },
         flatten(style),
       ]}>
       {children}
-    </H2>
+    </Text>
   )
 }
 
diff --git a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
index dec53d2ed..1123f2675 100644
--- a/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
+++ b/src/screens/Onboarding/StepAlgoFeeds/FeedCard.tsx
@@ -238,13 +238,14 @@ function FeedCardInner({feed}: {feed: FeedSourceInfo; config: FeedConfig}) {
           />
         </View>
 
-        <View style={[a.pt_2xs, a.flex_grow]}>
+        <View style={[a.pt_2xs, a.flex_1, a.flex_grow]}>
           <Text
             style={[
               a.text_md,
               a.font_bold,
               ctx.selected && styles.textSelected,
-            ]}>
+            ]}
+            numberOfLines={1}>
             {feed.displayName}
           </Text>
           <Text
diff --git a/src/screens/Onboarding/StepAlgoFeeds/index.tsx b/src/screens/Onboarding/StepAlgoFeeds/index.tsx
index 33e519207..35f525ef2 100644
--- a/src/screens/Onboarding/StepAlgoFeeds/index.tsx
+++ b/src/screens/Onboarding/StepAlgoFeeds/index.tsx
@@ -1,26 +1,26 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {IS_PROD} from '#/env'
-import {atoms as a, tokens, useTheme} from '#/alf'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import * as Toggle from '#/components/forms/Toggle'
-import {Text} from '#/components/Typography'
-import {Loader} from '#/components/Loader'
-import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
 import {useAnalytics} from '#/lib/analytics/analytics'
-
-import {Context} from '#/screens/Onboarding/state'
+import {logEvent} from '#/lib/statsig/statsig'
 import {
-  Title,
   Description,
   OnboardingControls,
+  Title,
 } from '#/screens/Onboarding/Layout'
+import {Context} from '#/screens/Onboarding/state'
 import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {IconCircle} from '#/components/IconCircle'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {IS_PROD} from '#/env'
 
 export type FeedConfig = {
   default: boolean
@@ -89,6 +89,12 @@ export function StepAlgoFeeds() {
       selectedSecondaryFeeds: secondaryFeedUris,
       selectedSecondaryFeedsLength: secondaryFeedUris.length,
     })
+    logEvent('onboarding:algoFeeds:nextPressed', {
+      selectedPrimaryFeeds: primaryFeedUris,
+      selectedPrimaryFeedsLength: primaryFeedUris.length,
+      selectedSecondaryFeeds: secondaryFeedUris,
+      selectedSecondaryFeedsLength: secondaryFeedUris.length,
+    })
   }, [primaryFeedUris, secondaryFeedUris, dispatch, track])
 
   React.useEffect(() => {
diff --git a/src/screens/Onboarding/StepFinished.tsx b/src/screens/Onboarding/StepFinished.tsx
index 72d53658b..0c81d2d25 100644
--- a/src/screens/Onboarding/StepFinished.tsx
+++ b/src/screens/Onboarding/StepFinished.tsx
@@ -1,33 +1,33 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
 import {logger} from '#/logger'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText, ButtonIcon} from '#/components/Button'
-import {News2_Stroke2_Corner0_Rounded as News} from '#/components/icons/News2'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {Growth_Stroke2_Corner0_Rounded as Growth} from '#/components/icons/Growth'
-import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
-import {Text} from '#/components/Typography'
-import {useOnboardingDispatch} from '#/state/shell'
-import {Loader} from '#/components/Loader'
 import {useSetSaveFeedsMutation} from '#/state/queries/preferences'
 import {getAgent} from '#/state/session'
-import {useAnalytics} from '#/lib/analytics/analytics'
-
-import {Context} from '#/screens/Onboarding/state'
+import {useOnboardingDispatch} from '#/state/shell'
 import {
-  Title,
   Description,
   OnboardingControls,
+  Title,
 } from '#/screens/Onboarding/Layout'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {Context} from '#/screens/Onboarding/state'
 import {
   bulkWriteFollows,
   sortPrimaryAlgorithmFeeds,
 } from '#/screens/Onboarding/util'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {IconCircle} from '#/components/IconCircle'
+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'
+import {Trending2_Stroke2_Corner2_Rounded as Trending} from '#/components/icons/Trending2'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
 
 export function StepFinished() {
   const {_} = useLingui()
@@ -76,6 +76,7 @@ export function StepFinished() {
     onboardDispatch({type: 'finish'})
     track('OnboardingV2:StepFinished:End')
     track('OnboardingV2:Complete')
+    logEvent('onboarding:finished:nextPressed', {})
   }, [state, dispatch, onboardDispatch, setSaving, saveFeeds, track])
 
   React.useEffect(() => {
diff --git a/src/screens/Onboarding/StepFollowingFeed.tsx b/src/screens/Onboarding/StepFollowingFeed.tsx
index 114e274b6..e886a0891 100644
--- a/src/screens/Onboarding/StepFollowingFeed.tsx
+++ b/src/screens/Onboarding/StepFollowingFeed.tsx
@@ -1,28 +1,28 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {atoms as a} from '#/alf'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {Text} from '#/components/Typography'
-import {Divider} from '#/components/Divider'
-import * as Toggle from '#/components/forms/Toggle'
 import {useAnalytics} from '#/lib/analytics/analytics'
-
-import {Context} from '#/screens/Onboarding/state'
-import {
-  Title,
-  Description,
-  OnboardingControls,
-} from '#/screens/Onboarding/Layout'
+import {logEvent} from '#/lib/statsig/statsig'
 import {
   usePreferencesQuery,
   useSetFeedViewPreferencesMutation,
 } from 'state/queries/preferences'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {
+  Description,
+  OnboardingControls,
+  Title,
+} from '#/screens/Onboarding/Layout'
+import {Context} from '#/screens/Onboarding/state'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import {IconCircle} from '#/components/IconCircle'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {FilterTimeline_Stroke2_Corner0_Rounded as FilterTimeline} from '#/components/icons/FilterTimeline'
+import {Text} from '#/components/Typography'
 
 export function StepFollowingFeed() {
   const {_} = useLingui()
@@ -46,6 +46,7 @@ export function StepFollowingFeed() {
   const onContinue = React.useCallback(() => {
     dispatch({type: 'next'})
     track('OnboardingV2:StepFollowingFeed:End')
+    logEvent('onboarding:followingFeed:nextPressed', {})
   }, [track, dispatch])
 
   React.useEffect(() => {
diff --git a/src/screens/Onboarding/StepInterests/index.tsx b/src/screens/Onboarding/StepInterests/index.tsx
index 4eaf0366e..8f34cced9 100644
--- a/src/screens/Onboarding/StepInterests/index.tsx
+++ b/src/screens/Onboarding/StepInterests/index.tsx
@@ -1,32 +1,32 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {useQuery} from '@tanstack/react-query'
 
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
+import {capitalize} from '#/lib/strings/capitalize'
 import {logger} from '#/logger'
-import {atoms as a, useBreakpoints, useTheme} from '#/alf'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
-import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
-import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {Loader} from '#/components/Loader'
-import * as Toggle from '#/components/forms/Toggle'
 import {getAgent} from '#/state/session'
-import {useAnalytics} from '#/lib/analytics/analytics'
-import {Text} from '#/components/Typography'
 import {useOnboardingDispatch} from '#/state/shell'
-import {capitalize} from '#/lib/strings/capitalize'
-
-import {Context, ApiResponseMap} from '#/screens/Onboarding/state'
 import {
-  Title,
   Description,
   OnboardingControls,
+  Title,
 } from '#/screens/Onboarding/Layout'
+import {ApiResponseMap, Context} from '#/screens/Onboarding/state'
 import {InterestButton} from '#/screens/Onboarding/StepInterests/InterestButton'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {IconCircle} from '#/components/IconCircle'
+import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as ArrowRotateCounterClockwise} from '#/components/icons/ArrowRotateCounterClockwise'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {EmojiSad_Stroke2_Corner0_Rounded as EmojiSad} from '#/components/icons/Emoji'
+import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
 
 export function StepInterests() {
   const {_} = useLingui()
@@ -107,6 +107,10 @@ export function StepInterests() {
         selectedInterests: interests,
         selectedInterestsLength: interests.length,
       })
+      logEvent('onboarding:interests:nextPressed', {
+        selectedInterests: interests,
+        selectedInterestsLength: interests.length,
+      })
     } catch (e: any) {
       logger.info(`onboading: error saving interests`)
       logger.error(e)
diff --git a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
index b38b3df1e..9e59c1db6 100644
--- a/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
+++ b/src/screens/Onboarding/StepModeration/AdultContentEnabledPref.tsx
@@ -1,18 +1,18 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 import {UseMutateFunction} from '@tanstack/react-query'
 
+import {logger} from '#/logger'
+import {isIOS} from '#/platform/detection'
+import {usePreferencesQuery} from '#/state/queries/preferences'
 import * as Toast from '#/view/com/util/Toast'
 import {atoms as a, useTheme} from '#/alf'
-import {usePreferencesQuery} from '#/state/queries/preferences'
-import {logger} from '#/logger'
-import {Text} from '#/components/Typography'
 import * as Toggle from '#/components/forms/Toggle'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import * as Prompt from '#/components/Prompt'
-import {isIOS} from '#/platform/detection'
+import {Text} from '#/components/Typography'
 
 function Card({children}: React.PropsWithChildren<{}>) {
   const t = useTheme()
@@ -56,7 +56,9 @@ export function AdultContentEnabledPref({
 
     try {
       mutate({
-        enabled: !(variables?.enabled ?? preferences?.adultContentEnabled),
+        enabled: !(
+          variables?.enabled ?? preferences?.moderationPrefs.adultContentEnabled
+        ),
       })
     } catch (e) {
       Toast.show(
@@ -75,7 +77,10 @@ export function AdultContentEnabledPref({
           <Toggle.Item
             name={_(msg`Enable adult content in your feeds`)}
             label={_(msg`Enable adult content in your feeds`)}
-            value={variables?.enabled ?? preferences?.adultContentEnabled}
+            value={
+              variables?.enabled ??
+              preferences?.moderationPrefs.adultContentEnabled
+            }
             onChange={onToggleAdultContent}>
             <View
               style={[
@@ -85,7 +90,9 @@ export function AdultContentEnabledPref({
                 a.align_center,
                 a.py_md,
               ]}>
-              <Text style={[a.font_bold]}>Enable Adult Content</Text>
+              <Text style={[a.font_bold]}>
+                <Trans>Enable Adult Content</Trans>
+              </Text>
               <Toggle.Switch />
             </View>
           </Toggle.Item>
@@ -106,7 +113,9 @@ export function AdultContentEnabledPref({
       )}
 
       <Prompt.Outer control={prompt}>
-        <Prompt.Title>Adult Content</Prompt.Title>
+        <Prompt.Title>
+          <Trans>Adult Content</Trans>
+        </Prompt.Title>
         <Prompt.Description>
           <Trans>
             Due to Apple policies, adult content can only be enabled on the web
@@ -114,7 +123,7 @@ export function AdultContentEnabledPref({
           </Trans>
         </Prompt.Description>
         <Prompt.Actions>
-          <Prompt.Action onPress={prompt.close}>OK</Prompt.Action>
+          <Prompt.Action onPress={() => prompt.close()} cta={_(msg`OK`)} />
         </Prompt.Actions>
       </Prompt.Outer>
     </>
diff --git a/src/screens/Onboarding/StepModeration/ModerationOption.tsx b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
index c61b520ba..ac02a874c 100644
--- a/src/screens/Onboarding/StepModeration/ModerationOption.tsx
+++ b/src/screens/Onboarding/StepModeration/ModerationOption.tsx
@@ -1,40 +1,51 @@
 import React from 'react'
 import {View} from 'react-native'
-import {LabelPreference} from '@atproto/api'
+import {LabelPreference, InterpretedLabelValueDefinition} from '@atproto/api'
 import {useLingui} from '@lingui/react'
-import {msg} from '@lingui/macro'
-import Animated, {Easing, Layout, FadeIn} from 'react-native-reanimated'
+import {msg, Trans} from '@lingui/macro'
 
 import {
-  CONFIGURABLE_LABEL_GROUPS,
-  ConfigurableLabelGroup,
   usePreferencesQuery,
   usePreferencesSetContentLabelMutation,
 } from '#/state/queries/preferences'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
 import * as ToggleButton from '#/components/forms/ToggleButton'
+import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
 
 export function ModerationOption({
-  labelGroup,
-  isMounted,
+  labelValueDefinition,
+  disabled,
 }: {
-  labelGroup: ConfigurableLabelGroup
-  isMounted: React.MutableRefObject<boolean>
+  labelValueDefinition: InterpretedLabelValueDefinition
+  disabled?: boolean
 }) {
   const {_} = useLingui()
   const t = useTheme()
-  const groupInfo = CONFIGURABLE_LABEL_GROUPS[labelGroup]
   const {data: preferences} = usePreferencesQuery()
   const {mutate, variables} = usePreferencesSetContentLabelMutation()
+  const label = labelValueDefinition.identifier
   const visibility =
-    variables?.visibility ?? preferences?.contentLabels?.[labelGroup]
+    variables?.visibility ?? preferences?.moderationPrefs.labels?.[label]
+
+  const allLabelStrings = useGlobalLabelStrings()
+  const labelStrings =
+    labelValueDefinition.identifier in allLabelStrings
+      ? allLabelStrings[labelValueDefinition.identifier]
+      : {
+          name: labelValueDefinition.identifier,
+          description: `Labeled "${labelValueDefinition.identifier}"`,
+        }
 
   const onChange = React.useCallback(
     (vis: string[]) => {
-      mutate({labelGroup, visibility: vis[0] as LabelPreference})
+      mutate({
+        label,
+        visibility: vis[0] as LabelPreference,
+        labelerDid: undefined,
+      })
     },
-    [mutate, labelGroup],
+    [mutate, label],
   )
 
   const labels = {
@@ -44,7 +55,7 @@ export function ModerationOption({
   }
 
   return (
-    <Animated.View
+    <View
       style={[
         a.flex_row,
         a.justify_between,
@@ -52,33 +63,37 @@ export function ModerationOption({
         a.py_xs,
         a.px_xs,
         a.align_center,
-      ]}
-      layout={Layout.easing(Easing.ease).duration(200)}
-      entering={isMounted.current ? FadeIn : undefined}>
-      <View style={[a.gap_xs, {width: '50%'}]}>
-        <Text style={[a.font_bold]}>{groupInfo.title}</Text>
+      ]}>
+      <View style={[a.gap_xs, a.flex_1]}>
+        <Text style={[a.font_bold]}>{labelStrings.name}</Text>
         <Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
-          {groupInfo.subtitle}
+          {labelStrings.description}
         </Text>
       </View>
-      <View style={[a.justify_center, {minHeight: 35}]}>
-        <ToggleButton.Group
-          label={_(
-            msg`Configure content filtering setting for category: ${groupInfo.title.toLowerCase()}`,
-          )}
-          values={[visibility ?? 'hide']}
-          onChange={onChange}>
-          <ToggleButton.Button name="hide" label={labels.hide}>
-            {labels.hide}
-          </ToggleButton.Button>
-          <ToggleButton.Button name="warn" label={labels.warn}>
-            {labels.warn}
-          </ToggleButton.Button>
-          <ToggleButton.Button name="ignore" label={labels.show}>
-            {labels.show}
-          </ToggleButton.Button>
-        </ToggleButton.Group>
+      <View style={[a.justify_center, {minHeight: 40}]}>
+        {disabled ? (
+          <Text style={[a.font_bold]}>
+            <Trans>Hide</Trans>
+          </Text>
+        ) : (
+          <ToggleButton.Group
+            label={_(
+              msg`Configure content filtering setting for category: ${labelStrings.name.toLowerCase()}`,
+            )}
+            values={[visibility ?? 'hide']}
+            onChange={onChange}>
+            <ToggleButton.Button name="ignore" label={labels.show}>
+              {labels.show}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="warn" label={labels.warn}>
+              {labels.warn}
+            </ToggleButton.Button>
+            <ToggleButton.Button name="hide" label={labels.hide}>
+              {labels.hide}
+            </ToggleButton.Button>
+          </ToggleButton.Group>
+        )}
       </View>
-    </Animated.View>
+    </View>
   )
 }
diff --git a/src/screens/Onboarding/StepModeration/index.tsx b/src/screens/Onboarding/StepModeration/index.tsx
index c831b6880..c5bdf5622 100644
--- a/src/screens/Onboarding/StepModeration/index.tsx
+++ b/src/screens/Onboarding/StepModeration/index.tsx
@@ -1,40 +1,27 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
+import {LABELS} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
-import Animated, {Easing, Layout} from 'react-native-reanimated'
+import {useLingui} from '@lingui/react'
 
-import {atoms as a} from '#/alf'
-import {
-  configurableAdultLabelGroups,
-  configurableOtherLabelGroups,
-  usePreferencesSetAdultContentMutation,
-} from 'state/queries/preferences'
-import {Divider} from '#/components/Divider'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
-import {usePreferencesQuery} from '#/state/queries/preferences'
-import {Loader} from '#/components/Loader'
 import {useAnalytics} from '#/lib/analytics/analytics'
-
+import {logEvent} from '#/lib/statsig/statsig'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {usePreferencesSetAdultContentMutation} from 'state/queries/preferences'
 import {
   Description,
   OnboardingControls,
   Title,
 } from '#/screens/Onboarding/Layout'
-import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
-import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
 import {Context} from '#/screens/Onboarding/state'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
-
-function AnimatedDivider() {
-  return (
-    <Animated.View layout={Layout.easing(Easing.ease).duration(200)}>
-      <Divider />
-    </Animated.View>
-  )
-}
+import {AdultContentEnabledPref} from '#/screens/Onboarding/StepModeration/AdultContentEnabledPref'
+import {ModerationOption} from '#/screens/Onboarding/StepModeration/ModerationOption'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {IconCircle} from '#/components/IconCircle'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
+import {Loader} from '#/components/Loader'
 
 export function StepModeration() {
   const {_} = useLingui()
@@ -52,12 +39,13 @@ export function StepModeration() {
 
   const adultContentEnabled = !!(
     (variables && variables.enabled) ||
-    (!variables && preferences?.adultContentEnabled)
+    (!variables && preferences?.moderationPrefs.adultContentEnabled)
   )
 
   const onContinue = React.useCallback(() => {
     dispatch({type: 'next'})
     track('OnboardingV2:StepModeration:End')
+    logEvent('onboarding:moderation:nextPressed', {})
   }, [track, dispatch])
 
   React.useEffect(() => {
@@ -86,22 +74,19 @@ export function StepModeration() {
           <AdultContentEnabledPref mutate={mutate} variables={variables} />
 
           <View style={[a.gap_sm, a.w_full]}>
-            {adultContentEnabled &&
-              configurableAdultLabelGroups.map((g, index) => (
-                <React.Fragment key={index}>
-                  {index === 0 && <AnimatedDivider />}
-                  <ModerationOption labelGroup={g} isMounted={isMounted} />
-                  <AnimatedDivider />
-                </React.Fragment>
-              ))}
-
-            {configurableOtherLabelGroups.map((g, index) => (
-              <React.Fragment key={index}>
-                {!adultContentEnabled && index === 0 && <AnimatedDivider />}
-                <ModerationOption labelGroup={g} isMounted={isMounted} />
-                <AnimatedDivider />
-              </React.Fragment>
-            ))}
+            <ModerationOption
+              labelValueDefinition={LABELS.porn}
+              disabled={!adultContentEnabled}
+            />
+            <ModerationOption
+              labelValueDefinition={LABELS.sexual}
+              disabled={!adultContentEnabled}
+            />
+            <ModerationOption
+              labelValueDefinition={LABELS['graphic-media']}
+              disabled={!adultContentEnabled}
+            />
+            <ModerationOption labelValueDefinition={LABELS.nudity} />
           </View>
         </>
       )}
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
index 067005892..7e4ea1f8b 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard.tsx
@@ -88,7 +88,7 @@ export function SuggestedAccountCard({
             <UserAvatar
               size={48}
               avatar={profile.avatar}
-              moderation={moderation.avatar}
+              moderation={moderation.ui('avatar')}
             />
           </View>
           <View style={[a.flex_1]}>
diff --git a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
index 3caa38d4f..2e6161362 100644
--- a/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
+++ b/src/screens/Onboarding/StepSuggestedAccounts/index.tsx
@@ -1,33 +1,33 @@
 import React from 'react'
 import {View} from 'react-native'
 import {AppBskyActorDefs} from '@atproto/api'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {atoms as a, useBreakpoints} from '#/alf'
-import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
-import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import {Text} from '#/components/Typography'
-import {useProfilesQuery} from '#/state/queries/profile'
-import {Loader} from '#/components/Loader'
-import * as Toggle from '#/components/forms/Toggle'
-import {useModerationOpts} from '#/state/queries/preferences'
 import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
 import {capitalize} from '#/lib/strings/capitalize'
-
-import {Context} from '#/screens/Onboarding/state'
+import {useModerationOpts} from '#/state/queries/preferences'
+import {useProfilesQuery} from '#/state/queries/profile'
 import {
-  Title,
   Description,
   OnboardingControls,
+  Title,
 } from '#/screens/Onboarding/Layout'
+import {Context} from '#/screens/Onboarding/state'
 import {
   SuggestedAccountCard,
   SuggestedAccountCardPlaceholder,
 } from '#/screens/Onboarding/StepSuggestedAccounts/SuggestedAccountCard'
 import {aggregateInterestItems} from '#/screens/Onboarding/util'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {atoms as a, useBreakpoints} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {IconCircle} from '#/components/IconCircle'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
 
 export function Inner({
   profiles,
@@ -76,7 +76,7 @@ export function StepSuggestedAccounts() {
     return aggregateInterestItems(
       state.interestsStepResults.selectedInterests,
       state.interestsStepResults.apiResponse.suggestedAccountDids,
-      state.interestsStepResults.apiResponse.suggestedAccountDids.default,
+      state.interestsStepResults.apiResponse.suggestedAccountDids.default || [],
     )
   }, [state.interestsStepResults])
   const moderationOpts = useModerationOpts()
@@ -110,12 +110,20 @@ export function StepSuggestedAccounts() {
     track('OnboardingV2:StepSuggestedAccounts:End', {
       selectedAccountsLength: dids.length,
     })
+    logEvent('onboarding:suggestedAccounts:nextPressed', {
+      selectedAccountsLength: dids.length,
+      skipped: false,
+    })
   }, [dids, setSaving, dispatch, track])
 
   const handleSkip = React.useCallback(() => {
     // if a user comes back and clicks skip, erase follows
     dispatch({type: 'setSuggestedAccountsStepResults', accountDids: []})
     dispatch({type: 'next'})
+    logEvent('onboarding:suggestedAccounts:nextPressed', {
+      selectedAccountsLength: 0,
+      skipped: true,
+    })
   }, [dispatch])
 
   const isLoading = isProfilesLoading && moderationOpts
diff --git a/src/screens/Onboarding/StepTopicalFeeds.tsx b/src/screens/Onboarding/StepTopicalFeeds.tsx
index 3640b764d..26b1c243b 100644
--- a/src/screens/Onboarding/StepTopicalFeeds.tsx
+++ b/src/screens/Onboarding/StepTopicalFeeds.tsx
@@ -1,42 +1,48 @@
 import React from 'react'
 import {View} from 'react-native'
-import {useLingui} from '@lingui/react'
 import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
 
-import {IS_PROD} from '#/env'
-import {atoms as a} from '#/alf'
-import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
-import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
-import {Button, ButtonIcon, ButtonText} from '#/components/Button'
-import * as Toggle from '#/components/forms/Toggle'
-import {Loader} from '#/components/Loader'
 import {useAnalytics} from '#/lib/analytics/analytics'
+import {logEvent} from '#/lib/statsig/statsig'
 import {capitalize} from '#/lib/strings/capitalize'
-
-import {Context} from '#/screens/Onboarding/state'
+import {IS_TEST_USER} from 'lib/constants'
+import {useSession} from 'state/session'
 import {
-  Title,
   Description,
   OnboardingControls,
+  Title,
 } from '#/screens/Onboarding/Layout'
+import {Context} from '#/screens/Onboarding/state'
 import {FeedCard} from '#/screens/Onboarding/StepAlgoFeeds/FeedCard'
 import {aggregateInterestItems} from '#/screens/Onboarding/util'
-import {IconCircle} from '#/screens/Onboarding/IconCircle'
+import {atoms as a} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Toggle from '#/components/forms/Toggle'
+import {IconCircle} from '#/components/IconCircle'
+import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
+import {ListMagnifyingGlass_Stroke2_Corner0_Rounded as ListMagnifyingGlass} from '#/components/icons/ListMagnifyingGlass'
+import {Loader} from '#/components/Loader'
 
 export function StepTopicalFeeds() {
   const {_} = useLingui()
   const {track} = useAnalytics()
+  const {currentAccount} = useSession()
   const {state, dispatch, interestsDisplayNames} = React.useContext(Context)
   const [selectedFeedUris, setSelectedFeedUris] = React.useState<string[]>([])
   const [saving, setSaving] = React.useState(false)
   const suggestedFeedUris = React.useMemo(() => {
-    if (!IS_PROD) return []
+    if (IS_TEST_USER(currentAccount?.handle)) return []
     return aggregateInterestItems(
       state.interestsStepResults.selectedInterests,
       state.interestsStepResults.apiResponse.suggestedFeedUris,
-      state.interestsStepResults.apiResponse.suggestedFeedUris.default,
+      state.interestsStepResults.apiResponse.suggestedFeedUris.default || [],
     ).slice(0, 10)
-  }, [state.interestsStepResults])
+  }, [
+    currentAccount?.handle,
+    state.interestsStepResults.apiResponse.suggestedFeedUris,
+    state.interestsStepResults.selectedInterests,
+  ])
 
   const interestsText = React.useMemo(() => {
     const i = state.interestsStepResults.selectedInterests.map(
@@ -56,6 +62,10 @@ export function StepTopicalFeeds() {
       selectedFeeds: selectedFeedUris,
       selectedFeedsLength: selectedFeedUris.length,
     })
+    logEvent('onboarding:topicalFeeds:nextPressed', {
+      selectedFeeds: selectedFeedUris,
+      selectedFeedsLength: selectedFeedUris.length,
+    })
   }, [selectedFeedUris, dispatch, track])
 
   React.useEffect(() => {
diff --git a/src/screens/Onboarding/state.ts b/src/screens/Onboarding/state.ts
index bd8205ca2..969edbdd2 100644
--- a/src/screens/Onboarding/state.ts
+++ b/src/screens/Onboarding/state.ts
@@ -232,7 +232,7 @@ export function reducer(
   })
 
   if (s.activeStep !== state.activeStep) {
-    logger.info(`onboarding: step changed`, {activeStep: state.activeStep})
+    logger.debug(`onboarding: step changed`, {activeStep: state.activeStep})
   }
 
   return state
diff --git a/src/screens/Profile/ErrorState.tsx b/src/screens/Profile/ErrorState.tsx
new file mode 100644
index 000000000..2ec2cf592
--- /dev/null
+++ b/src/screens/Profile/ErrorState.tsx
@@ -0,0 +1,72 @@
+import React from 'react'
+import {View} from 'react-native'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useNavigation} from '@react-navigation/native'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Button, ButtonText} from '#/components/Button'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {NavigationProp} from '#/lib/routes/types'
+
+export function ErrorState({error}: {error: string}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const navigation = useNavigation<NavigationProp>()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  return (
+    <View style={[a.px_xl]}>
+      <CircleInfo width={48} style={[t.atoms.text_contrast_low]} />
+
+      <Text style={[a.text_xl, a.font_bold, a.pb_md, a.pt_xl]}>
+        <Trans>Hmmmm, we couldn't load that moderation service.</Trans>
+      </Text>
+      <Text
+        style={[
+          a.text_md,
+          a.leading_normal,
+          a.pb_md,
+          t.atoms.text_contrast_medium,
+        ]}>
+        <Trans>
+          This moderation service is unavailable. See below for more details. If
+          this issue persists, contact us.
+        </Trans>
+      </Text>
+      <View
+        style={[
+          a.relative,
+          a.py_md,
+          a.px_lg,
+          a.rounded_md,
+          a.mb_2xl,
+          t.atoms.bg_contrast_25,
+        ]}>
+        <Text style={[a.text_md, a.leading_normal]}>{error}</Text>
+      </View>
+
+      <View style={{flexDirection: 'row'}}>
+        <Button
+          size="small"
+          color="secondary"
+          variant="solid"
+          label={_(msg`Go Back`)}
+          accessibilityHint="Return to previous page"
+          onPress={onPressBack}>
+          <ButtonText>
+            <Trans>Go Back</Trans>
+          </ButtonText>
+        </Button>
+      </View>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
new file mode 100644
index 000000000..b6d88db71
--- /dev/null
+++ b/src/screens/Profile/Header/DisplayName.tsx
@@ -0,0 +1,31 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {sanitizeHandle} from 'lib/strings/handles'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+import {Shadow} from '#/state/cache/types'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function ProfileHeaderDisplayName({
+  profile,
+  moderation,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+  moderation: ModerationDecision
+}) {
+  const t = useTheme()
+  return (
+    <View pointerEvents="none">
+      <Text
+        testID="profileHeaderDisplayName"
+        style={[t.atoms.text, a.text_4xl, {fontWeight: '500'}]}>
+        {sanitizeDisplayName(
+          profile.displayName || sanitizeHandle(profile.handle),
+          moderation.ui('displayName'),
+        )}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
new file mode 100644
index 000000000..fd1cbe533
--- /dev/null
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {isInvalidHandle} from 'lib/strings/handles'
+import {Shadow} from '#/state/cache/types'
+import {Trans} from '@lingui/macro'
+
+import {atoms as a, useTheme, web} from '#/alf'
+import {Text} from '#/components/Typography'
+
+export function ProfileHeaderHandle({
+  profile,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+}) {
+  const t = useTheme()
+  const invalidHandle = isInvalidHandle(profile.handle)
+  const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
+  return (
+    <View style={[a.flex_row, a.gap_xs, a.align_center]} pointerEvents="none">
+      {profile.viewer?.followedBy && !blockHide ? (
+        <View style={[t.atoms.bg_contrast_25, a.rounded_xs, a.px_sm, a.py_xs]}>
+          <Text style={[t.atoms.text, a.text_sm]}>
+            <Trans>Follows you</Trans>
+          </Text>
+        </View>
+      ) : undefined}
+      <Text
+        style={[
+          invalidHandle
+            ? [
+                a.border,
+                a.text_xs,
+                a.px_sm,
+                a.py_xs,
+                a.rounded_xs,
+                {borderColor: t.palette.contrast_200},
+              ]
+            : [a.text_md, t.atoms.text_contrast_medium],
+          web({wordBreak: 'break-all'}),
+        ]}>
+        {invalidHandle ? <Trans>âš Invalid Handle</Trans> : `@${profile.handle}`}
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/Metrics.tsx b/src/screens/Profile/Header/Metrics.tsx
new file mode 100644
index 000000000..d9a8a01a8
--- /dev/null
+++ b/src/screens/Profile/Header/Metrics.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Shadow} from '#/state/cache/types'
+import {pluralize} from '#/lib/strings/helpers'
+import {makeProfileLink} from 'lib/routes/links'
+import {formatCount} from 'view/com/util/numeric/format'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+
+export function ProfileHeaderMetrics({
+  profile,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const following = formatCount(profile.followsCount || 0)
+  const followers = formatCount(profile.followersCount || 0)
+  const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
+
+  return (
+    <View
+      style={[a.flex_row, a.gap_sm, a.align_center, a.pb_md]}
+      pointerEvents="box-none">
+      <InlineLink
+        testID="profileHeaderFollowersButton"
+        style={[a.flex_row, t.atoms.text]}
+        to={makeProfileLink(profile, 'followers')}
+        label={`${followers} ${pluralizedFollowers}`}>
+        <Text style={[a.font_bold, a.text_md]}>{followers} </Text>
+        <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
+          {pluralizedFollowers}
+        </Text>
+      </InlineLink>
+      <InlineLink
+        testID="profileHeaderFollowsButton"
+        style={[a.flex_row, t.atoms.text]}
+        to={makeProfileLink(profile, 'follows')}
+        label={_(msg`${following} following`)}>
+        <Trans>
+          <Text style={[a.font_bold, a.text_md]}>{following} </Text>
+          <Text style={[t.atoms.text_contrast_medium, a.text_md]}>
+            following
+          </Text>
+        </Trans>
+      </InlineLink>
+      <Text style={[a.font_bold, t.atoms.text, a.text_md]}>
+        {formatCount(profile.postsCount || 0)}{' '}
+        <Text style={[t.atoms.text_contrast_medium, a.font_normal, a.text_md]}>
+          {pluralize(profile.postsCount || 0, 'post')}
+        </Text>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderLabeler.tsx b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
new file mode 100644
index 000000000..a93cda134
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderLabeler.tsx
@@ -0,0 +1,329 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyLabelerDefs,
+  moderateProfile,
+  ModerationOpts,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {Haptics} from '#/lib/haptics'
+import {isAppLabeler} from '#/lib/moderation'
+import {pluralize} from '#/lib/strings/helpers'
+import {logger} from '#/logger'
+import {Shadow} from '#/state/cache/types'
+import {useModalControls} from '#/state/modals'
+import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, tokens, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {DialogOuterProps} from '#/components/Dialog'
+import {
+  Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+  Heart2_Stroke2_Corner0_Rounded as Heart,
+} from '#/components/icons/Heart2'
+import {Link} from '#/components/Link'
+import * as Prompt from '#/components/Prompt'
+import {RichText} from '#/components/RichText'
+import {Text} from '#/components/Typography'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderMetrics} from './Metrics'
+import {ProfileHeaderShell} from './Shell'
+
+interface Props {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed
+  descriptionRT: RichTextAPI | null
+  moderationOpts: ModerationOpts
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderLabeler = ({
+  profile: profileUnshadowed,
+  labeler,
+  descriptionRT,
+  moderationOpts,
+  hideBackButton = false,
+  isPlaceholderProfile,
+}: Props): React.ReactNode => {
+  const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
+    useProfileShadow(profileUnshadowed)
+  const t = useTheme()
+  const {_} = useLingui()
+  const {currentAccount, hasSession} = useSession()
+  const {openModal} = useModalControls()
+  const {track} = useAnalytics()
+  const cantSubscribePrompt = Prompt.usePromptControl()
+  const isSelf = currentAccount?.did === profile.did
+
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
+  const {data: preferences} = usePreferencesQuery()
+  const {mutateAsync: toggleSubscription, variables} =
+    useLabelerSubscriptionMutation()
+  const isSubscribed =
+    variables?.subscribe ??
+    preferences?.moderationPrefs.labelers.find(l => l.did === profile.did)
+  const canSubscribe =
+    isSubscribed ||
+    (preferences ? preferences?.moderationPrefs.labelers.length < 9 : false)
+  const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
+  const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
+    useUnlikeMutation()
+  const [likeUri, setLikeUri] = React.useState<string>(
+    labeler.viewer?.like || '',
+  )
+  const [likeCount, setLikeCount] = React.useState(labeler.likeCount || 0)
+
+  const onToggleLiked = React.useCallback(async () => {
+    if (!labeler) {
+      return
+    }
+    try {
+      Haptics.default()
+
+      if (likeUri) {
+        await unlikeMod({uri: likeUri})
+        track('CustomFeed:Unlike')
+        setLikeCount(c => c - 1)
+        setLikeUri('')
+      } else {
+        const res = await likeMod({uri: labeler.uri, cid: labeler.cid})
+        track('CustomFeed:Like')
+        setLikeCount(c => c + 1)
+        setLikeUri(res.uri)
+      }
+    } catch (e: any) {
+      Toast.show(
+        _(
+          msg`There was an an issue contacting the server, please check your internet connection and try again.`,
+        ),
+      )
+      logger.error(`Failed to toggle labeler like`, {message: e.message})
+    }
+  }, [labeler, likeUri, likeMod, unlikeMod, track, _])
+
+  const onPressEditProfile = React.useCallback(() => {
+    track('ProfileHeader:EditProfileButtonClicked')
+    openModal({
+      name: 'edit-profile',
+      profile,
+    })
+  }, [track, openModal, profile])
+
+  const onPressSubscribe = React.useCallback(async () => {
+    if (!canSubscribe) {
+      cantSubscribePrompt.open()
+      return
+    }
+    try {
+      await toggleSubscription({
+        did: profile.did,
+        subscribe: !isSubscribed,
+      })
+    } catch (e: any) {
+      // setSubscriptionError(e.message)
+      logger.error(`Failed to subscribe to labeler`, {message: e.message})
+    }
+  }, [
+    toggleSubscription,
+    isSubscribed,
+    profile,
+    canSubscribe,
+    cantSubscribePrompt,
+  ])
+
+  const isMe = React.useMemo(
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
+  )
+
+  return (
+    <ProfileHeaderShell
+      profile={profile}
+      moderation={moderation}
+      hideBackButton={hideBackButton}
+      isPlaceholderProfile={isPlaceholderProfile}>
+      <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
+        <View
+          style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_lg]}
+          pointerEvents="box-none">
+          {isMe ? (
+            <Button
+              testID="profileHeaderEditProfileButton"
+              size="small"
+              color="secondary"
+              variant="solid"
+              onPress={onPressEditProfile}
+              label={_(msg`Edit profile`)}
+              style={a.rounded_full}>
+              <ButtonText>
+                <Trans>Edit Profile</Trans>
+              </ButtonText>
+            </Button>
+          ) : !isAppLabeler(profile.did) ? (
+            <>
+              <Button
+                testID="toggleSubscribeBtn"
+                label={
+                  isSubscribed
+                    ? _(msg`Unsubscribe from this labeler`)
+                    : _(msg`Subscribe to this labeler`)
+                }
+                disabled={!hasSession}
+                onPress={onPressSubscribe}>
+                {state => (
+                  <View
+                    style={[
+                      {
+                        paddingVertical: 12,
+                        backgroundColor:
+                          isSubscribed || !canSubscribe
+                            ? state.hovered || state.pressed
+                              ? t.palette.contrast_50
+                              : t.palette.contrast_25
+                            : state.hovered || state.pressed
+                            ? tokens.color.temp_purple_dark
+                            : tokens.color.temp_purple,
+                      },
+                      a.px_lg,
+                      a.rounded_sm,
+                      a.gap_sm,
+                    ]}>
+                    <Text
+                      style={[
+                        {
+                          color: canSubscribe
+                            ? isSubscribed
+                              ? t.palette.contrast_700
+                              : t.palette.white
+                            : t.palette.contrast_400,
+                        },
+                        a.font_bold,
+                        a.text_center,
+                      ]}>
+                      {isSubscribed ? (
+                        <Trans>Unsubscribe</Trans>
+                      ) : (
+                        <Trans>Subscribe to Labeler</Trans>
+                      )}
+                    </Text>
+                  </View>
+                )}
+              </Button>
+            </>
+          ) : null}
+          <ProfileMenu profile={profile} />
+        </View>
+        <View style={[a.flex_col, a.gap_xs, a.pb_md]}>
+          <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
+          <ProfileHeaderHandle profile={profile} />
+        </View>
+        {!isPlaceholderProfile && (
+          <>
+            {isSelf && <ProfileHeaderMetrics profile={profile} />}
+            {descriptionRT && !moderation.ui('profileView').blur ? (
+              <View pointerEvents="auto">
+                <RichText
+                  testID="profileHeaderDescription"
+                  style={[a.text_md]}
+                  numberOfLines={15}
+                  value={descriptionRT}
+                />
+              </View>
+            ) : undefined}
+            {!isAppLabeler(profile.did) && (
+              <View style={[a.flex_row, a.gap_xs, a.align_center, a.pt_lg]}>
+                <Button
+                  testID="toggleLikeBtn"
+                  size="small"
+                  color="secondary"
+                  variant="solid"
+                  shape="round"
+                  label={_(msg`Like this feed`)}
+                  disabled={!hasSession || isLikePending || isUnlikePending}
+                  onPress={onToggleLiked}>
+                  {likeUri ? (
+                    <HeartFilled fill={t.palette.negative_400} />
+                  ) : (
+                    <Heart fill={t.atoms.text_contrast_medium.color} />
+                  )}
+                </Button>
+
+                {typeof likeCount === 'number' && (
+                  <Link
+                    to={{
+                      screen: 'ProfileLabelerLikedBy',
+                      params: {
+                        name: labeler.creator.handle || labeler.creator.did,
+                      },
+                    }}
+                    size="tiny"
+                    label={_(
+                      msg`Liked by ${likeCount} ${pluralize(
+                        likeCount,
+                        'user',
+                      )}`,
+                    )}>
+                    {({hovered, focused, pressed}) => (
+                      <Text
+                        style={[
+                          a.font_bold,
+                          a.text_sm,
+                          t.atoms.text_contrast_medium,
+                          (hovered || focused || pressed) &&
+                            t.atoms.text_contrast_high,
+                        ]}>
+                        <Trans>
+                          Liked by {likeCount} {pluralize(likeCount, 'user')}
+                        </Trans>
+                      </Text>
+                    )}
+                  </Link>
+                )}
+              </View>
+            )}
+          </>
+        )}
+      </View>
+      <CantSubscribePrompt control={cantSubscribePrompt} />
+    </ProfileHeaderShell>
+  )
+}
+ProfileHeaderLabeler = memo(ProfileHeaderLabeler)
+export {ProfileHeaderLabeler}
+
+function CantSubscribePrompt({
+  control,
+}: {
+  control: DialogOuterProps['control']
+}) {
+  const {_} = useLingui()
+  return (
+    <Prompt.Outer control={control}>
+      <Prompt.Title>Unable to subscribe</Prompt.Title>
+      <Prompt.Description>
+        <Trans>
+          We're sorry! You can only subscribe to ten labelers, and you've
+          reached your limit of ten.
+        </Trans>
+      </Prompt.Description>
+      <Prompt.Actions>
+        <Prompt.Action onPress={control.close} cta={_(msg`OK`)} />
+      </Prompt.Actions>
+    </Prompt.Outer>
+  )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
new file mode 100644
index 000000000..8b9038244
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -0,0 +1,286 @@
+import React, {memo, useMemo} from 'react'
+import {View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  ModerationOpts,
+  moderateProfile,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+import {useModalControls} from '#/state/modals'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSession, useRequireAuth} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {
+  useProfileFollowMutationQueue,
+  useProfileBlockMutationQueue,
+} from '#/state/queries/profile'
+import {logger} from '#/logger'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonText, ButtonIcon} from '#/components/Button'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderMetrics} from './Metrics'
+import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
+import {RichText} from '#/components/RichText'
+import * as Prompt from '#/components/Prompt'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus'
+
+interface Props {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  descriptionRT: RichTextAPI | null
+  moderationOpts: ModerationOpts
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderStandard = ({
+  profile: profileUnshadowed,
+  descriptionRT,
+  moderationOpts,
+  hideBackButton = false,
+  isPlaceholderProfile,
+}: Props): React.ReactNode => {
+  const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
+    useProfileShadow(profileUnshadowed)
+  const t = useTheme()
+  const {currentAccount, hasSession} = useSession()
+  const {_} = useLingui()
+  const {openModal} = useModalControls()
+  const {track} = useAnalytics()
+  const moderation = useMemo(
+    () => moderateProfile(profile, moderationOpts),
+    [profile, moderationOpts],
+  )
+  const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
+  const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
+    profile,
+    'ProfileHeader',
+  )
+  const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+  const unblockPromptControl = Prompt.usePromptControl()
+  const requireAuth = useRequireAuth()
+
+  const onPressEditProfile = React.useCallback(() => {
+    track('ProfileHeader:EditProfileButtonClicked')
+    openModal({
+      name: 'edit-profile',
+      profile,
+    })
+  }, [track, openModal, profile])
+
+  const onPressFollow = () => {
+    requireAuth(async () => {
+      try {
+        track('ProfileHeader:FollowButtonClicked')
+        await queueFollow()
+        Toast.show(
+          _(
+            msg`Following ${sanitizeDisplayName(
+              profile.displayName || profile.handle,
+              moderation.ui('displayName'),
+            )}`,
+          ),
+        )
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to follow', {message: String(e)})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
+        }
+      }
+    })
+  }
+
+  const onPressUnfollow = () => {
+    requireAuth(async () => {
+      try {
+        track('ProfileHeader:UnfollowButtonClicked')
+        await queueUnfollow()
+        Toast.show(
+          _(
+            msg`No longer following ${sanitizeDisplayName(
+              profile.displayName || profile.handle,
+              moderation.ui('displayName'),
+            )}`,
+          ),
+        )
+      } catch (e: any) {
+        if (e?.name !== 'AbortError') {
+          logger.error('Failed to unfollow', {message: String(e)})
+          Toast.show(_(msg`There was an issue! ${e.toString()}`))
+        }
+      }
+    })
+  }
+
+  const unblockAccount = React.useCallback(async () => {
+    track('ProfileHeader:UnblockAccountButtonClicked')
+    try {
+      await queueUnblock()
+      Toast.show(_(msg`Account unblocked`))
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to unblock account', {message: e})
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
+      }
+    }
+  }, [_, queueUnblock, track])
+
+  const isMe = React.useMemo(
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
+  )
+
+  return (
+    <ProfileHeaderShell
+      profile={profile}
+      moderation={moderation}
+      hideBackButton={hideBackButton}
+      isPlaceholderProfile={isPlaceholderProfile}>
+      <View style={[a.px_lg, a.pt_md, a.pb_sm]} pointerEvents="box-none">
+        <View
+          style={[a.flex_row, a.justify_end, a.gap_sm, a.pb_sm]}
+          pointerEvents="box-none">
+          {isMe ? (
+            <Button
+              testID="profileHeaderEditProfileButton"
+              size="small"
+              color="secondary"
+              variant="solid"
+              onPress={onPressEditProfile}
+              label={_(msg`Edit profile`)}
+              style={a.rounded_full}>
+              <ButtonText>
+                <Trans>Edit Profile</Trans>
+              </ButtonText>
+            </Button>
+          ) : profile.viewer?.blocking ? (
+            profile.viewer?.blockingByList ? null : (
+              <Button
+                testID="unblockBtn"
+                size="small"
+                color="secondary"
+                variant="solid"
+                label={_(msg`Unblock`)}
+                disabled={!hasSession}
+                onPress={() => unblockPromptControl.open()}
+                style={a.rounded_full}>
+                <ButtonText>
+                  <Trans context="action">Unblock</Trans>
+                </ButtonText>
+              </Button>
+            )
+          ) : !profile.viewer?.blockedBy ? (
+            <>
+              {hasSession && (
+                <Button
+                  testID="suggestedFollowsBtn"
+                  size="small"
+                  color={showSuggestedFollows ? 'primary' : 'secondary'}
+                  variant="solid"
+                  shape="round"
+                  onPress={() => setShowSuggestedFollows(!showSuggestedFollows)}
+                  label={_(msg`Show follows similar to ${profile.handle}`)}>
+                  <FontAwesomeIcon
+                    icon="user-plus"
+                    style={
+                      showSuggestedFollows
+                        ? {color: t.palette.white}
+                        : t.atoms.text
+                    }
+                    size={14}
+                  />
+                </Button>
+              )}
+
+              <Button
+                testID={profile.viewer?.following ? 'unfollowBtn' : 'followBtn'}
+                size="small"
+                color={profile.viewer?.following ? 'secondary' : 'primary'}
+                variant="solid"
+                label={
+                  profile.viewer?.following
+                    ? _(msg`Unfollow ${profile.handle}`)
+                    : _(msg`Follow ${profile.handle}`)
+                }
+                disabled={!hasSession}
+                onPress={
+                  profile.viewer?.following ? onPressUnfollow : onPressFollow
+                }
+                style={[a.rounded_full, a.gap_xs]}>
+                <ButtonIcon
+                  position="left"
+                  icon={profile.viewer?.following ? Check : Plus}
+                />
+                <ButtonText>
+                  {profile.viewer?.following ? (
+                    <Trans>Following</Trans>
+                  ) : (
+                    <Trans>Follow</Trans>
+                  )}
+                </ButtonText>
+              </Button>
+            </>
+          ) : null}
+          <ProfileMenu profile={profile} />
+        </View>
+        <View style={[a.flex_col, a.gap_xs, a.pb_sm]}>
+          <ProfileHeaderDisplayName profile={profile} moderation={moderation} />
+          <ProfileHeaderHandle profile={profile} />
+        </View>
+        {!isPlaceholderProfile && (
+          <>
+            <ProfileHeaderMetrics profile={profile} />
+            {descriptionRT && !moderation.ui('profileView').blur ? (
+              <View pointerEvents="auto">
+                <RichText
+                  testID="profileHeaderDescription"
+                  style={[a.text_md]}
+                  numberOfLines={15}
+                  value={descriptionRT}
+                />
+              </View>
+            ) : undefined}
+          </>
+        )}
+      </View>
+      {showSuggestedFollows && (
+        <ProfileHeaderSuggestedFollows
+          actorDid={profile.did}
+          requestDismiss={() => {
+            if (showSuggestedFollows) {
+              setShowSuggestedFollows(false)
+            } else {
+              track('ProfileHeader:SuggestedFollowsOpened')
+              setShowSuggestedFollows(true)
+            }
+          }}
+        />
+      )}
+      <Prompt.Basic
+        control={unblockPromptControl}
+        title={_(msg`Unblock Account?`)}
+        description={_(
+          msg`The account will be able to interact with you after unblocking.`,
+        )}
+        onConfirm={unblockAccount}
+        confirmButtonCta={
+          profile.viewer?.blocking ? _(msg`Unblock`) : _(msg`Block`)
+        }
+        confirmButtonColor="negative"
+      />
+    </ProfileHeaderShell>
+  )
+}
+ProfileHeaderStandard = memo(ProfileHeaderStandard)
+export {ProfileHeaderStandard}
diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx
new file mode 100644
index 000000000..c470cb286
--- /dev/null
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -0,0 +1,164 @@
+import React, {memo} from 'react'
+import {StyleSheet, TouchableWithoutFeedback, View} from 'react-native'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useNavigation} from '@react-navigation/native'
+import {AppBskyActorDefs, ModerationDecision} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {NavigationProp} from 'lib/routes/types'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {BACK_HITSLOP} from 'lib/constants'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
+
+import {atoms as a, useTheme} from '#/alf'
+import {LabelsOnMe} from '#/components/moderation/LabelsOnMe'
+import {BlurView} from 'view/com/util/BlurView'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {UserAvatar} from 'view/com/util/UserAvatar'
+import {UserBanner} from 'view/com/util/UserBanner'
+import {ProfileHeaderAlerts} from '#/components/moderation/ProfileHeaderAlerts'
+
+interface Props {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+  moderation: ModerationDecision
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderShell = ({
+  children,
+  profile,
+  moderation,
+  hideBackButton = false,
+  isPlaceholderProfile,
+}: React.PropsWithChildren<Props>): React.ReactNode => {
+  const t = useTheme()
+  const {currentAccount} = useSession()
+  const {_} = useLingui()
+  const {openLightbox} = useLightboxControls()
+  const navigation = useNavigation<NavigationProp>()
+  const {isDesktop} = useWebMediaQueries()
+
+  const onPressBack = React.useCallback(() => {
+    if (navigation.canGoBack()) {
+      navigation.goBack()
+    } else {
+      navigation.navigate('Home')
+    }
+  }, [navigation])
+
+  const onPressAvi = React.useCallback(() => {
+    const modui = moderation.ui('avatar')
+    if (profile.avatar && !(modui.blur && modui.noOverride)) {
+      openLightbox(new ProfileImageLightbox(profile))
+    }
+  }, [openLightbox, profile, moderation])
+
+  const isMe = React.useMemo(
+    () => currentAccount?.did === profile.did,
+    [currentAccount, profile],
+  )
+
+  return (
+    <View style={t.atoms.bg} pointerEvents="box-none">
+      <View pointerEvents="none">
+        {isPlaceholderProfile ? (
+          <LoadingPlaceholder
+            width="100%"
+            height={150}
+            style={{borderRadius: 0}}
+          />
+        ) : (
+          <UserBanner
+            type={profile.associated?.labeler ? 'labeler' : 'default'}
+            banner={profile.banner}
+            moderation={moderation.ui('banner')}
+          />
+        )}
+      </View>
+
+      {children}
+
+      <View style={[a.px_lg, a.pb_sm]} pointerEvents="box-none">
+        <ProfileHeaderAlerts moderation={moderation} />
+        {isMe && (
+          <LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
+        )}
+      </View>
+
+      {!isDesktop && !hideBackButton && (
+        <TouchableWithoutFeedback
+          testID="profileHeaderBackBtn"
+          onPress={onPressBack}
+          hitSlop={BACK_HITSLOP}
+          accessibilityRole="button"
+          accessibilityLabel={_(msg`Back`)}
+          accessibilityHint="">
+          <View style={styles.backBtnWrapper}>
+            <BlurView style={styles.backBtn} blurType="dark">
+              <FontAwesomeIcon size={18} icon="angle-left" color="white" />
+            </BlurView>
+          </View>
+        </TouchableWithoutFeedback>
+      )}
+      <TouchableWithoutFeedback
+        testID="profileHeaderAviButton"
+        onPress={onPressAvi}
+        accessibilityRole="image"
+        accessibilityLabel={_(msg`View ${profile.handle}'s avatar`)}
+        accessibilityHint="">
+        <View
+          style={[
+            t.atoms.bg,
+            {borderColor: t.atoms.bg.backgroundColor},
+            styles.avi,
+            profile.associated?.labeler && styles.aviLabeler,
+          ]}>
+          <UserAvatar
+            type={profile.associated?.labeler ? 'labeler' : 'user'}
+            size={90}
+            avatar={profile.avatar}
+            moderation={moderation.ui('avatar')}
+          />
+        </View>
+      </TouchableWithoutFeedback>
+    </View>
+  )
+}
+ProfileHeaderShell = memo(ProfileHeaderShell)
+export {ProfileHeaderShell}
+
+const styles = StyleSheet.create({
+  backBtnWrapper: {
+    position: 'absolute',
+    top: 10,
+    left: 10,
+    width: 30,
+    height: 30,
+    overflow: 'hidden',
+    borderRadius: 15,
+    // @ts-ignore web only
+    cursor: 'pointer',
+  },
+  backBtn: {
+    width: 30,
+    height: 30,
+    borderRadius: 15,
+    alignItems: 'center',
+    justifyContent: 'center',
+  },
+  avi: {
+    position: 'absolute',
+    top: 110,
+    left: 10,
+    width: 94,
+    height: 94,
+    borderRadius: 47,
+    borderWidth: 2,
+  },
+  aviLabeler: {
+    borderRadius: 10,
+  },
+})
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
new file mode 100644
index 000000000..1280dd8b1
--- /dev/null
+++ b/src/screens/Profile/Header/index.tsx
@@ -0,0 +1,78 @@
+import React, {memo} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {
+  AppBskyActorDefs,
+  AppBskyLabelerDefs,
+  ModerationOpts,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {LoadingPlaceholder} from 'view/com/util/LoadingPlaceholder'
+import {usePalette} from 'lib/hooks/usePalette'
+
+import {ProfileHeaderStandard} from './ProfileHeaderStandard'
+import {ProfileHeaderLabeler} from './ProfileHeaderLabeler'
+
+let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
+  const pal = usePalette('default')
+  return (
+    <View style={pal.view}>
+      <LoadingPlaceholder width="100%" height={150} style={{borderRadius: 0}} />
+      <View
+        style={[pal.view, {borderColor: pal.colors.background}, styles.avi]}>
+        <LoadingPlaceholder width={80} height={80} style={styles.br40} />
+      </View>
+      <View style={styles.content}>
+        <View style={[styles.buttonsLine]}>
+          <LoadingPlaceholder width={167} height={31} style={styles.br50} />
+        </View>
+      </View>
+    </View>
+  )
+}
+ProfileHeaderLoading = memo(ProfileHeaderLoading)
+export {ProfileHeaderLoading}
+
+interface Props {
+  profile: AppBskyActorDefs.ProfileViewDetailed
+  labeler: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+  descriptionRT: RichTextAPI | null
+  moderationOpts: ModerationOpts
+  hideBackButton?: boolean
+  isPlaceholderProfile?: boolean
+}
+
+let ProfileHeader = (props: Props): React.ReactNode => {
+  if (props.profile.associated?.labeler) {
+    if (!props.labeler) {
+      return <ProfileHeaderLoading />
+    }
+    return <ProfileHeaderLabeler {...props} labeler={props.labeler} />
+  }
+  return <ProfileHeaderStandard {...props} />
+}
+ProfileHeader = memo(ProfileHeader)
+export {ProfileHeader}
+
+const styles = StyleSheet.create({
+  avi: {
+    position: 'absolute',
+    top: 110,
+    left: 10,
+    width: 84,
+    height: 84,
+    borderRadius: 42,
+    borderWidth: 2,
+  },
+  content: {
+    paddingTop: 8,
+    paddingHorizontal: 14,
+    paddingBottom: 4,
+  },
+  buttonsLine: {
+    flexDirection: 'row',
+    marginLeft: 'auto',
+    marginBottom: 12,
+  },
+  br40: {borderRadius: 40},
+  br50: {borderRadius: 50},
+})
diff --git a/src/screens/Profile/ProfileLabelerLikedBy.tsx b/src/screens/Profile/ProfileLabelerLikedBy.tsx
new file mode 100644
index 000000000..1d2167520
--- /dev/null
+++ b/src/screens/Profile/ProfileLabelerLikedBy.tsx
@@ -0,0 +1,46 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {NativeStackScreenProps, CommonNavigatorParams} from '#/lib/routes/types'
+import {ViewHeader} from '#/view/com/util/ViewHeader'
+import {LikedByList} from '#/components/LikedByList'
+import {useSetMinimalShellMode} from '#/state/shell'
+import {makeRecordUri} from '#/lib/strings/url-helpers'
+
+import {atoms as a, useBreakpoints} from '#/alf'
+
+export function ProfileLabelerLikedByScreen({
+  route,
+}: NativeStackScreenProps<CommonNavigatorParams, 'ProfileLabelerLikedBy'>) {
+  const setMinimalShellMode = useSetMinimalShellMode()
+  const {name: handleOrDid} = route.params
+  const uri = makeRecordUri(handleOrDid, 'app.bsky.labeler.service', 'self')
+  const {_} = useLingui()
+  const {gtMobile} = useBreakpoints()
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setMinimalShellMode(false)
+    }, [setMinimalShellMode]),
+  )
+
+  return (
+    <View
+      style={[
+        a.mx_auto,
+        a.w_full,
+        a.h_full_vh,
+        gtMobile && [
+          {
+            maxWidth: 600,
+          },
+        ],
+      ]}>
+      <ViewHeader title={_(msg`Liked By`)} />
+      <LikedByList uri={uri} />
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Sections/Feed.tsx b/src/screens/Profile/Sections/Feed.tsx
new file mode 100644
index 000000000..0a5e2208d
--- /dev/null
+++ b/src/screens/Profile/Sections/Feed.tsx
@@ -0,0 +1,88 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {ListRef} from 'view/com/util/List'
+import {Feed} from 'view/com/posts/Feed'
+import {EmptyState} from 'view/com/util/EmptyState'
+import {FeedDescriptor} from '#/state/queries/post-feed'
+import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
+import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
+import {useQueryClient} from '@tanstack/react-query'
+import {truncateAndInvalidate} from '#/state/queries/util'
+import {Text} from '#/view/com/util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {isNative} from '#/platform/detection'
+import {SectionRef} from './types'
+
+interface FeedSectionProps {
+  feed: FeedDescriptor
+  headerHeight: number
+  isFocused: boolean
+  scrollElRef: ListRef
+  ignoreFilterFor?: string
+}
+export const ProfileFeedSection = React.forwardRef<
+  SectionRef,
+  FeedSectionProps
+>(function FeedSectionImpl(
+  {feed, headerHeight, isFocused, scrollElRef, ignoreFilterFor},
+  ref,
+) {
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
+  const [hasNew, setHasNew] = React.useState(false)
+  const [isScrolledDown, setIsScrolledDown] = React.useState(false)
+
+  const onScrollToTop = React.useCallback(() => {
+    scrollElRef.current?.scrollToOffset({
+      animated: isNative,
+      offset: -headerHeight,
+    })
+    truncateAndInvalidate(queryClient, FEED_RQKEY(feed))
+    setHasNew(false)
+  }, [scrollElRef, headerHeight, queryClient, feed, setHasNew])
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: onScrollToTop,
+  }))
+
+  const renderPostsEmpty = React.useCallback(() => {
+    return <EmptyState icon="feed" message={_(msg`This feed is empty!`)} />
+  }, [_])
+
+  return (
+    <View>
+      <Feed
+        testID="postsFeed"
+        enabled={isFocused}
+        feed={feed}
+        scrollElRef={scrollElRef}
+        onHasNew={setHasNew}
+        onScrolledDownChange={setIsScrolledDown}
+        renderEmptyState={renderPostsEmpty}
+        headerOffset={headerHeight}
+        renderEndOfFeed={ProfileEndOfFeed}
+        ignoreFilterFor={ignoreFilterFor}
+      />
+      {(isScrolledDown || hasNew) && (
+        <LoadLatestBtn
+          onPress={onScrollToTop}
+          label={_(msg`Load new posts`)}
+          showIndicator={hasNew}
+        />
+      )}
+    </View>
+  )
+})
+
+function ProfileEndOfFeed() {
+  const pal = usePalette('default')
+
+  return (
+    <View style={[pal.border, {paddingTop: 32, borderTopWidth: 1}]}>
+      <Text style={[pal.textLight, pal.border, {textAlign: 'center'}]}>
+        <Trans>End of feed</Trans>
+      </Text>
+    </View>
+  )
+}
diff --git a/src/screens/Profile/Sections/Labels.tsx b/src/screens/Profile/Sections/Labels.tsx
new file mode 100644
index 000000000..5ba8f00a5
--- /dev/null
+++ b/src/screens/Profile/Sections/Labels.tsx
@@ -0,0 +1,214 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+import {
+  AppBskyLabelerDefs,
+  InterpretedLabelValueDefinition,
+  interpretLabelValueDefinitions,
+  ModerationOpts,
+} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {isLabelerSubscribed, lookupLabelValueDefinition} from '#/lib/moderation'
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {isNative} from '#/platform/detection'
+import {ListRef} from '#/view/com/util/List'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {atoms as a, useTheme} from '#/alf'
+import {Divider} from '#/components/Divider'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {Loader} from '#/components/Loader'
+import {LabelerLabelPreference} from '#/components/moderation/LabelPreference'
+import {Text} from '#/components/Typography'
+import {ErrorState} from '../ErrorState'
+import {SectionRef} from './types'
+
+interface LabelsSectionProps {
+  isLabelerLoading: boolean
+  labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed | undefined
+  labelerError: Error | null
+  moderationOpts: ModerationOpts
+  scrollElRef: ListRef
+  headerHeight: number
+}
+export const ProfileLabelsSection = React.forwardRef<
+  SectionRef,
+  LabelsSectionProps
+>(function LabelsSectionImpl(
+  {
+    isLabelerLoading,
+    labelerInfo,
+    labelerError,
+    moderationOpts,
+    scrollElRef,
+    headerHeight,
+  },
+  ref,
+) {
+  const {_} = useLingui()
+  const {height: minHeight} = useSafeAreaFrame()
+
+  const onScrollToTop = React.useCallback(() => {
+    // @ts-ignore TODO fix this
+    scrollElRef.current?.scrollTo({
+      animated: isNative,
+      x: 0,
+      y: -headerHeight,
+    })
+  }, [scrollElRef, headerHeight])
+
+  React.useImperativeHandle(ref, () => ({
+    scrollToTop: onScrollToTop,
+  }))
+
+  return (
+    <CenteredView style={{flex: 1, minHeight}} sideBorders>
+      {isLabelerLoading ? (
+        <View style={[a.w_full, a.align_center]}>
+          <Loader size="xl" />
+        </View>
+      ) : labelerError || !labelerInfo ? (
+        <ErrorState
+          error={
+            labelerError?.toString() ||
+            _(msg`Something went wrong, please try again.`)
+          }
+        />
+      ) : (
+        <ProfileLabelsSectionInner
+          moderationOpts={moderationOpts}
+          labelerInfo={labelerInfo}
+          scrollElRef={scrollElRef}
+          headerHeight={headerHeight}
+        />
+      )}
+    </CenteredView>
+  )
+})
+
+export function ProfileLabelsSectionInner({
+  moderationOpts,
+  labelerInfo,
+  scrollElRef,
+  headerHeight,
+}: {
+  moderationOpts: ModerationOpts
+  labelerInfo: AppBskyLabelerDefs.LabelerViewDetailed
+  scrollElRef: ListRef
+  headerHeight: number
+}) {
+  const t = useTheme()
+  const contextScrollHandlers = useScrollHandlers()
+
+  const scrollHandler = useAnimatedScrollHandler({
+    onBeginDrag(e, ctx) {
+      contextScrollHandlers.onBeginDrag?.(e, ctx)
+    },
+    onEndDrag(e, ctx) {
+      contextScrollHandlers.onEndDrag?.(e, ctx)
+    },
+    onScroll(e, ctx) {
+      contextScrollHandlers.onScroll?.(e, ctx)
+    },
+  })
+
+  const {labelValues} = labelerInfo.policies
+  const isSubscribed = isLabelerSubscribed(labelerInfo, moderationOpts)
+  const labelDefs = React.useMemo(() => {
+    const customDefs = interpretLabelValueDefinitions(labelerInfo)
+    return labelValues
+      .map(val => lookupLabelValueDefinition(val, customDefs))
+      .filter(
+        def => def && def?.configurable,
+      ) as InterpretedLabelValueDefinition[]
+  }, [labelerInfo, labelValues])
+
+  return (
+    <ScrollView
+      // @ts-ignore TODO fix this
+      ref={scrollElRef}
+      scrollEventThrottle={1}
+      contentContainerStyle={{
+        paddingTop: headerHeight,
+        borderWidth: 0,
+      }}
+      contentOffset={{x: 0, y: headerHeight * -1}}
+      onScroll={scrollHandler}>
+      <View style={[a.pt_xl, a.px_lg, a.border_t, t.atoms.border_contrast_low]}>
+        <View>
+          <Text style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
+            <Trans>
+              Labels are annotations on users and content. They can be used to
+              hide, warn, and categorize the network.
+            </Trans>
+          </Text>
+          {labelerInfo.creator.viewer?.blocking ? (
+            <View style={[a.flex_row, a.gap_sm, a.align_center, a.mt_md]}>
+              <CircleInfo size="sm" fill={t.atoms.text_contrast_medium.color} />
+              <Text
+                style={[t.atoms.text_contrast_high, a.leading_snug, a.text_sm]}>
+                <Trans>
+                  Blocking does not prevent this labeler from placing labels on
+                  your account.
+                </Trans>
+              </Text>
+            </View>
+          ) : null}
+          {labelValues.length === 0 ? (
+            <Text
+              style={[
+                a.pt_xl,
+                t.atoms.text_contrast_high,
+                a.leading_snug,
+                a.text_sm,
+              ]}>
+              <Trans>
+                This labeler hasn't declared what labels it publishes, and may
+                not be active.
+              </Trans>
+            </Text>
+          ) : !isSubscribed ? (
+            <Text
+              style={[
+                a.pt_xl,
+                t.atoms.text_contrast_high,
+                a.leading_snug,
+                a.text_sm,
+              ]}>
+              <Trans>
+                Subscribe to @{labelerInfo.creator.handle} to use these labels:
+              </Trans>
+            </Text>
+          ) : null}
+        </View>
+        {labelDefs.length > 0 && (
+          <View
+            style={[
+              a.mt_xl,
+              a.w_full,
+              a.rounded_md,
+              a.overflow_hidden,
+              t.atoms.bg_contrast_25,
+            ]}>
+            {labelDefs.map((labelDef, i) => {
+              return (
+                <React.Fragment key={labelDef.identifier}>
+                  {i !== 0 && <Divider />}
+                  <LabelerLabelPreference
+                    disabled={isSubscribed ? undefined : true}
+                    labelDefinition={labelDef}
+                    labelerDid={labelerInfo.creator.did}
+                  />
+                </React.Fragment>
+              )
+            })}
+          </View>
+        )}
+
+        <View style={{height: 400}} />
+      </View>
+    </ScrollView>
+  )
+}
diff --git a/src/screens/Profile/Sections/types.ts b/src/screens/Profile/Sections/types.ts
new file mode 100644
index 000000000..a7f77d648
--- /dev/null
+++ b/src/screens/Profile/Sections/types.ts
@@ -0,0 +1,3 @@
+export interface SectionRef {
+  scrollToTop: () => void
+}
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
new file mode 100644
index 000000000..50918c4ce
--- /dev/null
+++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.tsx
@@ -0,0 +1,87 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+import {WebView, WebViewNavigation} from 'react-native-webview'
+import {ShouldStartLoadRequest} from 'react-native-webview/lib/WebViewTypes'
+
+import {SignupState} from '#/screens/Signup/state'
+
+const ALLOWED_HOSTS = [
+  'bsky.social',
+  'bsky.app',
+  'staging.bsky.app',
+  'staging.bsky.dev',
+  'js.hcaptcha.com',
+  'newassets.hcaptcha.com',
+  'api2.hcaptcha.com',
+]
+
+export function CaptchaWebView({
+  url,
+  stateParam,
+  state,
+  onSuccess,
+  onError,
+}: {
+  url: string
+  stateParam: string
+  state?: SignupState
+  onSuccess: (code: string) => void
+  onError: () => void
+}) {
+  const redirectHost = React.useMemo(() => {
+    if (!state?.serviceUrl) return 'bsky.app'
+
+    return state?.serviceUrl &&
+      new URL(state?.serviceUrl).host === 'staging.bsky.dev'
+      ? 'staging.bsky.app'
+      : 'bsky.app'
+  }, [state?.serviceUrl])
+
+  const wasSuccessful = React.useRef(false)
+
+  const onShouldStartLoadWithRequest = React.useCallback(
+    (event: ShouldStartLoadRequest) => {
+      const urlp = new URL(event.url)
+      return ALLOWED_HOSTS.includes(urlp.host)
+    },
+    [],
+  )
+
+  const onNavigationStateChange = React.useCallback(
+    (e: WebViewNavigation) => {
+      if (wasSuccessful.current) return
+
+      const urlp = new URL(e.url)
+      if (urlp.host !== redirectHost) return
+
+      const code = urlp.searchParams.get('code')
+      if (urlp.searchParams.get('state') !== stateParam || !code) {
+        onError()
+        return
+      }
+
+      wasSuccessful.current = true
+      onSuccess(code)
+    },
+    [redirectHost, stateParam, onSuccess, onError],
+  )
+
+  return (
+    <WebView
+      source={{uri: url}}
+      javaScriptEnabled
+      style={styles.webview}
+      onShouldStartLoadWithRequest={onShouldStartLoadWithRequest}
+      onNavigationStateChange={onNavigationStateChange}
+      scrollEnabled={false}
+    />
+  )
+}
+
+const styles = StyleSheet.create({
+  webview: {
+    flex: 1,
+    backgroundColor: 'transparent',
+    borderRadius: 10,
+  },
+})
diff --git a/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
new file mode 100644
index 000000000..7791a58dd
--- /dev/null
+++ b/src/screens/Signup/StepCaptcha/CaptchaWebView.web.tsx
@@ -0,0 +1,61 @@
+import React from 'react'
+import {StyleSheet} from 'react-native'
+
+// @ts-ignore web only, we will always redirect to the app on web (CORS)
+const REDIRECT_HOST = new URL(window.location.href).host
+
+export function CaptchaWebView({
+  url,
+  stateParam,
+  onSuccess,
+  onError,
+}: {
+  url: string
+  stateParam: string
+  onSuccess: (code: string) => void
+  onError: () => void
+}) {
+  const onLoad = React.useCallback(() => {
+    // @ts-ignore web
+    const frame: HTMLIFrameElement = document.getElementById(
+      'captcha-iframe',
+    ) as HTMLIFrameElement
+
+    try {
+      // @ts-ignore web
+      const href = frame?.contentWindow?.location.href
+      if (!href) return
+      const urlp = new URL(href)
+
+      // This shouldn't happen with CORS protections, but for good measure
+      if (urlp.host !== REDIRECT_HOST) return
+
+      const code = urlp.searchParams.get('code')
+      if (urlp.searchParams.get('state') !== stateParam || !code) {
+        onError()
+        return
+      }
+      onSuccess(code)
+    } catch (e) {
+      // We don't need to handle this
+    }
+  }, [stateParam, onSuccess, onError])
+
+  return (
+    <iframe
+      src={url}
+      style={styles.iframe}
+      id="captcha-iframe"
+      onLoad={onLoad}
+    />
+  )
+}
+
+const styles = StyleSheet.create({
+  iframe: {
+    flex: 1,
+    borderWidth: 0,
+    borderRadius: 10,
+    backgroundColor: 'transparent',
+  },
+})
diff --git a/src/screens/Signup/StepCaptcha/index.tsx b/src/screens/Signup/StepCaptcha/index.tsx
new file mode 100644
index 000000000..2429b0c5e
--- /dev/null
+++ b/src/screens/Signup/StepCaptcha/index.tsx
@@ -0,0 +1,80 @@
+import React from 'react'
+import {ActivityIndicator, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {nanoid} from 'nanoid/non-secure'
+
+import {createFullHandle} from '#/lib/strings/handles'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {useSignupContext, useSubmitSignup} from '#/screens/Signup/state'
+import {CaptchaWebView} from '#/screens/Signup/StepCaptcha/CaptchaWebView'
+import {atoms as a, useTheme} from '#/alf'
+import {FormError} from '#/components/forms/FormError'
+
+const CAPTCHA_PATH = '/gate/signup'
+
+export function StepCaptcha() {
+  const {_} = useLingui()
+  const theme = useTheme()
+  const {state, dispatch} = useSignupContext()
+  const submit = useSubmitSignup({state, dispatch})
+
+  const [completed, setCompleted] = React.useState(false)
+
+  const stateParam = React.useMemo(() => nanoid(15), [])
+  const url = React.useMemo(() => {
+    const newUrl = new URL(state.serviceUrl)
+    newUrl.pathname = CAPTCHA_PATH
+    newUrl.searchParams.set(
+      'handle',
+      createFullHandle(state.handle, state.userDomain),
+    )
+    newUrl.searchParams.set('state', stateParam)
+    newUrl.searchParams.set('colorScheme', theme.name)
+
+    return newUrl.href
+  }, [state.serviceUrl, state.handle, state.userDomain, stateParam, theme.name])
+
+  const onSuccess = React.useCallback(
+    (code: string) => {
+      setCompleted(true)
+      submit(code)
+    },
+    [submit],
+  )
+
+  const onError = React.useCallback(() => {
+    dispatch({
+      type: 'setError',
+      value: _(msg`Error receiving captcha response.`),
+    })
+  }, [_, dispatch])
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <View
+          style={[
+            a.w_full,
+            a.pb_xl,
+            a.overflow_hidden,
+            {minHeight: 500},
+            completed && [a.align_center, a.justify_center],
+          ]}>
+          {!completed ? (
+            <CaptchaWebView
+              url={url}
+              stateParam={stateParam}
+              state={state}
+              onSuccess={onSuccess}
+              onError={onError}
+            />
+          ) : (
+            <ActivityIndicator size="large" />
+          )}
+        </View>
+        <FormError error={state.error} />
+      </View>
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/Signup/StepHandle.tsx b/src/screens/Signup/StepHandle.tsx
new file mode 100644
index 000000000..44a33b833
--- /dev/null
+++ b/src/screens/Signup/StepHandle.tsx
@@ -0,0 +1,134 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useFocusEffect} from '@react-navigation/native'
+
+import {
+  createFullHandle,
+  IsValidHandle,
+  validateHandle,
+} from '#/lib/strings/handles'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {useSignupContext} from '#/screens/Signup/state'
+import {atoms as a, useTheme} from '#/alf'
+import * as TextField from '#/components/forms/TextField'
+import {At_Stroke2_Corner0_Rounded as At} from '#/components/icons/At'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+export function StepHandle() {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {state, dispatch} = useSignupContext()
+
+  const [validCheck, setValidCheck] = React.useState<IsValidHandle>({
+    handleChars: false,
+    hyphenStartOrEnd: false,
+    frontLength: false,
+    totalLength: true,
+    overall: false,
+  })
+
+  useFocusEffect(
+    React.useCallback(() => {
+      setValidCheck(validateHandle(state.handle, state.userDomain))
+    }, [state.handle, state.userDomain]),
+  )
+
+  const onHandleChange = React.useCallback(
+    (value: string) => {
+      if (state.error) {
+        dispatch({type: 'setError', value: ''})
+      }
+
+      dispatch({
+        type: 'setHandle',
+        value,
+      })
+    },
+    [dispatch, state.error],
+  )
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_lg]}>
+        <View>
+          <TextField.Root>
+            <TextField.Icon icon={At} />
+            <TextField.Input
+              onChangeText={onHandleChange}
+              label={_(msg`Input your user handle`)}
+              defaultValue={state.handle}
+              autoCapitalize="none"
+              autoCorrect={false}
+              autoFocus
+              autoComplete="off"
+            />
+          </TextField.Root>
+        </View>
+        <Text style={[a.text_md]}>
+          <Trans>Your full handle will be</Trans>{' '}
+          <Text style={[a.text_md, a.font_bold]}>
+            @{createFullHandle(state.handle, state.userDomain)}
+          </Text>
+        </Text>
+
+        <View
+          style={[
+            a.w_full,
+            a.rounded_sm,
+            a.border,
+            a.p_md,
+            a.gap_sm,
+            t.atoms.border_contrast_low,
+          ]}>
+          {state.error ? (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={false} />
+              <Text style={[a.text_md, a.flex_1]}>{state.error}</Text>
+            </View>
+          ) : undefined}
+          {validCheck.hyphenStartOrEnd ? (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={validCheck.handleChars} />
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>Only contains letters, numbers, and hyphens</Trans>
+              </Text>
+            </View>
+          ) : (
+            <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+              <IsValidIcon valid={validCheck.hyphenStartOrEnd} />
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>Doesn't begin or end with a hyphen</Trans>
+              </Text>
+            </View>
+          )}
+          <View style={[a.w_full, a.flex_row, a.align_center, a.gap_sm]}>
+            <IsValidIcon
+              valid={validCheck.frontLength && validCheck.totalLength}
+            />
+            {!validCheck.totalLength ? (
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>No longer than 253 characters</Trans>
+              </Text>
+            ) : (
+              <Text style={[a.text_md, a.flex_1]}>
+                <Trans>At least 3 characters</Trans>
+              </Text>
+            )}
+          </View>
+        </View>
+      </View>
+    </ScreenTransition>
+  )
+}
+
+function IsValidIcon({valid}: {valid: boolean}) {
+  const t = useTheme()
+  if (!valid) {
+    return <Times size="md" style={{color: t.palette.negative_500}} />
+  }
+  return <Check size="md" style={{color: t.palette.positive_700}} />
+}
diff --git a/src/screens/Signup/StepInfo/Policies.tsx b/src/screens/Signup/StepInfo/Policies.tsx
new file mode 100644
index 000000000..4879ae7b3
--- /dev/null
+++ b/src/screens/Signup/StepInfo/Policies.tsx
@@ -0,0 +1,97 @@
+import React from 'react'
+import {View} from 'react-native'
+import {ComAtprotoServerDescribeServer} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useTheme} from '#/alf'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export const Policies = ({
+  serviceDescription,
+  needsGuardian,
+  under13,
+}: {
+  serviceDescription: ComAtprotoServerDescribeServer.OutputSchema
+  needsGuardian: boolean
+  under13: boolean
+}) => {
+  const t = useTheme()
+  const {_} = useLingui()
+
+  if (!serviceDescription) {
+    return <View />
+  }
+
+  const tos = validWebLink(serviceDescription.links?.termsOfService)
+  const pp = validWebLink(serviceDescription.links?.privacyPolicy)
+
+  if (!tos && !pp) {
+    return (
+      <View style={[a.flex_row, a.align_center, a.gap_xs]}>
+        <CircleInfo size="md" fill={t.atoms.text_contrast_low.color} />
+
+        <Text style={[t.atoms.text_contrast_medium]}>
+          <Trans>
+            This service has not provided terms of service or a privacy policy.
+          </Trans>
+        </Text>
+      </View>
+    )
+  }
+
+  const els = []
+  if (tos) {
+    els.push(
+      <InlineLink key="tos" to={tos}>
+        {_(msg`Terms of Service`)}
+      </InlineLink>,
+    )
+  }
+  if (pp) {
+    els.push(
+      <InlineLink key="pp" to={pp}>
+        {_(msg`Privacy Policy`)}
+      </InlineLink>,
+    )
+  }
+  if (els.length === 2) {
+    els.splice(
+      1,
+      0,
+      <Text key="and" style={[t.atoms.text_contrast_medium]}>
+        {' '}
+        and{' '}
+      </Text>,
+    )
+  }
+
+  return (
+    <View style={[a.gap_sm]}>
+      <Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
+        <Trans>By creating an account you agree to the {els}.</Trans>
+      </Text>
+
+      {under13 ? (
+        <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+          <Trans>You must be 13 years of age or older to sign up.</Trans>
+        </Text>
+      ) : needsGuardian ? (
+        <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
+          <Trans>
+            If you are not yet an adult according to the laws of your country,
+            your parent or legal guardian must read these Terms on your behalf.
+          </Trans>
+        </Text>
+      ) : undefined}
+    </View>
+  )
+}
+
+function validWebLink(url?: string): string | undefined {
+  return url && (url.startsWith('http://') || url.startsWith('https://'))
+    ? url
+    : undefined
+}
diff --git a/src/screens/Signup/StepInfo/index.tsx b/src/screens/Signup/StepInfo/index.tsx
new file mode 100644
index 000000000..136592a0b
--- /dev/null
+++ b/src/screens/Signup/StepInfo/index.tsx
@@ -0,0 +1,146 @@
+import React from 'react'
+import {View} from 'react-native'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {logger} from '#/logger'
+import {ScreenTransition} from '#/screens/Login/ScreenTransition'
+import {is13, is18, useSignupContext} from '#/screens/Signup/state'
+import {Policies} from '#/screens/Signup/StepInfo/Policies'
+import {atoms as a} from '#/alf'
+import * as DateField from '#/components/forms/DateField'
+import {FormError} from '#/components/forms/FormError'
+import {HostingProvider} from '#/components/forms/HostingProvider'
+import * as TextField from '#/components/forms/TextField'
+import {Envelope_Stroke2_Corner0_Rounded as Envelope} from '#/components/icons/Envelope'
+import {Lock_Stroke2_Corner0_Rounded as Lock} from '#/components/icons/Lock'
+import {Ticket_Stroke2_Corner0_Rounded as Ticket} from '#/components/icons/Ticket'
+import {Loader} from '#/components/Loader'
+
+function sanitizeDate(date: Date): Date {
+  if (!date || date.toString() === 'Invalid Date') {
+    logger.error(`Create account: handled invalid date for birthDate`, {
+      hasDate: !!date,
+    })
+    return new Date()
+  }
+  return date
+}
+
+export function StepInfo() {
+  const {_} = useLingui()
+  const {state, dispatch} = useSignupContext()
+
+  return (
+    <ScreenTransition>
+      <View style={[a.gap_md]}>
+        <FormError error={state.error} />
+        <View>
+          <TextField.Label>
+            <Trans>Hosting provider</Trans>
+          </TextField.Label>
+          <HostingProvider
+            serviceUrl={state.serviceUrl}
+            onSelectServiceUrl={v =>
+              dispatch({type: 'setServiceUrl', value: v})
+            }
+          />
+        </View>
+        {state.isLoading ? (
+          <View style={[a.align_center]}>
+            <Loader size="xl" />
+          </View>
+        ) : state.serviceDescription ? (
+          <>
+            {state.serviceDescription.inviteCodeRequired && (
+              <View>
+                <TextField.Label>
+                  <Trans>Invite code</Trans>
+                </TextField.Label>
+                <TextField.Root>
+                  <TextField.Icon icon={Ticket} />
+                  <TextField.Input
+                    onChangeText={value => {
+                      dispatch({
+                        type: 'setInviteCode',
+                        value: value.trim(),
+                      })
+                    }}
+                    label={_(msg`Required for this provider`)}
+                    defaultValue={state.inviteCode}
+                    autoCapitalize="none"
+                    autoComplete="email"
+                    keyboardType="email-address"
+                  />
+                </TextField.Root>
+              </View>
+            )}
+            <View>
+              <TextField.Label>
+                <Trans>Email</Trans>
+              </TextField.Label>
+              <TextField.Root>
+                <TextField.Icon icon={Envelope} />
+                <TextField.Input
+                  onChangeText={value => {
+                    dispatch({
+                      type: 'setEmail',
+                      value: value.trim(),
+                    })
+                  }}
+                  label={_(msg`Enter your email address`)}
+                  defaultValue={state.email}
+                  autoCapitalize="none"
+                  autoComplete="email"
+                  keyboardType="email-address"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <TextField.Label>
+                <Trans>Password</Trans>
+              </TextField.Label>
+              <TextField.Root>
+                <TextField.Icon icon={Lock} />
+                <TextField.Input
+                  onChangeText={value => {
+                    dispatch({
+                      type: 'setPassword',
+                      value,
+                    })
+                  }}
+                  label={_(msg`Choose your password`)}
+                  defaultValue={state.password}
+                  secureTextEntry
+                  autoComplete="new-password"
+                />
+              </TextField.Root>
+            </View>
+            <View>
+              <DateField.Label>
+                <Trans>Your birth date</Trans>
+              </DateField.Label>
+              <DateField.DateField
+                testID="date"
+                value={DateField.utils.toSimpleDateString(state.dateOfBirth)}
+                onChangeDate={date => {
+                  dispatch({
+                    type: 'setDateOfBirth',
+                    value: sanitizeDate(new Date(date)),
+                  })
+                }}
+                label={_(msg`Date of birth`)}
+                accessibilityHint={_(msg`Select your date of birth`)}
+              />
+            </View>
+            <Policies
+              serviceDescription={state.serviceDescription}
+              needsGuardian={!is18(state.dateOfBirth)}
+              under13={!is13(state.dateOfBirth)}
+            />
+          </>
+        ) : undefined}
+      </View>
+    </ScreenTransition>
+  )
+}
diff --git a/src/screens/Signup/index.tsx b/src/screens/Signup/index.tsx
new file mode 100644
index 000000000..a085fe44c
--- /dev/null
+++ b/src/screens/Signup/index.tsx
@@ -0,0 +1,228 @@
+import React from 'react'
+import {View} from 'react-native'
+import {LayoutAnimationConfig} from 'react-native-reanimated'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {FEEDBACK_FORM_URL} from '#/lib/constants'
+import {logEvent} from '#/lib/statsig/statsig'
+import {createFullHandle} from '#/lib/strings/handles'
+import {useServiceQuery} from '#/state/queries/service'
+import {getAgent} from '#/state/session'
+import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
+import {
+  initialState,
+  reducer,
+  SignupContext,
+  SignupStep,
+  useSubmitSignup,
+} from '#/screens/Signup/state'
+import {StepCaptcha} from '#/screens/Signup/StepCaptcha'
+import {StepHandle} from '#/screens/Signup/StepHandle'
+import {StepInfo} from '#/screens/Signup/StepInfo'
+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Divider} from '#/components/Divider'
+import {InlineLink} from '#/components/Link'
+import {Text} from '#/components/Typography'
+
+export function Signup({onPressBack}: {onPressBack: () => void}) {
+  const {_} = useLingui()
+  const t = useTheme()
+  const {screen} = useAnalytics()
+  const [state, dispatch] = React.useReducer(reducer, initialState)
+  const submit = useSubmitSignup({state, dispatch})
+  const {gtMobile} = useBreakpoints()
+
+  const {
+    data: serviceInfo,
+    isFetching,
+    isError,
+    refetch,
+  } = useServiceQuery(state.serviceUrl)
+
+  React.useEffect(() => {
+    screen('CreateAccount')
+  }, [screen])
+
+  React.useEffect(() => {
+    if (isFetching) {
+      dispatch({type: 'setIsLoading', value: true})
+    } else if (!isFetching) {
+      dispatch({type: 'setIsLoading', value: false})
+    }
+  }, [isFetching])
+
+  React.useEffect(() => {
+    if (isError) {
+      dispatch({type: 'setServiceDescription', value: undefined})
+      dispatch({
+        type: 'setError',
+        value: _(
+          msg`Unable to contact your service. Please check your Internet connection.`,
+        ),
+      })
+    } else if (serviceInfo) {
+      dispatch({type: 'setServiceDescription', value: serviceInfo})
+      dispatch({type: 'setError', value: ''})
+    }
+  }, [_, serviceInfo, isError])
+
+  const onNextPress = React.useCallback(async () => {
+    if (state.activeStep === SignupStep.HANDLE) {
+      try {
+        dispatch({type: 'setIsLoading', value: true})
+
+        const res = await getAgent().resolveHandle({
+          handle: createFullHandle(state.handle, state.userDomain),
+        })
+
+        if (res.data.did) {
+          dispatch({
+            type: 'setError',
+            value: _(msg`That handle is already taken.`),
+          })
+          return
+        }
+      } catch (e) {
+        // Don't have to handle
+      } finally {
+        dispatch({type: 'setIsLoading', value: false})
+      }
+    }
+
+    // phoneVerificationRequired is actually whether a captcha is required
+    if (
+      state.activeStep === SignupStep.HANDLE &&
+      !state.serviceDescription?.phoneVerificationRequired
+    ) {
+      submit()
+      return
+    }
+
+    dispatch({type: 'next'})
+    logEvent('signup:nextPressed', {
+      activeStep: state.activeStep,
+    })
+  }, [
+    _,
+    state.activeStep,
+    state.handle,
+    state.serviceDescription?.phoneVerificationRequired,
+    state.userDomain,
+    submit,
+  ])
+
+  const onBackPress = React.useCallback(() => {
+    if (state.activeStep !== SignupStep.INFO) {
+      dispatch({type: 'prev'})
+    } else {
+      onPressBack()
+    }
+  }, [onPressBack, state.activeStep])
+
+  return (
+    <SignupContext.Provider value={{state, dispatch}}>
+      <LoggedOutLayout
+        leadin=""
+        title={_(msg`Create Account`)}
+        description={_(msg`We're so excited to have you join us!`)}
+        scrollable>
+        <View testID="createAccount" style={a.flex_1}>
+          <View
+            style={[
+              a.flex_1,
+              a.px_xl,
+              a.pt_2xl,
+              !gtMobile && {paddingBottom: 100},
+            ]}>
+            <View style={[a.gap_sm, a.pb_3xl]}>
+              <Text style={[a.font_semibold, t.atoms.text_contrast_medium]}>
+                <Trans>Step</Trans> {state.activeStep + 1} <Trans>of</Trans>{' '}
+                {state.serviceDescription &&
+                !state.serviceDescription.phoneVerificationRequired
+                  ? '2'
+                  : '3'}
+              </Text>
+              <Text style={[a.text_3xl, a.font_bold]}>
+                {state.activeStep === SignupStep.INFO ? (
+                  <Trans>Your account</Trans>
+                ) : state.activeStep === SignupStep.HANDLE ? (
+                  <Trans>Your user handle</Trans>
+                ) : (
+                  <Trans>Complete the challenge</Trans>
+                )}
+              </Text>
+            </View>
+
+            <View style={[a.pb_3xl]}>
+              <LayoutAnimationConfig skipEntering skipExiting>
+                {state.activeStep === SignupStep.INFO ? (
+                  <StepInfo />
+                ) : state.activeStep === SignupStep.HANDLE ? (
+                  <StepHandle />
+                ) : (
+                  <StepCaptcha />
+                )}
+              </LayoutAnimationConfig>
+            </View>
+
+            <View style={[a.flex_row, a.justify_between, a.pb_lg]}>
+              <Button
+                label={_(msg`Go back to previous step`)}
+                variant="solid"
+                color="secondary"
+                size="medium"
+                onPress={onBackPress}>
+                <ButtonText>
+                  <Trans>Back</Trans>
+                </ButtonText>
+              </Button>
+              {state.activeStep !== SignupStep.CAPTCHA && (
+                <>
+                  {isError ? (
+                    <Button
+                      label={_(msg`Press to retry`)}
+                      variant="solid"
+                      color="primary"
+                      size="medium"
+                      disabled={state.isLoading}
+                      onPress={() => refetch()}>
+                      <ButtonText>
+                        <Trans>Retry</Trans>
+                      </ButtonText>
+                    </Button>
+                  ) : (
+                    <Button
+                      label={_(msg`Continue to next step`)}
+                      variant="solid"
+                      color="primary"
+                      size="medium"
+                      disabled={!state.canNext || state.isLoading}
+                      onPress={onNextPress}>
+                      <ButtonText>
+                        <Trans>Next</Trans>
+                      </ButtonText>
+                    </Button>
+                  )}
+                </>
+              )}
+            </View>
+
+            <Divider />
+
+            <View style={[a.w_full, a.py_lg]}>
+              <Text style={[t.atoms.text_contrast_medium]}>
+                <Trans>Having trouble?</Trans>{' '}
+                <InlineLink to={FEEDBACK_FORM_URL({email: state.email})}>
+                  <Trans>Contact support</Trans>
+                </InlineLink>
+              </Text>
+            </View>
+          </View>
+        </View>
+      </LoggedOutLayout>
+    </SignupContext.Provider>
+  )
+}
diff --git a/src/screens/Signup/state.ts b/src/screens/Signup/state.ts
new file mode 100644
index 000000000..86a144368
--- /dev/null
+++ b/src/screens/Signup/state.ts
@@ -0,0 +1,320 @@
+import React, {useCallback} from 'react'
+import {LayoutAnimation} from 'react-native'
+import {
+  ComAtprotoServerCreateAccount,
+  ComAtprotoServerDescribeServer,
+} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import * as EmailValidator from 'email-validator'
+
+import {DEFAULT_SERVICE, IS_PROD_SERVICE} from '#/lib/constants'
+import {cleanError} from '#/lib/strings/errors'
+import {createFullHandle, validateHandle} from '#/lib/strings/handles'
+import {getAge} from '#/lib/strings/time'
+import {logger} from '#/logger'
+import {
+  DEFAULT_PROD_FEEDS,
+  usePreferencesSetBirthDateMutation,
+  useSetSaveFeedsMutation,
+} from '#/state/queries/preferences'
+import {useSessionApi} from '#/state/session'
+import {useOnboardingDispatch} from '#/state/shell'
+
+export type ServiceDescription = ComAtprotoServerDescribeServer.OutputSchema
+
+const DEFAULT_DATE = new Date(Date.now() - 60e3 * 60 * 24 * 365 * 20) // default to 20 years ago
+
+export enum SignupStep {
+  INFO,
+  HANDLE,
+  CAPTCHA,
+}
+
+export type SignupState = {
+  hasPrev: boolean
+  canNext: boolean
+  activeStep: SignupStep
+
+  serviceUrl: string
+  serviceDescription?: ServiceDescription
+  userDomain: string
+  dateOfBirth: Date
+  email: string
+  password: string
+  inviteCode: string
+  handle: string
+
+  error: string
+  isLoading: boolean
+}
+
+export type SignupAction =
+  | {type: 'prev'}
+  | {type: 'next'}
+  | {type: 'finish'}
+  | {type: 'setStep'; value: SignupStep}
+  | {type: 'setServiceUrl'; value: string}
+  | {type: 'setServiceDescription'; value: ServiceDescription | undefined}
+  | {type: 'setEmail'; value: string}
+  | {type: 'setPassword'; value: string}
+  | {type: 'setDateOfBirth'; value: Date}
+  | {type: 'setInviteCode'; value: string}
+  | {type: 'setHandle'; value: string}
+  | {type: 'setVerificationCode'; value: string}
+  | {type: 'setError'; value: string}
+  | {type: 'setCanNext'; value: boolean}
+  | {type: 'setIsLoading'; value: boolean}
+
+export const initialState: SignupState = {
+  hasPrev: false,
+  canNext: false,
+  activeStep: SignupStep.INFO,
+
+  serviceUrl: DEFAULT_SERVICE,
+  serviceDescription: undefined,
+  userDomain: '',
+  dateOfBirth: DEFAULT_DATE,
+  email: '',
+  password: '',
+  handle: '',
+  inviteCode: '',
+
+  error: '',
+  isLoading: false,
+}
+
+export function is13(date: Date) {
+  return getAge(date) >= 13
+}
+
+export function is18(date: Date) {
+  return getAge(date) >= 18
+}
+
+export function reducer(s: SignupState, a: SignupAction): SignupState {
+  let next = {...s}
+
+  switch (a.type) {
+    case 'prev': {
+      if (s.activeStep !== SignupStep.INFO) {
+        LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+        next.activeStep--
+        next.error = ''
+      }
+      break
+    }
+    case 'next': {
+      if (s.activeStep !== SignupStep.CAPTCHA) {
+        LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+        next.activeStep++
+        next.error = ''
+      }
+      break
+    }
+    case 'setStep': {
+      next.activeStep = a.value
+      break
+    }
+    case 'setServiceUrl': {
+      next.serviceUrl = a.value
+      break
+    }
+    case 'setServiceDescription': {
+      LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+
+      next.serviceDescription = a.value
+      next.userDomain = a.value?.availableUserDomains[0] ?? ''
+      next.isLoading = false
+      break
+    }
+
+    case 'setEmail': {
+      next.email = a.value
+      break
+    }
+    case 'setPassword': {
+      next.password = a.value
+      break
+    }
+    case 'setDateOfBirth': {
+      next.dateOfBirth = a.value
+      break
+    }
+    case 'setInviteCode': {
+      next.inviteCode = a.value
+      break
+    }
+    case 'setHandle': {
+      next.handle = a.value
+      break
+    }
+    case 'setCanNext': {
+      next.canNext = a.value
+      break
+    }
+    case 'setIsLoading': {
+      next.isLoading = a.value
+      break
+    }
+    case 'setError': {
+      next.error = a.value
+      break
+    }
+  }
+
+  next.hasPrev = next.activeStep !== SignupStep.INFO
+
+  switch (next.activeStep) {
+    case SignupStep.INFO: {
+      const isValidEmail = EmailValidator.validate(next.email)
+      next.canNext =
+        !!(next.email && next.password && next.dateOfBirth) &&
+        (!next.serviceDescription?.inviteCodeRequired || !!next.inviteCode) &&
+        is13(next.dateOfBirth) &&
+        isValidEmail
+      break
+    }
+    case SignupStep.HANDLE: {
+      next.canNext =
+        !!next.handle && validateHandle(next.handle, next.userDomain).overall
+      break
+    }
+  }
+
+  logger.debug('signup', next)
+
+  if (s.activeStep !== next.activeStep) {
+    logger.debug('signup: step changed', {activeStep: next.activeStep})
+  }
+
+  return next
+}
+
+interface IContext {
+  state: SignupState
+  dispatch: React.Dispatch<SignupAction>
+}
+export const SignupContext = React.createContext<IContext>({} as IContext)
+export const useSignupContext = () => React.useContext(SignupContext)
+
+export function useSubmitSignup({
+  state,
+  dispatch,
+}: {
+  state: SignupState
+  dispatch: (action: SignupAction) => void
+}) {
+  const {_} = useLingui()
+  const {createAccount} = useSessionApi()
+  const {mutateAsync: setBirthDate} = usePreferencesSetBirthDateMutation()
+  const {mutate: setSavedFeeds} = useSetSaveFeedsMutation()
+  const onboardingDispatch = useOnboardingDispatch()
+
+  return useCallback(
+    async (verificationCode?: string) => {
+      if (!state.email) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please enter your email.`),
+        })
+      }
+      if (!EmailValidator.validate(state.email)) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Your email appears to be invalid.`),
+        })
+      }
+      if (!state.password) {
+        dispatch({type: 'setStep', value: SignupStep.INFO})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please choose your password.`),
+        })
+      }
+      if (!state.handle) {
+        dispatch({type: 'setStep', value: SignupStep.HANDLE})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please choose your handle.`),
+        })
+      }
+      if (
+        state.serviceDescription?.phoneVerificationRequired &&
+        !verificationCode
+      ) {
+        dispatch({type: 'setStep', value: SignupStep.CAPTCHA})
+        return dispatch({
+          type: 'setError',
+          value: _(msg`Please complete the verification captcha.`),
+        })
+      }
+      dispatch({type: 'setError', value: ''})
+      dispatch({type: 'setIsLoading', value: true})
+
+      try {
+        onboardingDispatch({type: 'start'}) // start now to avoid flashing the wrong view
+        await createAccount({
+          service: state.serviceUrl,
+          email: state.email,
+          handle: createFullHandle(state.handle, state.userDomain),
+          password: state.password,
+          inviteCode: state.inviteCode.trim(),
+          verificationCode: verificationCode,
+        })
+        await setBirthDate({birthDate: state.dateOfBirth})
+        if (IS_PROD_SERVICE(state.serviceUrl)) {
+          setSavedFeeds(DEFAULT_PROD_FEEDS)
+        }
+      } catch (e: any) {
+        onboardingDispatch({type: 'skip'}) // undo starting the onboard
+        let errMsg = e.toString()
+        if (e instanceof ComAtprotoServerCreateAccount.InvalidInviteCodeError) {
+          dispatch({
+            type: 'setError',
+            value: _(
+              msg`Invite code not accepted. Check that you input it correctly and try again.`,
+            ),
+          })
+          dispatch({type: 'setStep', value: SignupStep.INFO})
+          return
+        }
+
+        if ([400, 429].includes(e.status)) {
+          logger.warn('Failed to create account', {message: e})
+        } else {
+          logger.error(`Failed to create account (${e.status} status)`, {
+            message: e,
+          })
+        }
+
+        const error = cleanError(errMsg)
+        const isHandleError = error.toLowerCase().includes('handle')
+
+        dispatch({type: 'setIsLoading', value: false})
+        dispatch({type: 'setError', value: cleanError(errMsg)})
+        dispatch({type: 'setStep', value: isHandleError ? 2 : 1})
+      } finally {
+        dispatch({type: 'setIsLoading', value: false})
+      }
+    },
+    [
+      state.email,
+      state.password,
+      state.handle,
+      state.serviceDescription?.phoneVerificationRequired,
+      state.serviceUrl,
+      state.userDomain,
+      state.inviteCode,
+      state.dateOfBirth,
+      dispatch,
+      _,
+      onboardingDispatch,
+      createAccount,
+      setBirthDate,
+      setSavedFeeds,
+    ],
+  )
+}