From 090b35e52e3c42214bcf044a70d3658d1cf8b2de Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 12 Mar 2024 14:06:12 -0700 Subject: [PATCH] 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 --- assets/icons/flag_stroke2_corner0_rounded.svg | 1 + .../peopleRemove2_stroke2_corner0_rounded.svg | 1 + .../personCheck_stroke2_corner0_rounded.svg | 1 + .../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 diff --git a/assets/icons/flag_stroke2_corner0_rounded.svg b/assets/icons/flag_stroke2_corner0_rounded.svg new file mode 100644 index 00000000..9f9cc5cd --- /dev/null +++ b/assets/icons/flag_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..2e798cbe --- /dev/null +++ b/assets/icons/peopleRemove2_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..b3231c27 --- /dev/null +++ b/assets/icons/personCheck_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ 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 00000000..073015bc --- /dev/null +++ b/assets/icons/personX_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index f9b697ea..9be9dd86 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 +}>) { const context = React.useContext(Context) return ( diff --git a/src/components/Menu/index.web.tsx b/src/components/Menu/index.web.tsx index f23c39ce..2004ee7c 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 +}>) { 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} diff --git a/src/components/icons/Flag.tsx b/src/components/icons/Flag.tsx new file mode 100644 index 00000000..d986db75 --- /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 00000000..3d16ed96 --- /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 00000000..097271d8 --- /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 00000000..a015e137 --- /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 3e479d7b..a11fe837 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 ? ( - - - - - - ) : undefined} + +}): 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 ( + + + + {({props}) => { + return ( + + + + ) + }} + + + + + + + Share + + + + + {hasSession && ( + <> + + + + + Add to Lists + + + + {!isSelf && ( + <> + {profile.viewer?.following && + (profile.viewer.blocking || profile.viewer.blockedBy) && ( + + + Unfollow Account + + + + )} + {!profile.viewer?.blocking && + !profile.viewer?.mutedByList && ( + + + {profile.viewer?.muted ? ( + Unmute Account + ) : ( + Mute Account + )} + + + + )} + {!profile.viewer?.blockingByList && ( + + + {profile.viewer?.blocking ? ( + Unblock Account + ) : ( + Block Account + )} + + + + )} + + + Report Account + + + + + )} + + + )} + + + + ) +} + +ProfileMenu = memo(ProfileMenu) +export {ProfileMenu}