From 812329919266924c1ea488669dc38aa106b79d71 Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 12 Mar 2024 10:17:27 -0700
Subject: Convert profile edit avatar/banner dropdown menus to new menu (#3177)

* convert profile edit dropdown menu to new menu

fix banner text

add `showCancel` prop to menu outer

banner dropdown to menu

add Cancel button to menu

replace user avatar dropdown with menu

add StreamingLive icon

add camera icon

* remove export

* use new camera icon

* adjust icon color
---
 src/components/Menu/index.tsx | 30 ++++++++++++++++++++++++++++--
 1 file changed, 28 insertions(+), 2 deletions(-)

(limited to 'src/components/Menu/index.tsx')

diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index ee96a5667..f9b697ea2 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -16,6 +16,10 @@ import {
   ItemTextProps,
   ItemIconProps,
 } from '#/components/Menu/types'
+import {Button, ButtonText} from '#/components/Button'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {isNative} from 'platform/detection'
 
 export {useDialogControl as useMenuControl} from '#/components/Dialog'
 
@@ -68,7 +72,10 @@ export function Trigger({children, label}: TriggerProps) {
   })
 }
 
-export function Outer({children}: React.PropsWithChildren<{}>) {
+export function Outer({
+  children,
+  showCancel,
+}: React.PropsWithChildren<{showCancel?: boolean}>) {
   const context = React.useContext(Context)
 
   return (
@@ -78,7 +85,10 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
       {/* Re-wrap with context since Dialogs are portal-ed to root */}
       <Context.Provider value={context}>
         <Dialog.ScrollableInner label="Menu TODO">
-          <View style={[a.gap_lg]}>{children}</View>
+          <View style={[a.gap_lg]}>
+            {children}
+            {isNative && showCancel && <Cancel />}
+          </View>
           <View style={{height: a.gap_lg.gap}} />
         </Dialog.ScrollableInner>
       </Context.Provider>
@@ -185,6 +195,22 @@ export function Group({children, style}: GroupProps) {
   )
 }
 
+function Cancel() {
+  const {_} = useLingui()
+  const {control} = React.useContext(Context)
+
+  return (
+    <Button
+      label={_(msg`Close this dialog`)}
+      size="small"
+      variant="ghost"
+      color="secondary"
+      onPress={() => control.close()}>
+      <ButtonText>Cancel</ButtonText>
+    </Button>
+  )
+}
+
 export function Divider() {
   return null
 }
-- 
cgit 1.4.1


From 090b35e52e3c42214bcf044a70d3658d1cf8b2de Mon Sep 17 00:00:00 2001
From: Hailey <me@haileyok.com>
Date: Tue, 12 Mar 2024 14:06:12 -0700
Subject: Use new menu for Profile (#3168)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* use new menu on profile

* organize imports

* fix testID

* add person icons

* use `style` prop for minWidth

* use new icons

* rm circleban

* Add unfollow option if account is blocked/blocking

* use `StyleProp` 🤯

* ts after merge

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
---
 assets/icons/flag_stroke2_corner0_rounded.svg      |   1 +
 .../peopleRemove2_stroke2_corner0_rounded.svg      |   1 +
 .../icons/personCheck_stroke2_corner0_rounded.svg  |   1 +
 assets/icons/personX_stroke2_corner0_rounded.svg   |   1 +
 src/components/Menu/index.tsx                      |   7 +-
 src/components/Menu/index.web.tsx                  |  11 +-
 src/components/icons/Flag.tsx                      |   5 +
 src/components/icons/PeopleRemove2.tsx             |   5 +
 src/components/icons/PersonCheck.tsx               |   5 +
 src/components/icons/PersonX.tsx                   |   5 +
 src/view/com/profile/ProfileHeader.tsx             | 207 +-------------
 src/view/com/profile/ProfileMenu.tsx               | 307 +++++++++++++++++++++
 12 files changed, 351 insertions(+), 205 deletions(-)
 create mode 100644 assets/icons/flag_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/peopleRemove2_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/personCheck_stroke2_corner0_rounded.svg
 create mode 100644 assets/icons/personX_stroke2_corner0_rounded.svg
 create mode 100644 src/components/icons/Flag.tsx
 create mode 100644 src/components/icons/PeopleRemove2.tsx
 create mode 100644 src/components/icons/PersonCheck.tsx
 create mode 100644 src/components/icons/PersonX.tsx
 create mode 100644 src/view/com/profile/ProfileMenu.tsx

(limited to 'src/components/Menu/index.tsx')

diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..9f9cc5cdd
--- /dev/null
+++ b/assets/icons/flag_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..2e798cbe2
--- /dev/null
+++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#000" fill-rule="evenodd" d="M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/personCheck_stroke2_corner0_rounded.svg b/assets/icons/personCheck_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..b3231c278
--- /dev/null
+++ b/assets/icons/personCheck_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/assets/icons/personX_stroke2_corner0_rounded.svg b/assets/icons/personX_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..073015bc5
--- /dev/null
+++ b/assets/icons/personX_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z" clip-rule="evenodd"/></svg>
\ No newline at end of file
diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx
index f9b697ea2..9be9dd86b 100644
--- a/src/components/Menu/index.tsx
+++ b/src/components/Menu/index.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {View, Pressable} from 'react-native'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
 import flattenReactChildren from 'react-keyed-flatten-children'
 
 import {atoms as a, useTheme} from '#/alf'
@@ -75,7 +75,10 @@ export function Trigger({children, label}: TriggerProps) {
 export function Outer({
   children,
   showCancel,
-}: React.PropsWithChildren<{showCancel?: boolean}>) {
+}: React.PropsWithChildren<{
+  showCancel?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
   const context = React.useContext(Context)
 
   return (
diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx
index f23c39ced..2004ee7c6 100644
--- a/src/components/Menu/index.web.tsx
+++ b/src/components/Menu/index.web.tsx
@@ -1,7 +1,7 @@
 /* eslint-disable react/prop-types */
 
 import React from 'react'
-import {View, Pressable} from 'react-native'
+import {View, Pressable, ViewStyle, StyleProp} from 'react-native'
 import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
 
 import * as Dialog from '#/components/Dialog'
@@ -132,7 +132,13 @@ export function Trigger({children, label}: TriggerProps) {
   )
 }
 
-export function Outer({children}: React.PropsWithChildren<{}>) {
+export function Outer({
+  children,
+  style,
+}: React.PropsWithChildren<{
+  showCancel?: boolean
+  style?: StyleProp<ViewStyle>
+}>) {
   const t = useTheme()
 
   return (
@@ -144,6 +150,7 @@ export function Outer({children}: React.PropsWithChildren<{}>) {
             a.p_xs,
             t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25,
             t.atoms.shadow_md,
+            style,
           ]}>
           {children}
         </View>
diff --git a/src/components/icons/Flag.tsx b/src/components/icons/Flag.tsx
new file mode 100644
index 000000000..d986db75a
--- /dev/null
+++ b/src/components/icons/Flag.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Flag_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M4 4a2 2 0 0 1 2-2h13.131c1.598 0 2.55 1.78 1.665 3.11L18.202 9l2.594 3.89c.886 1.33-.067 3.11-1.665 3.11H6v5a1 1 0 1 1-2 0V4Zm2 10h13.131l-2.593-3.89a2 2 0 0 1 0-2.22L19.13 4H6v10Z',
+})
diff --git a/src/components/icons/PeopleRemove2.tsx b/src/components/icons/PeopleRemove2.tsx
new file mode 100644
index 000000000..3d16ed968
--- /dev/null
+++ b/src/components/icons/PeopleRemove2.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PeopleRemove2_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M10 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM5.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM16 11a1 1 0 0 1 1-1h5a1 1 0 1 1 0 2h-5a1 1 0 0 1-1-1ZM3.678 19h12.644c-.71-2.909-3.092-5-6.322-5s-5.613 2.091-6.322 5Zm-2.174.906C1.917 15.521 5.242 12 10 12c4.758 0 8.083 3.521 8.496 7.906A1 1 0 0 1 17.5 21h-15a1 1 0 0 1-.996-1.094Z',
+})
diff --git a/src/components/icons/PersonCheck.tsx b/src/components/icons/PersonCheck.tsx
new file mode 100644
index 000000000..097271d89
--- /dev/null
+++ b/src/components/icons/PersonCheck.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5a6.69 6.69 0 0 1 2.612.51 1 1 0 0 0 .776-1.844A8.687 8.687 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H11a1 1 0 1 0 0-2H5.679Zm14.835-4.857a1 1 0 0 1 .344 1.371l-3 5a1 1 0 0 1-1.458.286l-2-1.5a1 1 0 0 1 1.2-1.6l1.113.835 2.43-4.05a1 1 0 0 1 1.372-.342Z',
+})
diff --git a/src/components/icons/PersonX.tsx b/src/components/icons/PersonX.tsx
new file mode 100644
index 000000000..a015e1376
--- /dev/null
+++ b/src/components/icons/PersonX.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({
+  path: 'M12 4a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5ZM7.5 6.5a4.5 4.5 0 1 1 9 0 4.5 4.5 0 0 1-9 0ZM5.679 19c.709-2.902 3.079-5 6.321-5 .302 0 .595.018.878.053a1 1 0 0 0 .243-1.985A9.235 9.235 0 0 0 12 12c-4.3 0-7.447 2.884-8.304 6.696-.29 1.29.767 2.304 1.902 2.304H12a1 1 0 1 0 0-2H5.679Zm9.614-3.707a1 1 0 0 1 1.414 0L18 16.586l1.293-1.293a1 1 0 0 1 1.414 1.414L19.414 18l1.293 1.293a1 1 0 0 1-1.414 1.414L18 19.414l-1.293 1.293a1 1 0 0 1-1.414-1.414L16.586 18l-1.293-1.293a1 1 0 0 1 0-1.414Z',
+})
diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx
index 3e479d7b5..a11fe8374 100644
--- a/src/view/com/profile/ProfileHeader.tsx
+++ b/src/view/com/profile/ProfileHeader.tsx
@@ -7,7 +7,6 @@ import {
 } from 'react-native'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
 import {useNavigation} from '@react-navigation/native'
-import {useQueryClient} from '@tanstack/react-query'
 import {
   AppBskyActorDefs,
   ModerationOpts,
@@ -17,7 +16,7 @@ import {
 import {Trans, msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {NavigationProp} from 'lib/routes/types'
-import {isNative, isWeb} from 'platform/detection'
+import {isNative} from 'platform/detection'
 import {BlurView} from '../util/BlurView'
 import * as Toast from '../util/Toast'
 import {LoadingPlaceholder} from '../util/LoadingPlaceholder'
@@ -28,14 +27,11 @@ import {UserAvatar} from '../util/UserAvatar'
 import {UserBanner} from '../util/UserBanner'
 import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts'
 import {formatCount} from '../util/numeric/format'
-import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown'
 import {Link} from '../util/Link'
 import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
 import {useModalControls} from '#/state/modals'
 import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
 import {
-  RQKEY as profileQueryKey,
-  useProfileMuteMutationQueue,
   useProfileBlockMutationQueue,
   useProfileFollowMutationQueue,
 } from '#/state/queries/profile'
@@ -46,9 +42,7 @@ import {BACK_HITSLOP} from 'lib/constants'
 import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles'
 import {makeProfileLink} from 'lib/routes/links'
 import {pluralize} from 'lib/strings/helpers'
-import {toShareUrl} from 'lib/strings/url-helpers'
 import {sanitizeDisplayName} from 'lib/strings/display-names'
-import {shareUrl} from 'lib/sharing'
 import {s, colors} from 'lib/styles'
 import {logger} from '#/logger'
 import {useSession} from '#/state/session'
@@ -57,6 +51,7 @@ import {useRequireAuth} from '#/state/session'
 import {LabelInfo} from '../util/moderation/LabelInfo'
 import {useProfileShadow} from 'state/cache/profile-shadow'
 import {atoms as a} from '#/alf'
+import {ProfileMenu} from 'view/com/profile/ProfileMenu'
 
 let ProfileHeaderLoading = (_props: {}): React.ReactNode => {
   const pal = usePalette('default')
@@ -108,20 +103,12 @@ let ProfileHeader = ({
   const {isDesktop} = useWebMediaQueries()
   const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false)
   const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
-  const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
-  const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
-  const queryClient = useQueryClient()
+  const [__, queueUnblock] = useProfileBlockMutationQueue(profile)
   const moderation = useMemo(
     () => moderateProfile(profile, moderationOpts),
     [profile, moderationOpts],
   )
 
-  const invalidateProfileQuery = React.useCallback(() => {
-    queryClient.invalidateQueries({
-      queryKey: profileQueryKey(profile.did),
-    })
-  }, [queryClient, profile.did])
-
   const onPressBack = React.useCallback(() => {
     if (navigation.canGoBack()) {
       navigation.goBack()
@@ -189,72 +176,7 @@ let ProfileHeader = ({
     })
   }, [track, openModal, profile])
 
-  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 () => {
+  const onPressUnblockAccount = React.useCallback(() => {
     track('ProfileHeader:UnblockAccountButtonClicked')
     openModal({
       name: 'confirm',
@@ -274,119 +196,12 @@ let ProfileHeader = ({
         }
       },
     })
-  }, [track, queueUnblock, openModal, _])
-
-  const onPressReportAccount = React.useCallback(() => {
-    track('ProfileHeader:ReportAccountButtonClicked')
-    openModal({
-      name: 'report',
-      did: profile.did,
-    })
-  }, [track, openModal, profile])
+  }, [_, openModal, queueUnblock, track])
 
   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) {
-      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.viewer?.muted,
-    profile.viewer?.mutedByList,
-    profile.viewer?.blocking,
-    profile.viewer?.blockingByList,
-    onPressShare,
-    onPressUnmuteAccount,
-    onPressMuteAccount,
-    onPressUnblockAccount,
-    onPressBlockAccount,
-    onPressReportAccount,
-    onPressAddRemoveLists,
-    _,
-  ])
 
   const blockHide =
     !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy)
@@ -516,17 +331,7 @@ let ProfileHeader = ({
               )}
             </>
           ) : null}
-          {dropdownItems?.length ? (
-            <NativeDropdown
-              testID="profileHeaderDropdownBtn"
-              items={dropdownItems}
-              accessibilityLabel={_(msg`More options`)}
-              accessibilityHint="">
-              <View style={[styles.btn, styles.secondaryBtn, pal.btn]}>
-                <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} />
-              </View>
-            </NativeDropdown>
-          ) : undefined}
+          <ProfileMenu profile={profile} />
         </View>
         <View pointerEvents="none">
           <Text
diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx
new file mode 100644
index 000000000..d79e1891d
--- /dev/null
+++ b/src/view/com/profile/ProfileMenu.tsx
@@ -0,0 +1,307 @@
+import React, {memo} from 'react'
+import {TouchableOpacity} from 'react-native'
+import {AppBskyActorDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {useQueryClient} from '@tanstack/react-query'
+import * as Toast from 'view/com/util/Toast'
+import {EventStopper} from 'view/com/util/EventStopper'
+import {useSession} from 'state/session'
+import * as Menu from '#/components/Menu'
+import {useTheme} from '#/alf'
+import {usePalette} from 'lib/hooks/usePalette'
+import {HITSLOP_10} from 'lib/constants'
+import {shareUrl} from 'lib/sharing'
+import {toShareUrl} from 'lib/strings/url-helpers'
+import {makeProfileLink} from 'lib/routes/links'
+import {useAnalytics} from 'lib/analytics/analytics'
+import {useModalControls} from 'state/modals'
+import {
+  RQKEY as profileQueryKey,
+  useProfileBlockMutationQueue,
+  useProfileFollowMutationQueue,
+  useProfileMuteMutationQueue,
+} from 'state/queries/profile'
+import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
+import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle'
+import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
+import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
+import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag'
+import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck'
+import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX'
+import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2'
+import {logger} from '#/logger'
+import {Shadow} from 'state/cache/types'
+
+let ProfileMenu = ({
+  profile,
+}: {
+  profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
+}): React.ReactNode => {
+  const {_} = useLingui()
+  const {currentAccount, hasSession} = useSession()
+  const t = useTheme()
+  // TODO ALF this
+  const pal = usePalette('default')
+  const {track} = useAnalytics()
+  const {openModal} = useModalControls()
+  const queryClient = useQueryClient()
+  const isSelf = currentAccount?.did === profile.did
+
+  const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
+  const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
+  const [, queueUnfollow] = useProfileFollowMutationQueue(profile)
+
+  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 () => {
+    if (profile.viewer?.muted) {
+      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()}`))
+        }
+      }
+    } else {
+      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()}`))
+        }
+      }
+    }
+  }, [profile.viewer?.muted, track, queueUnmute, _, queueMute])
+
+  const onPressBlockAccount = React.useCallback(async () => {
+    if (profile.viewer?.blocking) {
+      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()}`))
+            }
+          }
+        },
+      })
+    } else {
+      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()}`))
+            }
+          }
+        },
+      })
+    }
+  }, [profile.viewer?.blocking, track, openModal, _, queueUnblock, queueBlock])
+
+  const onPressUnfollowAccount = React.useCallback(async () => {
+    track('ProfileHeader:UnfollowButtonClicked')
+    try {
+      await queueUnfollow()
+      Toast.show(_(msg`Account unfollowed`))
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to unfollow account', {message: e})
+        Toast.show(_(msg`There was an issue! ${e.toString()}`))
+      }
+    }
+  }, [_, queueUnfollow, track])
+
+  const onPressReportAccount = React.useCallback(() => {
+    track('ProfileHeader:ReportAccountButtonClicked')
+    openModal({
+      name: 'report',
+      did: profile.did,
+    })
+  }, [track, openModal, profile])
+
+  return (
+    <EventStopper onKeyDown={false}>
+      <Menu.Root>
+        <Menu.Trigger label={_(`More options`)}>
+          {({props}) => {
+            return (
+              <TouchableOpacity
+                {...props}
+                hitSlop={HITSLOP_10}
+                testID="profileHeaderDropdownBtn"
+                style={[
+                  {
+                    flexDirection: 'row',
+                    alignItems: 'center',
+                    justifyContent: 'center',
+                    paddingVertical: 7,
+                    borderRadius: 50,
+                    marginLeft: 6,
+                    paddingHorizontal: 14,
+                  },
+                  pal.btn,
+                ]}>
+                <FontAwesomeIcon
+                  icon="ellipsis"
+                  size={20}
+                  style={t.atoms.text}
+                />
+              </TouchableOpacity>
+            )
+          }}
+        </Menu.Trigger>
+
+        <Menu.Outer style={{minWidth: 170}}>
+          <Menu.Group>
+            <Menu.Item
+              testID="profileHeaderDropdownShareBtn"
+              label={_(msg`Share`)}
+              onPress={onPressShare}>
+              <Menu.ItemText>
+                <Trans>Share</Trans>
+              </Menu.ItemText>
+              <Menu.ItemIcon icon={Share} />
+            </Menu.Item>
+          </Menu.Group>
+          {hasSession && (
+            <>
+              <Menu.Divider />
+              <Menu.Group>
+                <Menu.Item
+                  testID="profileHeaderDropdownListAddRemoveBtn"
+                  label={_(msg`Add to Lists`)}
+                  onPress={onPressAddRemoveLists}>
+                  <Menu.ItemText>
+                    <Trans>Add to Lists</Trans>
+                  </Menu.ItemText>
+                  <Menu.ItemIcon icon={List} />
+                </Menu.Item>
+                {!isSelf && (
+                  <>
+                    {profile.viewer?.following &&
+                      (profile.viewer.blocking || profile.viewer.blockedBy) && (
+                        <Menu.Item
+                          testID="profileHeaderDropdownUnfollowBtn"
+                          label={_(msg`Unfollow Account`)}
+                          onPress={onPressUnfollowAccount}>
+                          <Menu.ItemText>
+                            <Trans>Unfollow Account</Trans>
+                          </Menu.ItemText>
+                          <Menu.ItemIcon icon={UserMinus} />
+                        </Menu.Item>
+                      )}
+                    {!profile.viewer?.blocking &&
+                      !profile.viewer?.mutedByList && (
+                        <Menu.Item
+                          testID="profileHeaderDropdownMuteBtn"
+                          label={
+                            profile.viewer?.muted
+                              ? _(msg`Unmute Account`)
+                              : _(msg`Mute Account`)
+                          }
+                          onPress={onPressMuteAccount}>
+                          <Menu.ItemText>
+                            {profile.viewer?.muted ? (
+                              <Trans>Unmute Account</Trans>
+                            ) : (
+                              <Trans>Mute Account</Trans>
+                            )}
+                          </Menu.ItemText>
+                          <Menu.ItemIcon
+                            icon={profile.viewer?.muted ? Unmute : Mute}
+                          />
+                        </Menu.Item>
+                      )}
+                    {!profile.viewer?.blockingByList && (
+                      <Menu.Item
+                        testID="profileHeaderDropdownBlockBtn"
+                        label={
+                          profile.viewer
+                            ? _(msg`Unblock Account`)
+                            : _(msg`Block Account`)
+                        }
+                        onPress={onPressBlockAccount}>
+                        <Menu.ItemText>
+                          {profile.viewer?.blocking ? (
+                            <Trans>Unblock Account</Trans>
+                          ) : (
+                            <Trans>Block Account</Trans>
+                          )}
+                        </Menu.ItemText>
+                        <Menu.ItemIcon
+                          icon={
+                            profile.viewer?.blocking ? PersonCheck : PersonX
+                          }
+                        />
+                      </Menu.Item>
+                    )}
+                    <Menu.Item
+                      testID="profileHeaderDropdownReportBtn"
+                      label={_(msg`Report Account`)}
+                      onPress={onPressReportAccount}>
+                      <Menu.ItemText>
+                        <Trans>Report Account</Trans>
+                      </Menu.ItemText>
+                      <Menu.ItemIcon icon={Flag} />
+                    </Menu.Item>
+                  </>
+                )}
+              </Menu.Group>
+            </>
+          )}
+        </Menu.Outer>
+      </Menu.Root>
+    </EventStopper>
+  )
+}
+
+ProfileMenu = memo(ProfileMenu)
+export {ProfileMenu}
-- 
cgit 1.4.1

