about summary refs log tree commit diff
path: root/src/screens/Profile/Header
diff options
context:
space:
mode:
Diffstat (limited to 'src/screens/Profile/Header')
-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
7 files changed, 995 insertions, 0 deletions
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..6722ed09b
--- /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,
+  ModerationOpts,
+  moderateProfile,
+  RichText as RichTextAPI,
+} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {RichText} from '#/components/RichText'
+import {useModalControls} from '#/state/modals'
+import {usePreferencesQuery} from '#/state/queries/preferences'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {useProfileShadow} from 'state/cache/profile-shadow'
+import {useLabelerSubscriptionMutation} from '#/state/queries/labeler'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {logger} from '#/logger'
+import {Haptics} from '#/lib/haptics'
+import {pluralize} from '#/lib/strings/helpers'
+import {isAppLabeler} from '#/lib/moderation'
+
+import {atoms as a, useTheme, tokens} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+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 {
+  Heart2_Stroke2_Corner0_Rounded as Heart,
+  Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+} from '#/components/icons/Heart2'
+import {DialogOuterProps} from '#/components/Dialog'
+import * as Prompt from '#/components/Prompt'
+import {Link} from '#/components/Link'
+
+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']
+}) {
+  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}>OK</Prompt.Action>
+      </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},
+})