diff --git a/src/components/Button.tsx b/src/components/Button.tsx
index d71786dfc9..b9820e29a1 100644
--- a/src/components/Button.tsx
+++ b/src/components/Button.tsx
@@ -380,7 +380,6 @@ export function Button({
a.flex_row,
a.align_center,
a.justify_center,
- a.justify_center,
flattenedBaseStyles,
...(state.hovered || state.pressed ? hoverStyles : []),
...(state.focused ? focusStyles : []),
diff --git a/src/screens/Profile/FiltersSection.tsx b/src/screens/Profile/FiltersSection.tsx
new file mode 100644
index 0000000000..726d50267c
--- /dev/null
+++ b/src/screens/Profile/FiltersSection.tsx
@@ -0,0 +1,238 @@
+import React from 'react'
+import {View} from 'react-native'
+import {AppBskyModerationDefs, AppBskyActorDefs} from '@atproto/api'
+import {Trans, msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useSafeAreaFrame} from 'react-native-safe-area-context'
+
+import * as Toast from '#/view/com/util/Toast'
+import {useSetTitle} from '#/lib/hooks/useSetTitle'
+import {Haptics} from '#/lib/haptics'
+import {useAnalytics} from '#/lib/analytics/analytics'
+import {CenteredView, ScrollView} from '#/view/com/util/Views'
+import {logger} from '#/logger'
+import {
+ useModServiceInfoQuery,
+ useModServiceEnableMutation,
+} from '#/state/queries/modservice'
+import {
+ UsePreferencesQueryResponse,
+ usePreferencesQuery,
+} from '#/state/queries/preferences'
+import {useSession} from '#/state/session'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+
+import {getLabelGroupsFromLabels} from '#/lib/moderation'
+
+import {useTheme, atoms as a} from '#/alf'
+import {Text} from '#/components/Typography'
+import {Loader} from '#/components/Loader'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+
+import {ErrorState} from '#/screens/ProfileModerationService/ErrorState'
+import {PreferenceRow} from '#/screens/ProfileModerationService/PreferenceRow'
+
+export function ProfileFiltersSection({
+ profile,
+}: {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {height: minHeight} = useSafeAreaFrame()
+ const {
+ isLoading: isPreferencesLoading,
+ error: preferencesError,
+ data: preferences,
+ } = usePreferencesQuery()
+ const {
+ isLoading: isModServiceLoading,
+ error: modServiceError,
+ data: modservice,
+ } = useModServiceInfoQuery({did: profile.did})
+
+ const isLoading = isPreferencesLoading || isModServiceLoading
+ const error = preferencesError || modServiceError
+
+ return (
+
+
+ {isLoading ? (
+
+
+
+ ) : error || !(preferences && modservice) ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+export function ProfileFiltersSectionInner({
+ preferences,
+ modservice,
+}: {
+ preferences: UsePreferencesQueryResponse
+ modservice: AppBskyModerationDefs.ModServiceViewDetailed
+}) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const {hasSession} = useSession()
+ const {track} = useAnalytics()
+ const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
+ const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
+ useUnlikeMutation()
+ const {mutateAsync: toggleEnabled, variables: enabledVariables} =
+ useModServiceEnableMutation()
+
+ const [likeUri, setLikeUri] = React.useState(
+ modservice.viewer?.like || '',
+ )
+ // TODO error state
+ const [_enablementError, setEnablementError] = React.useState('')
+
+ const isLiked = !!likeUri
+ const groups = React.useMemo(() => {
+ return getLabelGroupsFromLabels(modservice.policies.labelValues).filter(
+ def => def.configurable,
+ )
+ }, [modservice.policies.labelValues])
+ const modservicePreferences = React.useMemo(() => {
+ return preferences.moderationOpts.mods.find(
+ p => p.did === modservice.creator.did,
+ )
+ }, [modservice.creator.did, preferences.moderationOpts.mods])
+ const isSubscribed = preferences.moderationOpts.mods.find(
+ mod => mod.did === modservice.creator.did,
+ )
+ const isEnabled = Boolean(
+ enabledVariables?.enabled ??
+ preferences.moderationOpts.mods.find(
+ mod => mod.did === modservice.creator.did && mod.enabled,
+ ),
+ )
+
+ useSetTitle(modservice.creator.displayName || modservice.creator.handle)
+
+ const onToggleLiked = React.useCallback(async () => {
+ try {
+ Haptics.default()
+
+ if (isLiked && likeUri) {
+ await unlikeMod({uri: likeUri})
+ track('CustomFeed:Unlike')
+ setLikeUri('')
+ } else {
+ const res = await likeMod({uri: modservice.uri, cid: modservice.cid})
+ track('CustomFeed:Like')
+ 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})
+ }
+ }, [likeUri, isLiked, modservice, likeMod, unlikeMod, track, _])
+
+ const onToggleLabelerEnabled = React.useCallback(async () => {
+ try {
+ await toggleEnabled({
+ did: modservice.creator.did,
+ enabled: !isEnabled,
+ })
+ } catch (e: any) {
+ setEnablementError(e.message)
+ logger.error(`Failed to toggle labeler enabled`, {message: e.message})
+ }
+ }, [toggleEnabled, isEnabled, modservice.creator.did])
+
+ return (
+
+ {isSubscribed ? (
+
+
+
+ Enable or disable labels from this service.
+
+
+
+
+ {isEnabled ? 'Enabled' : 'Disabled'}
+
+
+
+ ) : (
+
+
+ This labeler moderates the following types of content.
+
+
+ )}
+
+
+ {groups.map((def, i) => {
+ return (
+
+ {i !== 0 && }
+
+
+
+
+ )
+ })}
+
+
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/DisplayName.tsx b/src/screens/Profile/Header/DisplayName.tsx
new file mode 100644
index 0000000000..b6d88db712
--- /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
+ moderation: ModerationDecision
+}) {
+ const t = useTheme()
+ return (
+
+
+ {sanitizeDisplayName(
+ profile.displayName || sanitizeHandle(profile.handle),
+ moderation.ui('displayName'),
+ )}
+
+
+ )
+}
diff --git a/src/screens/Profile/Header/DropdownBtn.tsx b/src/screens/Profile/Header/DropdownBtn.tsx
new file mode 100644
index 0000000000..24244b6646
--- /dev/null
+++ b/src/screens/Profile/Header/DropdownBtn.tsx
@@ -0,0 +1,281 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useQueryClient} from '@tanstack/react-query'
+import {AppBskyActorDefs} from '@atproto/api'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {isWeb} from 'platform/detection'
+import {useModalControls} from '#/state/modals'
+import {
+ RQKEY as profileQueryKey,
+ useProfileMuteMutationQueue,
+ useProfileBlockMutationQueue,
+} from '#/state/queries/profile'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {makeProfileLink} from 'lib/routes/links'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {shareUrl} from 'lib/sharing'
+import {logger} from '#/logger'
+import {useSession} from '#/state/session'
+import {Shadow} from '#/state/cache/types'
+import {NEW_REPORT_DIALOG_ENABLED} from '#/lib/build-flags'
+
+import {atoms as a, useTheme, tokens} from '#/alf'
+import * as Toast from 'view/com/util/Toast'
+import {NativeDropdown, DropdownItem} from 'view/com/util/forms/NativeDropdown'
+import {useOpenGlobalDialog} from '#/components/dialogs'
+import {ReportDialog} from '#/components/dialogs/ReportDialog'
+import {DotGrid1x3Horizontal_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid'
+
+export function ProfileHeaderDropdownBtn({
+ profile,
+}: {
+ profile: Shadow
+}) {
+ const t = useTheme()
+ const {currentAccount, hasSession} = useSession()
+ const {_} = useLingui()
+ const {openModal} = useModalControls()
+ const {track} = useAnalytics()
+ const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
+ const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+ const queryClient = useQueryClient()
+ const openDialog = useOpenGlobalDialog()
+
+ const invalidateProfileQuery = React.useCallback(() => {
+ queryClient.invalidateQueries({
+ queryKey: profileQueryKey(profile.did),
+ })
+ }, [queryClient, profile.did])
+
+ const onPressShare = React.useCallback(() => {
+ track('ProfileHeader:ShareButtonClicked')
+ shareUrl(toShareUrl(makeProfileLink(profile)))
+ }, [track, profile])
+
+ const onPressAddRemoveLists = React.useCallback(() => {
+ track('ProfileHeader:AddToListsButtonClicked')
+ openModal({
+ name: 'user-add-remove-lists',
+ subject: profile.did,
+ handle: profile.handle,
+ displayName: profile.displayName || profile.handle,
+ onAdd: invalidateProfileQuery,
+ onRemove: invalidateProfileQuery,
+ })
+ }, [track, profile, openModal, invalidateProfileQuery])
+
+ const onPressMuteAccount = React.useCallback(async () => {
+ track('ProfileHeader:MuteAccountButtonClicked')
+ try {
+ await queueMute()
+ Toast.show(_(msg`Account muted`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to mute account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }, [track, queueMute, _])
+
+ const onPressUnmuteAccount = React.useCallback(async () => {
+ track('ProfileHeader:UnmuteAccountButtonClicked')
+ try {
+ await queueUnmute()
+ Toast.show(_(msg`Account unmuted`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to unmute account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ }, [track, queueUnmute, _])
+
+ const onPressBlockAccount = React.useCallback(async () => {
+ track('ProfileHeader:BlockAccountButtonClicked')
+ openModal({
+ name: 'confirm',
+ title: _(msg`Block Account`),
+ message: _(
+ msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`,
+ ),
+ onPressConfirm: async () => {
+ try {
+ await queueBlock()
+ Toast.show(_(msg`Account blocked`))
+ } catch (e: any) {
+ if (e?.name !== 'AbortError') {
+ logger.error('Failed to block account', {message: e})
+ Toast.show(_(msg`There was an issue! ${e.toString()}`))
+ }
+ }
+ },
+ })
+ }, [track, queueBlock, openModal, _])
+
+ const onPressUnblockAccount = React.useCallback(async () => {
+ track('ProfileHeader:UnblockAccountButtonClicked')
+ openModal({
+ name: 'confirm',
+ title: _(msg`Unblock Account`),
+ message: _(
+ msg`The account will be able to interact with you after unblocking.`,
+ ),
+ onPressConfirm: async () => {
+ 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()}`))
+ }
+ }
+ },
+ })
+ }, [track, queueUnblock, openModal, _])
+
+ const onPressReportAccount = React.useCallback(() => {
+ track('ProfileHeader:ReportAccountButtonClicked')
+ if (NEW_REPORT_DIALOG_ENABLED) {
+ openDialog(ReportDialog, {type: 'profile', did: profile.did})
+ } else {
+ openModal({
+ name: 'report',
+ did: profile.did,
+ })
+ }
+ }, [track, openModal, profile, openDialog])
+
+ const isMe = React.useMemo(
+ () => currentAccount?.did === profile.did,
+ [currentAccount, profile],
+ )
+ const dropdownItems: DropdownItem[] = React.useMemo(() => {
+ let items: DropdownItem[] = [
+ {
+ testID: 'profileHeaderDropdownShareBtn',
+ label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`),
+ onPress: onPressShare,
+ icon: {
+ ios: {
+ name: 'square.and.arrow.up',
+ },
+ android: 'ic_menu_share',
+ web: 'share',
+ },
+ },
+ ]
+ if (hasSession) {
+ if (!profile.associated?.modservice) {
+ items.push({label: 'separator'})
+ items.push({
+ testID: 'profileHeaderDropdownListAddRemoveBtn',
+ label: _(msg`Add to Lists`),
+ onPress: onPressAddRemoveLists,
+ icon: {
+ ios: {
+ name: 'list.bullet',
+ },
+ android: 'ic_menu_add',
+ web: 'list',
+ },
+ })
+ if (!isMe) {
+ if (!profile.viewer?.blocking) {
+ if (!profile.viewer?.mutedByList) {
+ items.push({
+ testID: 'profileHeaderDropdownMuteBtn',
+ label: profile.viewer?.muted
+ ? _(msg`Unmute Account`)
+ : _(msg`Mute Account`),
+ onPress: profile.viewer?.muted
+ ? onPressUnmuteAccount
+ : onPressMuteAccount,
+ icon: {
+ ios: {
+ name: 'speaker.slash',
+ },
+ android: 'ic_lock_silent_mode',
+ web: 'comment-slash',
+ },
+ })
+ }
+ }
+ if (!profile.viewer?.blockingByList) {
+ items.push({
+ testID: 'profileHeaderDropdownBlockBtn',
+ label: profile.viewer?.blocking
+ ? _(msg`Unblock Account`)
+ : _(msg`Block Account`),
+ onPress: profile.viewer?.blocking
+ ? onPressUnblockAccount
+ : onPressBlockAccount,
+ icon: {
+ ios: {
+ name: 'person.fill.xmark',
+ },
+ android: 'ic_menu_close_clear_cancel',
+ web: 'user-slash',
+ },
+ })
+ }
+ }
+ }
+ items.push({
+ testID: 'profileHeaderDropdownReportBtn',
+ label: _(msg`Report Account`),
+ onPress: onPressReportAccount,
+ icon: {
+ ios: {
+ name: 'exclamationmark.triangle',
+ },
+ android: 'ic_menu_report_image',
+ web: 'circle-exclamation',
+ },
+ })
+ }
+ return items
+ }, [
+ isMe,
+ hasSession,
+ profile.associated?.modservice,
+ profile.viewer?.muted,
+ profile.viewer?.mutedByList,
+ profile.viewer?.blocking,
+ profile.viewer?.blockingByList,
+ onPressShare,
+ onPressUnmuteAccount,
+ onPressMuteAccount,
+ onPressUnblockAccount,
+ onPressBlockAccount,
+ onPressReportAccount,
+ onPressAddRemoveLists,
+ _,
+ ])
+
+ return dropdownItems?.length ? (
+
+
+
+
+
+ ) : null
+}
diff --git a/src/screens/Profile/Header/Handle.tsx b/src/screens/Profile/Header/Handle.tsx
new file mode 100644
index 0000000000..53961c961d
--- /dev/null
+++ b/src/screens/Profile/Header/Handle.tsx
@@ -0,0 +1,55 @@
+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
+}) {
+ const t = useTheme()
+ const invalidHandle = isInvalidHandle(profile.handle)
+ const blockHide = profile.viewer?.blocking || profile.viewer?.blockedBy
+ return (
+
+ {profile.viewer?.followedBy &&
+ !blockHide &&
+ !profile.associated?.modservice ? (
+
+
+ Follows you
+
+
+ ) : undefined}
+
+ {invalidHandle ? ā Invalid Handle : `@${profile.handle}`}
+
+ {profile.associated?.modservice ? (
+
+
+ Moderation service
+
+
+ ) : undefined}
+
+ )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderModerator.tsx b/src/screens/Profile/Header/ProfileHeaderModerator.tsx
new file mode 100644
index 0000000000..9c5d36a58f
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderModerator.tsx
@@ -0,0 +1,228 @@
+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 {RichText} from 'view/com/util/text/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 {useModServiceSubscriptionMutation} from '#/state/queries/modservice'
+import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like'
+import {logger} from '#/logger'
+import {Haptics} from '#/lib/haptics'
+import {pluralize} from '#/lib/strings/helpers'
+
+import {atoms as a, useTheme} from '#/alf'
+import {InlineLink} from '#/components/Link'
+import {Button, ButtonText} from '#/components/Button'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileHeaderDropdownBtn} from './DropdownBtn'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {
+ Heart2_Stroke2_Corner0_Rounded as Heart,
+ Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled,
+} from '#/components/icons/Heart2'
+
+interface Props {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ descriptionRT: RichTextAPI | null
+ moderationOpts: ModerationOpts
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderModerator = ({
+ profile: profileUnshadowed,
+ descriptionRT,
+ moderationOpts,
+ hideBackButton = false,
+ isPlaceholderProfile,
+}: Props): React.ReactNode => {
+ const profile: Shadow =
+ 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 {data: preferences} = usePreferencesQuery()
+ const {mutateAsync: toggleSubscription, variables} =
+ useModServiceSubscriptionMutation()
+ const isSubscribed =
+ variables?.subscribe ??
+ preferences?.moderationOpts.mods.find(mod => mod.did === profile.did)
+ const modservice = {likeCount: 0} // TODO
+ const {mutateAsync: likeMod, isPending: isLikePending} = useLikeMutation()
+ const {mutateAsync: unlikeMod, isPending: isUnlikePending} =
+ useUnlikeMutation()
+ const [likeUri, setLikeUri] = React.useState(
+ /* TODO modservice.viewer?.like ||*/ '',
+ )
+ const isLiked = !!likeUri
+
+ const onToggleLiked = React.useCallback(async () => {
+ try {
+ Haptics.default()
+
+ if (isLiked && likeUri) {
+ await unlikeMod({uri: likeUri})
+ track('CustomFeed:Unlike')
+ setLikeUri('')
+ } else {
+ // TODO
+ // const res = await likeMod({uri: modservice.uri, cid: modservice.cid})
+ track('CustomFeed:Like')
+ // 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})
+ }
+ }, [likeUri, isLiked, likeMod, unlikeMod, track, _])
+
+ const onPressEditProfile = React.useCallback(() => {
+ track('ProfileHeader:EditProfileButtonClicked')
+ openModal({
+ name: 'edit-profile',
+ profile,
+ })
+ }, [track, openModal, profile])
+
+ const onPressSubscribe = React.useCallback(async () => {
+ 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])
+
+ const isMe = React.useMemo(
+ () => currentAccount?.did === profile.did,
+ [currentAccount, profile],
+ )
+
+ return (
+
+
+
+ {isMe ? (
+
+ ) : (
+ <>
+
+ >
+ )}
+
+
+
+
+
+
+ {!isPlaceholderProfile && (
+ <>
+
+
+
+ {typeof modservice.likeCount === 'number' && (
+
+
+ Liked by {modservice.likeCount}{' '}
+ {pluralize(modservice.likeCount, 'user')}
+
+
+ )}
+
+ {descriptionRT && !moderation.ui('profileView').blur ? (
+
+
+
+ ) : undefined}
+ >
+ )}
+
+
+ )
+}
+ProfileHeaderModerator = memo(ProfileHeaderModerator)
+export {ProfileHeaderModerator}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
new file mode 100644
index 0000000000..e12b259e36
--- /dev/null
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -0,0 +1,317 @@
+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 {pluralize} from '#/lib/strings/helpers'
+import {makeProfileLink} from 'lib/routes/links'
+import {formatCount} from 'view/com/util/numeric/format'
+import {sanitizeDisplayName} from 'lib/strings/display-names'
+
+import {atoms as a, useTheme} from '#/alf'
+import {Text} from '#/components/Typography'
+import {InlineLink} from '#/components/Link'
+import {Button, ButtonText} from '#/components/Button'
+import * as Toast from '#/view/com/util/Toast'
+import {ProfileHeaderShell} from './Shell'
+import {ProfileHeaderDropdownBtn} from './DropdownBtn'
+import {ProfileHeaderDisplayName} from './DisplayName'
+import {ProfileHeaderHandle} from './Handle'
+import {ProfileHeaderSuggestedFollows} from '#/view/com/profile/ProfileHeaderSuggestedFollows'
+import {RichText} from 'view/com/util/text/RichText'
+
+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 =
+ 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)
+ const [_queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+ 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,
+ )}`,
+ ),
+ )
+ } 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,
+ )}`,
+ ),
+ )
+ } 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 onPressUnblockAccount = React.useCallback(async () => {
+ track('ProfileHeader:UnblockAccountButtonClicked')
+ openModal({
+ name: 'confirm',
+ title: _(msg`Unblock Account`),
+ message: _(
+ msg`The account will be able to interact with you after unblocking.`,
+ ),
+ onPressConfirm: async () => {
+ 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()}`))
+ }
+ }
+ },
+ })
+ }, [track, queueUnblock, openModal, _])
+
+ const isMe = React.useMemo(
+ () => currentAccount?.did === profile.did,
+ [currentAccount, profile],
+ )
+ const following = formatCount(profile.followsCount || 0)
+ const followers = formatCount(profile.followersCount || 0)
+ const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower')
+
+ return (
+
+
+
+ {isMe ? (
+
+ ) : profile.viewer?.blocking ? (
+ profile.viewer?.blockingByList ? null : (
+
+ )
+ ) : !profile.viewer?.blockedBy ? (
+ <>
+ {hasSession && (
+
+ )}
+
+
+ >
+ ) : null}
+
+
+
+
+
+
+ {!isPlaceholderProfile && (
+ <>
+
+
+
+ {followers}{' '}
+
+
+ {pluralizedFollowers}
+
+
+
+
+
+ {following}{' '}
+
+
+ following
+
+
+
+
+ {formatCount(profile.postsCount || 0)}{' '}
+
+ {pluralize(profile.postsCount || 0, 'post')}
+
+
+
+ {descriptionRT && !moderation.ui('profileView').blur ? (
+
+
+
+ ) : undefined}
+ >
+ )}
+
+ {showSuggestedFollows && (
+ {
+ if (showSuggestedFollows) {
+ setShowSuggestedFollows(false)
+ } else {
+ track('ProfileHeader:SuggestedFollowsOpened')
+ setShowSuggestedFollows(true)
+ }
+ }}
+ />
+ )}
+
+ )
+}
+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 0000000000..8843300c9b
--- /dev/null
+++ b/src/screens/Profile/Header/Shell.tsx
@@ -0,0 +1,158 @@
+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 'view/com/util/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 'view/com/util/moderation/ProfileHeaderAlerts'
+
+interface Props {
+ profile: Shadow
+ moderation: ModerationDecision
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeaderShell = ({
+ children,
+ profile,
+ moderation,
+ hideBackButton = false,
+ isPlaceholderProfile,
+}: React.PropsWithChildren): React.ReactNode => {
+ const t = useTheme()
+ const {currentAccount} = useSession()
+ const {_} = useLingui()
+ const {openLightbox} = useLightboxControls()
+ const navigation = useNavigation()
+ 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 (
+
+
+ {isPlaceholderProfile ? (
+
+ ) : (
+
+ )}
+
+
+ {children}
+
+
+
+ {isMe && (
+
+ )}
+
+
+ {!isDesktop && !hideBackButton && (
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
+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,
+ },
+})
diff --git a/src/screens/Profile/Header/index.tsx b/src/screens/Profile/Header/index.tsx
new file mode 100644
index 0000000000..ea39d9c44f
--- /dev/null
+++ b/src/screens/Profile/Header/index.tsx
@@ -0,0 +1,73 @@
+import React, {memo} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {
+ AppBskyActorDefs,
+ 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 {ProfileHeaderModerator} from './ProfileHeaderModerator'
+
+let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
+ const pal = usePalette('default')
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+ProfileHeaderLoading = memo(ProfileHeaderLoading)
+export {ProfileHeaderLoading}
+
+interface Props {
+ profile: AppBskyActorDefs.ProfileViewDetailed
+ descriptionRT: RichTextAPI | null
+ moderationOpts: ModerationOpts
+ hideBackButton?: boolean
+ isPlaceholderProfile?: boolean
+}
+
+let ProfileHeader = (props: Props): React.ReactNode => {
+ if (props.profile.associated?.modservice) {
+ return
+ }
+ return
+}
+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/view/screens/Profile.tsx b/src/view/screens/Profile.tsx
index 4e3586a463..b208062c61 100644
--- a/src/view/screens/Profile.tsx
+++ b/src/view/screens/Profile.tsx
@@ -52,6 +52,8 @@ import {isInvalidHandle} from '#/lib/strings/handles'
import {useTheme, atoms as a} from '#/alf'
import * as ModerationServiceCard from '#/components/ModerationServiceCard'
import {RaisingHande4Finger_Stroke2_Corner0_Rounded as RaisingHand} from '#/components/icons/RaisingHand'
+import {ProfileFiltersSection} from '#/screens/Profile/FiltersSection'
+import {ProfileHeader as ProfileHeaderV2} from '#/screens/Profile/Header'
interface SectionRef {
scrollToTop: () => void
@@ -115,8 +117,11 @@ export function ProfileScreen({route}: Props) {
)
}
if (profile && moderationOpts) {
+ if (profile.handle === 'alice.test') {
+ profile.associated = {modservice: true}
+ }
return (
- (null)
+ const repliesSectionRef = React.useRef(null)
+ const mediaSectionRef = React.useRef(null)
+ const likesSectionRef = React.useRef(null)
+ const feedsSectionRef = React.useRef(null)
+ const listsSectionRef = React.useRef(null)
+ const filtersSectionRef = React.useRef(null)
+
+ useSetTitle(combinedDisplayName(profile))
+
+ const description = profile.description ?? ''
+ const hasDescription = description !== ''
+ const [descriptionRT, isResolvingDescriptionRT] = useRichText(description)
+ const showPlaceholder = isPlaceholderProfile || isResolvingDescriptionRT
+ const moderation = useMemo(
+ () => moderateProfile(profile, moderationOpts),
+ [profile, moderationOpts],
+ )
+
+ const isMe = profile.did === currentAccount?.did
+ const isModService = !!profile.associated?.modservice
+ const showFiltersTab = hasSession && profile.associated?.modservice
+ const showPostsTab = !isModService
+ const showRepliesTab = hasSession && !isModService
+ const showMediaTab = !isModService
+ const showLikesTab = isMe && !isModService
+ const showFeedsTab =
+ hasSession && (isMe || (profile.associated?.feedgens || 0) > 0)
+ const showListsTab =
+ hasSession && (isMe || (profile.associated?.lists || 0) > 0)
+
+ const sectionTitles = useMemo(() => {
+ return [
+ showFiltersTab ? _(msg`Content Filters`) : undefined,
+ showPostsTab ? _(msg`Posts`) : undefined,
+ showRepliesTab ? _(msg`Replies`) : undefined,
+ showMediaTab ? _(msg`Media`) : undefined,
+ showLikesTab ? _(msg`Likes`) : undefined,
+ showFeedsTab ? _(msg`Feeds`) : undefined,
+ showListsTab ? _(msg`Lists`) : undefined,
+ ].filter(Boolean) as string[]
+ }, [
+ showPostsTab,
+ showRepliesTab,
+ showMediaTab,
+ showLikesTab,
+ showFeedsTab,
+ showListsTab,
+ showFiltersTab,
+ _,
+ ])
+
+ let nextIndex = 0
+ let filtersIndex: number | null = null
+ if (showFiltersTab) {
+ filtersIndex = nextIndex++
+ }
+ let postsIndex: number | null = null
+ if (showPostsTab) {
+ postsIndex = nextIndex++
+ }
+ let repliesIndex: number | null = null
+ if (showRepliesTab) {
+ repliesIndex = nextIndex++
+ }
+ let mediaIndex: number | null = null
+ if (showMediaTab) {
+ mediaIndex = nextIndex++
+ }
+ let likesIndex: number | null = null
+ if (showLikesTab) {
+ likesIndex = nextIndex++
+ }
+ let feedsIndex: number | null = null
+ if (showFeedsTab) {
+ feedsIndex = nextIndex++
+ }
+ let listsIndex: number | null = null
+ if (showListsTab) {
+ listsIndex = nextIndex++
+ }
+
+ const scrollSectionToTop = React.useCallback(
+ (index: number) => {
+ if (index === filtersIndex) {
+ filtersSectionRef.current?.scrollToTop()
+ } else if (index === postsIndex) {
+ postsSectionRef.current?.scrollToTop()
+ } else if (index === repliesIndex) {
+ repliesSectionRef.current?.scrollToTop()
+ } else if (index === mediaIndex) {
+ mediaSectionRef.current?.scrollToTop()
+ } else if (index === likesIndex) {
+ likesSectionRef.current?.scrollToTop()
+ } else if (index === feedsIndex) {
+ feedsSectionRef.current?.scrollToTop()
+ } else if (index === listsIndex) {
+ listsSectionRef.current?.scrollToTop()
+ }
+ },
+ [
+ filtersIndex,
+ postsIndex,
+ repliesIndex,
+ mediaIndex,
+ likesIndex,
+ feedsIndex,
+ listsIndex,
+ ],
+ )
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setMinimalShellMode(false)
+ screen('Profile')
+ return listenSoftReset(() => {
+ scrollSectionToTop(currentPage)
+ })
+ }, [setMinimalShellMode, screen, currentPage, scrollSectionToTop]),
+ )
+
+ useFocusEffect(
+ React.useCallback(() => {
+ setDrawerSwipeDisabled(currentPage > 0)
+ return () => {
+ setDrawerSwipeDisabled(false)
+ }
+ }, [setDrawerSwipeDisabled, currentPage]),
+ )
+
+ // events
+ // =
+
+ const onPressCompose = React.useCallback(() => {
+ track('ProfileScreen:PressCompose')
+ const mention =
+ profile.handle === currentAccount?.handle ||
+ isInvalidHandle(profile.handle)
+ ? undefined
+ : profile.handle
+ openComposer({mention})
+ }, [openComposer, currentAccount, track, profile])
+
+ const onPageSelected = React.useCallback(
+ (i: number) => {
+ setCurrentPage(i)
+ },
+ [setCurrentPage],
+ )
+
+ const onCurrentPageSelected = React.useCallback(
+ (index: number) => {
+ scrollSectionToTop(index)
+ },
+ [scrollSectionToTop],
+ )
+
+ // rendering
+ // =
+
+ const renderHeader = React.useCallback(() => {
+ return (
+
+ )
+ }, [
+ profile,
+ descriptionRT,
+ hasDescription,
+ moderationOpts,
+ hideBackButton,
+ showPlaceholder,
+ ])
+
+ return (
+
+
+ {showFiltersTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showPostsTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showRepliesTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showMediaTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showLikesTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showFeedsTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+ {showListsTab
+ ? ({headerHeight, isFocused, scrollElRef}) => (
+
+ )
+ : null}
+
+ {hasSession && (
+ }
+ accessibilityRole="button"
+ accessibilityLabel={_(msg`New post`)}
+ accessibilityHint=""
+ />
+ )}
+
+ )
+}
+
interface FeedSectionProps {
feed: FeedDescriptor
headerHeight: number
@@ -548,6 +860,55 @@ function ModerationSection({did}: {did: string}) {
)
}
+function FiltersSection({did}: {did: string}) {
+ const t = useTheme()
+ return (
+
+ (
+
+ {ctx => (
+
+
+
+
+
+
+ )}
+
+ )}
+ />
+
+ )
+}
+
function useRichText(text: string): [RichTextAPI, boolean] {
const [prevText, setPrevText] = React.useState(text)
const [rawRT, setRawRT] = React.useState(() => new RichTextAPI({text}))