Bluesky app fork with some witchin' additions 💫

Use new menu for Profile (#3168)

* 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>

authored by hailey.at

Samuel Newman and committed by
GitHub
090b35e5 70ad820d

+351 -205
+1
assets/icons/flag_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/peopleRemove2_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/personCheck_stroke2_corner0_rounded.svg
··· 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>
+1
assets/icons/personX_stroke2_corner0_rounded.svg
··· 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>
+5 -2
src/components/Menu/index.tsx
··· 1 1 import React from 'react' 2 - import {View, Pressable} from 'react-native' 2 + import {View, Pressable, ViewStyle, StyleProp} from 'react-native' 3 3 import flattenReactChildren from 'react-keyed-flatten-children' 4 4 5 5 import {atoms as a, useTheme} from '#/alf' ··· 75 75 export function Outer({ 76 76 children, 77 77 showCancel, 78 - }: React.PropsWithChildren<{showCancel?: boolean}>) { 78 + }: React.PropsWithChildren<{ 79 + showCancel?: boolean 80 + style?: StyleProp<ViewStyle> 81 + }>) { 79 82 const context = React.useContext(Context) 80 83 81 84 return (
+9 -2
src/components/Menu/index.web.tsx
··· 1 1 /* eslint-disable react/prop-types */ 2 2 3 3 import React from 'react' 4 - import {View, Pressable} from 'react-native' 4 + import {View, Pressable, ViewStyle, StyleProp} from 'react-native' 5 5 import * as DropdownMenu from '@radix-ui/react-dropdown-menu' 6 6 7 7 import * as Dialog from '#/components/Dialog' ··· 132 132 ) 133 133 } 134 134 135 - export function Outer({children}: React.PropsWithChildren<{}>) { 135 + export function Outer({ 136 + children, 137 + style, 138 + }: React.PropsWithChildren<{ 139 + showCancel?: boolean 140 + style?: StyleProp<ViewStyle> 141 + }>) { 136 142 const t = useTheme() 137 143 138 144 return ( ··· 144 150 a.p_xs, 145 151 t.name === 'light' ? t.atoms.bg : t.atoms.bg_contrast_25, 146 152 t.atoms.shadow_md, 153 + style, 147 154 ]}> 148 155 {children} 149 156 </View>
+5
src/components/icons/Flag.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Flag_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/PeopleRemove2.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PeopleRemove2_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/PersonCheck.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PersonCheck_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+5
src/components/icons/PersonX.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PersonX_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+6 -201
src/view/com/profile/ProfileHeader.tsx
··· 7 7 } from 'react-native' 8 8 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 9 import {useNavigation} from '@react-navigation/native' 10 - import {useQueryClient} from '@tanstack/react-query' 11 10 import { 12 11 AppBskyActorDefs, 13 12 ModerationOpts, ··· 17 16 import {Trans, msg} from '@lingui/macro' 18 17 import {useLingui} from '@lingui/react' 19 18 import {NavigationProp} from 'lib/routes/types' 20 - import {isNative, isWeb} from 'platform/detection' 19 + import {isNative} from 'platform/detection' 21 20 import {BlurView} from '../util/BlurView' 22 21 import * as Toast from '../util/Toast' 23 22 import {LoadingPlaceholder} from '../util/LoadingPlaceholder' ··· 28 27 import {UserBanner} from '../util/UserBanner' 29 28 import {ProfileHeaderAlerts} from '../util/moderation/ProfileHeaderAlerts' 30 29 import {formatCount} from '../util/numeric/format' 31 - import {NativeDropdown, DropdownItem} from '../util/forms/NativeDropdown' 32 30 import {Link} from '../util/Link' 33 31 import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows' 34 32 import {useModalControls} from '#/state/modals' 35 33 import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' 36 34 import { 37 - RQKEY as profileQueryKey, 38 - useProfileMuteMutationQueue, 39 35 useProfileBlockMutationQueue, 40 36 useProfileFollowMutationQueue, 41 37 } from '#/state/queries/profile' ··· 46 42 import {isInvalidHandle, sanitizeHandle} from 'lib/strings/handles' 47 43 import {makeProfileLink} from 'lib/routes/links' 48 44 import {pluralize} from 'lib/strings/helpers' 49 - import {toShareUrl} from 'lib/strings/url-helpers' 50 45 import {sanitizeDisplayName} from 'lib/strings/display-names' 51 - import {shareUrl} from 'lib/sharing' 52 46 import {s, colors} from 'lib/styles' 53 47 import {logger} from '#/logger' 54 48 import {useSession} from '#/state/session' ··· 57 51 import {LabelInfo} from '../util/moderation/LabelInfo' 58 52 import {useProfileShadow} from 'state/cache/profile-shadow' 59 53 import {atoms as a} from '#/alf' 54 + import {ProfileMenu} from 'view/com/profile/ProfileMenu' 60 55 61 56 let ProfileHeaderLoading = (_props: {}): React.ReactNode => { 62 57 const pal = usePalette('default') ··· 108 103 const {isDesktop} = useWebMediaQueries() 109 104 const [showSuggestedFollows, setShowSuggestedFollows] = React.useState(false) 110 105 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) 111 - const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 112 - const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 113 - const queryClient = useQueryClient() 106 + const [__, queueUnblock] = useProfileBlockMutationQueue(profile) 114 107 const moderation = useMemo( 115 108 () => moderateProfile(profile, moderationOpts), 116 109 [profile, moderationOpts], 117 110 ) 118 111 119 - const invalidateProfileQuery = React.useCallback(() => { 120 - queryClient.invalidateQueries({ 121 - queryKey: profileQueryKey(profile.did), 122 - }) 123 - }, [queryClient, profile.did]) 124 - 125 112 const onPressBack = React.useCallback(() => { 126 113 if (navigation.canGoBack()) { 127 114 navigation.goBack() ··· 189 176 }) 190 177 }, [track, openModal, profile]) 191 178 192 - const onPressShare = React.useCallback(() => { 193 - track('ProfileHeader:ShareButtonClicked') 194 - shareUrl(toShareUrl(makeProfileLink(profile))) 195 - }, [track, profile]) 196 - 197 - const onPressAddRemoveLists = React.useCallback(() => { 198 - track('ProfileHeader:AddToListsButtonClicked') 199 - openModal({ 200 - name: 'user-add-remove-lists', 201 - subject: profile.did, 202 - handle: profile.handle, 203 - displayName: profile.displayName || profile.handle, 204 - onAdd: invalidateProfileQuery, 205 - onRemove: invalidateProfileQuery, 206 - }) 207 - }, [track, profile, openModal, invalidateProfileQuery]) 208 - 209 - const onPressMuteAccount = React.useCallback(async () => { 210 - track('ProfileHeader:MuteAccountButtonClicked') 211 - try { 212 - await queueMute() 213 - Toast.show(_(msg`Account muted`)) 214 - } catch (e: any) { 215 - if (e?.name !== 'AbortError') { 216 - logger.error('Failed to mute account', {message: e}) 217 - Toast.show(_(msg`There was an issue! ${e.toString()}`)) 218 - } 219 - } 220 - }, [track, queueMute, _]) 221 - 222 - const onPressUnmuteAccount = React.useCallback(async () => { 223 - track('ProfileHeader:UnmuteAccountButtonClicked') 224 - try { 225 - await queueUnmute() 226 - Toast.show(_(msg`Account unmuted`)) 227 - } catch (e: any) { 228 - if (e?.name !== 'AbortError') { 229 - logger.error('Failed to unmute account', {message: e}) 230 - Toast.show(_(msg`There was an issue! ${e.toString()}`)) 231 - } 232 - } 233 - }, [track, queueUnmute, _]) 234 - 235 - const onPressBlockAccount = React.useCallback(async () => { 236 - track('ProfileHeader:BlockAccountButtonClicked') 237 - openModal({ 238 - name: 'confirm', 239 - title: _(msg`Block Account`), 240 - message: _( 241 - msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 242 - ), 243 - onPressConfirm: async () => { 244 - try { 245 - await queueBlock() 246 - Toast.show(_(msg`Account blocked`)) 247 - } catch (e: any) { 248 - if (e?.name !== 'AbortError') { 249 - logger.error('Failed to block account', {message: e}) 250 - Toast.show(_(msg`There was an issue! ${e.toString()}`)) 251 - } 252 - } 253 - }, 254 - }) 255 - }, [track, queueBlock, openModal, _]) 256 - 257 - const onPressUnblockAccount = React.useCallback(async () => { 179 + const onPressUnblockAccount = React.useCallback(() => { 258 180 track('ProfileHeader:UnblockAccountButtonClicked') 259 181 openModal({ 260 182 name: 'confirm', ··· 274 196 } 275 197 }, 276 198 }) 277 - }, [track, queueUnblock, openModal, _]) 278 - 279 - const onPressReportAccount = React.useCallback(() => { 280 - track('ProfileHeader:ReportAccountButtonClicked') 281 - openModal({ 282 - name: 'report', 283 - did: profile.did, 284 - }) 285 - }, [track, openModal, profile]) 199 + }, [_, openModal, queueUnblock, track]) 286 200 287 201 const isMe = React.useMemo( 288 202 () => currentAccount?.did === profile.did, 289 203 [currentAccount, profile], 290 204 ) 291 - const dropdownItems: DropdownItem[] = React.useMemo(() => { 292 - let items: DropdownItem[] = [ 293 - { 294 - testID: 'profileHeaderDropdownShareBtn', 295 - label: isWeb ? _(msg`Copy link to profile`) : _(msg`Share`), 296 - onPress: onPressShare, 297 - icon: { 298 - ios: { 299 - name: 'square.and.arrow.up', 300 - }, 301 - android: 'ic_menu_share', 302 - web: 'share', 303 - }, 304 - }, 305 - ] 306 - if (hasSession) { 307 - items.push({label: 'separator'}) 308 - items.push({ 309 - testID: 'profileHeaderDropdownListAddRemoveBtn', 310 - label: _(msg`Add to Lists`), 311 - onPress: onPressAddRemoveLists, 312 - icon: { 313 - ios: { 314 - name: 'list.bullet', 315 - }, 316 - android: 'ic_menu_add', 317 - web: 'list', 318 - }, 319 - }) 320 - if (!isMe) { 321 - if (!profile.viewer?.blocking) { 322 - if (!profile.viewer?.mutedByList) { 323 - items.push({ 324 - testID: 'profileHeaderDropdownMuteBtn', 325 - label: profile.viewer?.muted 326 - ? _(msg`Unmute Account`) 327 - : _(msg`Mute Account`), 328 - onPress: profile.viewer?.muted 329 - ? onPressUnmuteAccount 330 - : onPressMuteAccount, 331 - icon: { 332 - ios: { 333 - name: 'speaker.slash', 334 - }, 335 - android: 'ic_lock_silent_mode', 336 - web: 'comment-slash', 337 - }, 338 - }) 339 - } 340 - } 341 - if (!profile.viewer?.blockingByList) { 342 - items.push({ 343 - testID: 'profileHeaderDropdownBlockBtn', 344 - label: profile.viewer?.blocking 345 - ? _(msg`Unblock Account`) 346 - : _(msg`Block Account`), 347 - onPress: profile.viewer?.blocking 348 - ? onPressUnblockAccount 349 - : onPressBlockAccount, 350 - icon: { 351 - ios: { 352 - name: 'person.fill.xmark', 353 - }, 354 - android: 'ic_menu_close_clear_cancel', 355 - web: 'user-slash', 356 - }, 357 - }) 358 - } 359 - items.push({ 360 - testID: 'profileHeaderDropdownReportBtn', 361 - label: _(msg`Report Account`), 362 - onPress: onPressReportAccount, 363 - icon: { 364 - ios: { 365 - name: 'exclamationmark.triangle', 366 - }, 367 - android: 'ic_menu_report_image', 368 - web: 'circle-exclamation', 369 - }, 370 - }) 371 - } 372 - } 373 - return items 374 - }, [ 375 - isMe, 376 - hasSession, 377 - profile.viewer?.muted, 378 - profile.viewer?.mutedByList, 379 - profile.viewer?.blocking, 380 - profile.viewer?.blockingByList, 381 - onPressShare, 382 - onPressUnmuteAccount, 383 - onPressMuteAccount, 384 - onPressUnblockAccount, 385 - onPressBlockAccount, 386 - onPressReportAccount, 387 - onPressAddRemoveLists, 388 - _, 389 - ]) 390 205 391 206 const blockHide = 392 207 !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) ··· 516 331 )} 517 332 </> 518 333 ) : null} 519 - {dropdownItems?.length ? ( 520 - <NativeDropdown 521 - testID="profileHeaderDropdownBtn" 522 - items={dropdownItems} 523 - accessibilityLabel={_(msg`More options`)} 524 - accessibilityHint=""> 525 - <View style={[styles.btn, styles.secondaryBtn, pal.btn]}> 526 - <FontAwesomeIcon icon="ellipsis" size={20} style={[pal.text]} /> 527 - </View> 528 - </NativeDropdown> 529 - ) : undefined} 334 + <ProfileMenu profile={profile} /> 530 335 </View> 531 336 <View pointerEvents="none"> 532 337 <Text
+307
src/view/com/profile/ProfileMenu.tsx
··· 1 + import React, {memo} from 'react' 2 + import {TouchableOpacity} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 7 + import {useQueryClient} from '@tanstack/react-query' 8 + import * as Toast from 'view/com/util/Toast' 9 + import {EventStopper} from 'view/com/util/EventStopper' 10 + import {useSession} from 'state/session' 11 + import * as Menu from '#/components/Menu' 12 + import {useTheme} from '#/alf' 13 + import {usePalette} from 'lib/hooks/usePalette' 14 + import {HITSLOP_10} from 'lib/constants' 15 + import {shareUrl} from 'lib/sharing' 16 + import {toShareUrl} from 'lib/strings/url-helpers' 17 + import {makeProfileLink} from 'lib/routes/links' 18 + import {useAnalytics} from 'lib/analytics/analytics' 19 + import {useModalControls} from 'state/modals' 20 + import { 21 + RQKEY as profileQueryKey, 22 + useProfileBlockMutationQueue, 23 + useProfileFollowMutationQueue, 24 + useProfileMuteMutationQueue, 25 + } from 'state/queries/profile' 26 + import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' 27 + import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' 28 + import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' 29 + import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker' 30 + import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' 31 + import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheck} from '#/components/icons/PersonCheck' 32 + import {PersonX_Stroke2_Corner0_Rounded as PersonX} from '#/components/icons/PersonX' 33 + import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinus} from '#/components/icons/PeopleRemove2' 34 + import {logger} from '#/logger' 35 + import {Shadow} from 'state/cache/types' 36 + 37 + let ProfileMenu = ({ 38 + profile, 39 + }: { 40 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> 41 + }): React.ReactNode => { 42 + const {_} = useLingui() 43 + const {currentAccount, hasSession} = useSession() 44 + const t = useTheme() 45 + // TODO ALF this 46 + const pal = usePalette('default') 47 + const {track} = useAnalytics() 48 + const {openModal} = useModalControls() 49 + const queryClient = useQueryClient() 50 + const isSelf = currentAccount?.did === profile.did 51 + 52 + const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 53 + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 54 + const [, queueUnfollow] = useProfileFollowMutationQueue(profile) 55 + 56 + const invalidateProfileQuery = React.useCallback(() => { 57 + queryClient.invalidateQueries({ 58 + queryKey: profileQueryKey(profile.did), 59 + }) 60 + }, [queryClient, profile.did]) 61 + 62 + const onPressShare = React.useCallback(() => { 63 + track('ProfileHeader:ShareButtonClicked') 64 + shareUrl(toShareUrl(makeProfileLink(profile))) 65 + }, [track, profile]) 66 + 67 + const onPressAddRemoveLists = React.useCallback(() => { 68 + track('ProfileHeader:AddToListsButtonClicked') 69 + openModal({ 70 + name: 'user-add-remove-lists', 71 + subject: profile.did, 72 + handle: profile.handle, 73 + displayName: profile.displayName || profile.handle, 74 + onAdd: invalidateProfileQuery, 75 + onRemove: invalidateProfileQuery, 76 + }) 77 + }, [track, profile, openModal, invalidateProfileQuery]) 78 + 79 + const onPressMuteAccount = React.useCallback(async () => { 80 + if (profile.viewer?.muted) { 81 + track('ProfileHeader:UnmuteAccountButtonClicked') 82 + try { 83 + await queueUnmute() 84 + Toast.show(_(msg`Account unmuted`)) 85 + } catch (e: any) { 86 + if (e?.name !== 'AbortError') { 87 + logger.error('Failed to unmute account', {message: e}) 88 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 89 + } 90 + } 91 + } else { 92 + track('ProfileHeader:MuteAccountButtonClicked') 93 + try { 94 + await queueMute() 95 + Toast.show(_(msg`Account muted`)) 96 + } catch (e: any) { 97 + if (e?.name !== 'AbortError') { 98 + logger.error('Failed to mute account', {message: e}) 99 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 100 + } 101 + } 102 + } 103 + }, [profile.viewer?.muted, track, queueUnmute, _, queueMute]) 104 + 105 + const onPressBlockAccount = React.useCallback(async () => { 106 + if (profile.viewer?.blocking) { 107 + track('ProfileHeader:UnblockAccountButtonClicked') 108 + openModal({ 109 + name: 'confirm', 110 + title: _(msg`Unblock Account`), 111 + message: _( 112 + msg`The account will be able to interact with you after unblocking.`, 113 + ), 114 + onPressConfirm: async () => { 115 + try { 116 + await queueUnblock() 117 + Toast.show(_(msg`Account unblocked`)) 118 + } catch (e: any) { 119 + if (e?.name !== 'AbortError') { 120 + logger.error('Failed to unblock account', {message: e}) 121 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 122 + } 123 + } 124 + }, 125 + }) 126 + } else { 127 + track('ProfileHeader:BlockAccountButtonClicked') 128 + openModal({ 129 + name: 'confirm', 130 + title: _(msg`Block Account`), 131 + message: _( 132 + msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 133 + ), 134 + onPressConfirm: async () => { 135 + try { 136 + await queueBlock() 137 + Toast.show(_(msg`Account blocked`)) 138 + } catch (e: any) { 139 + if (e?.name !== 'AbortError') { 140 + logger.error('Failed to block account', {message: e}) 141 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 142 + } 143 + } 144 + }, 145 + }) 146 + } 147 + }, [profile.viewer?.blocking, track, openModal, _, queueUnblock, queueBlock]) 148 + 149 + const onPressUnfollowAccount = React.useCallback(async () => { 150 + track('ProfileHeader:UnfollowButtonClicked') 151 + try { 152 + await queueUnfollow() 153 + Toast.show(_(msg`Account unfollowed`)) 154 + } catch (e: any) { 155 + if (e?.name !== 'AbortError') { 156 + logger.error('Failed to unfollow account', {message: e}) 157 + Toast.show(_(msg`There was an issue! ${e.toString()}`)) 158 + } 159 + } 160 + }, [_, queueUnfollow, track]) 161 + 162 + const onPressReportAccount = React.useCallback(() => { 163 + track('ProfileHeader:ReportAccountButtonClicked') 164 + openModal({ 165 + name: 'report', 166 + did: profile.did, 167 + }) 168 + }, [track, openModal, profile]) 169 + 170 + return ( 171 + <EventStopper onKeyDown={false}> 172 + <Menu.Root> 173 + <Menu.Trigger label={_(`More options`)}> 174 + {({props}) => { 175 + return ( 176 + <TouchableOpacity 177 + {...props} 178 + hitSlop={HITSLOP_10} 179 + testID="profileHeaderDropdownBtn" 180 + style={[ 181 + { 182 + flexDirection: 'row', 183 + alignItems: 'center', 184 + justifyContent: 'center', 185 + paddingVertical: 7, 186 + borderRadius: 50, 187 + marginLeft: 6, 188 + paddingHorizontal: 14, 189 + }, 190 + pal.btn, 191 + ]}> 192 + <FontAwesomeIcon 193 + icon="ellipsis" 194 + size={20} 195 + style={t.atoms.text} 196 + /> 197 + </TouchableOpacity> 198 + ) 199 + }} 200 + </Menu.Trigger> 201 + 202 + <Menu.Outer style={{minWidth: 170}}> 203 + <Menu.Group> 204 + <Menu.Item 205 + testID="profileHeaderDropdownShareBtn" 206 + label={_(msg`Share`)} 207 + onPress={onPressShare}> 208 + <Menu.ItemText> 209 + <Trans>Share</Trans> 210 + </Menu.ItemText> 211 + <Menu.ItemIcon icon={Share} /> 212 + </Menu.Item> 213 + </Menu.Group> 214 + {hasSession && ( 215 + <> 216 + <Menu.Divider /> 217 + <Menu.Group> 218 + <Menu.Item 219 + testID="profileHeaderDropdownListAddRemoveBtn" 220 + label={_(msg`Add to Lists`)} 221 + onPress={onPressAddRemoveLists}> 222 + <Menu.ItemText> 223 + <Trans>Add to Lists</Trans> 224 + </Menu.ItemText> 225 + <Menu.ItemIcon icon={List} /> 226 + </Menu.Item> 227 + {!isSelf && ( 228 + <> 229 + {profile.viewer?.following && 230 + (profile.viewer.blocking || profile.viewer.blockedBy) && ( 231 + <Menu.Item 232 + testID="profileHeaderDropdownUnfollowBtn" 233 + label={_(msg`Unfollow Account`)} 234 + onPress={onPressUnfollowAccount}> 235 + <Menu.ItemText> 236 + <Trans>Unfollow Account</Trans> 237 + </Menu.ItemText> 238 + <Menu.ItemIcon icon={UserMinus} /> 239 + </Menu.Item> 240 + )} 241 + {!profile.viewer?.blocking && 242 + !profile.viewer?.mutedByList && ( 243 + <Menu.Item 244 + testID="profileHeaderDropdownMuteBtn" 245 + label={ 246 + profile.viewer?.muted 247 + ? _(msg`Unmute Account`) 248 + : _(msg`Mute Account`) 249 + } 250 + onPress={onPressMuteAccount}> 251 + <Menu.ItemText> 252 + {profile.viewer?.muted ? ( 253 + <Trans>Unmute Account</Trans> 254 + ) : ( 255 + <Trans>Mute Account</Trans> 256 + )} 257 + </Menu.ItemText> 258 + <Menu.ItemIcon 259 + icon={profile.viewer?.muted ? Unmute : Mute} 260 + /> 261 + </Menu.Item> 262 + )} 263 + {!profile.viewer?.blockingByList && ( 264 + <Menu.Item 265 + testID="profileHeaderDropdownBlockBtn" 266 + label={ 267 + profile.viewer 268 + ? _(msg`Unblock Account`) 269 + : _(msg`Block Account`) 270 + } 271 + onPress={onPressBlockAccount}> 272 + <Menu.ItemText> 273 + {profile.viewer?.blocking ? ( 274 + <Trans>Unblock Account</Trans> 275 + ) : ( 276 + <Trans>Block Account</Trans> 277 + )} 278 + </Menu.ItemText> 279 + <Menu.ItemIcon 280 + icon={ 281 + profile.viewer?.blocking ? PersonCheck : PersonX 282 + } 283 + /> 284 + </Menu.Item> 285 + )} 286 + <Menu.Item 287 + testID="profileHeaderDropdownReportBtn" 288 + label={_(msg`Report Account`)} 289 + onPress={onPressReportAccount}> 290 + <Menu.ItemText> 291 + <Trans>Report Account</Trans> 292 + </Menu.ItemText> 293 + <Menu.ItemIcon icon={Flag} /> 294 + </Menu.Item> 295 + </> 296 + )} 297 + </Menu.Group> 298 + </> 299 + )} 300 + </Menu.Outer> 301 + </Menu.Root> 302 + </EventStopper> 303 + ) 304 + } 305 + 306 + ProfileMenu = memo(ProfileMenu) 307 + export {ProfileMenu}