import {memo, useCallback, useMemo} from 'react' import {useRef, useState} from 'react' import * as ExpoClipboard from 'expo-clipboard' import {Trans, useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' import {useOpenLink} from '#/lib/hooks/useOpenLink' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {toShareUrl, toShareUrlBsky} from '#/lib/strings/url-helpers' import {type Shadow} from '#/state/cache/types' import { toAtprotoExplorerUrl, useAtprotoExplorer, } from '#/state/preferences/atproto-explorer' import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import { useDeerVerificationEnabled, useDeerVerificationTrusted, useSetDeerVerificationTrust, } from '#/state/preferences/deer-verification' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useDeerVerificationProfileOverlay} from '#/state/queries/deer-verification' import { RQKEY as profileQueryKey, useProfileBlockMutationQueue, useProfileFollowMutationQueue, useProfileMuteMutationQueue, useProfileMuteRepostsMutationQueue, } from '#/state/queries/profile' import {useSession} from '#/state/session' import {EventStopper} from '#/view/com/util/EventStopper' import {Button, ButtonIcon} from '#/components/Button' import {useDialogControl} from '#/components/Dialog' import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' import {UserAddRemoveListsDialog} from '#/components/dialogs/lists/UserAddRemoveListsDialog' import {StarterPackDialog} from '#/components/dialogs/StarterPackDialog' import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as FlagIcon} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as ListIcon} from '#/components/icons/ListSparkle' import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {PeopleRemove2_Stroke2_Corner0_Rounded as UserMinusIcon} from '#/components/icons/PeopleRemove2' import { PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon, PersonX_Stroke2_Corner0_Rounded as PersonXIcon, } from '#/components/icons/Person' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' import { Repost_Stroke2_Corner0_Rounded as RepostIcon, RepostStrike_Stroke2_Corner0_Rounded as RepostStrikeIcon, } from '#/components/icons/Repost' import {BlueskyIcon} from '#/components/icons/services/Bluesky' import {PDSlsIcon} from '#/components/icons/services/PDSls' import {SkyTraceIcon} from '#/components/icons/services/SkyTrace' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {StarterPack_Stroke2_Corner0_Rounded as StarterPackIcon} from '#/components/icons/StarterPack' import * as Menu from '#/components/Menu' import {CheckboxItemText} from '#/components/Menu/CheckboxItem' import {BlockDialog} from '#/components/moderation/BlockDialog' import { ReportDialog, useReportDialogControl, } from '#/components/moderation/ReportDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {useFullVerificationState} from '#/components/verification' import {VerificationCreatePrompt} from '#/components/verification/VerificationCreatePrompt' import {VerificationRemovePrompt} from '#/components/verification/VerificationRemovePrompt' import {useAnalytics} from '#/analytics' import {IS_IOS, IS_WEB} from '#/env' import {useActorStatus, useLiveNowConfig} from '#/features/liveNow' import {EditLiveDialog} from '#/features/liveNow/components/EditLiveDialog' import {GoLiveDialog} from '#/features/liveNow/components/GoLiveDialog' import {GoLiveDisabledDialog} from '#/features/liveNow/components/GoLiveDisabledDialog' import {type app} from '#/lexicons' import {useDevMode} from '#/storage/hooks/dev-mode' let ProfileMenu = ({ profile, }: { profile: Shadow }): React.ReactNode => { const ax = useAnalytics() const {t: l} = useLingui() const {currentAccount, hasSession} = useSession() const reportDialogControl = useReportDialogControl() const queryClient = useQueryClient() const navigation = useNavigation() const isSelf = currentAccount?.did === profile.did const isFollowedBy = profile.viewer?.followedBy const isFollowing = profile.viewer?.following const isBlocked = profile.viewer?.blocking || profile.viewer?.blockedBy const isFollowingBlockedAccount = isFollowing && isBlocked const isLabeler = !!profile.associated?.labeler const [devModeEnabled] = useDevMode() const copyLinksRef = useRef(false) const profileWithDeerVerification = useDeerVerificationProfileOverlay(profile) const verification = useFullVerificationState({ profile: profileWithDeerVerification, }) const {canGoLive} = useLiveNowConfig() const status = useActorStatus(profile) const deerVerificationEnabled = useDeerVerificationEnabled() const deerVerificationTrusted = useDeerVerificationTrusted().has(profile.did) const setDeerVerificationTrust = useSetDeerVerificationTrust() const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) const [queueMuteReposts, queueUnmuteReposts] = useProfileMuteRepostsMutationQueue(profile) const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue( profile, 'ProfileMenu', ) const blockPromptControl = Prompt.usePromptControl() const loggedOutWarningPromptControl = Prompt.usePromptControl() const goLiveDialogControl = useDialogControl() const goLiveDisabledDialogControl = useDialogControl() const addToStarterPacksDialogControl = useDialogControl() const addToListsDialogControl = useDialogControl() const pendingShareAction = useRef<() => void>(() => {}) const atprotoExplorer = useAtprotoExplorer() const openLink = useOpenLink() const atprotoExplorerRepositoryUrl = toAtprotoExplorerUrl( atprotoExplorer, `at://${profile.did}`, ) const showLoggedOutWarning = useMemo(() => { return ( profile.did !== currentAccount?.did && !!profile.labels?.find(label => label.val === '!no-unauthenticated') ) }, [currentAccount, profile]) const invalidateProfileQuery = useCallback(() => { void queryClient.invalidateQueries({ queryKey: profileQueryKey(profile.did), }) }, [queryClient, profile.did]) const onPressAddToStarterPacks = useCallback(() => { ax.metric('profile:addToStarterPack', {}) addToStarterPacksDialogControl.open() }, [addToStarterPacksDialogControl, ax]) const onPressShareBsky = useCallback(() => { void shareUrl(toShareUrlBsky(makeProfileLink(profile))) }, [profile]) const onPressShareHandle = useCallback(() => { void shareText(profile.handle) }, [profile.handle]) const onPressCopy = useCallback(async () => { const url = toShareUrl(makeProfileLink(profile)) if (IS_IOS) { await ExpoClipboard.setUrlAsync(url) } else { await ExpoClipboard.setStringAsync(url) } Toast.show(l`Copied to clipboard`, {type: 'success'}) }, [l, profile]) const onPressCopyBsky = useCallback(async () => { const url = toShareUrlBsky(makeProfileLink(profile)) if (IS_IOS) { await ExpoClipboard.setUrlAsync(url) } else { await ExpoClipboard.setStringAsync(url) } Toast.show(l`Copied to clipboard`, {type: 'success'}) }, [l, profile]) const onPressCopyHandle = useCallback(async () => { await ExpoClipboard.setStringAsync(profile.handle) Toast.show(l`Copied to clipboard`, {type: 'success'}) }, [l, profile.handle]) const onPressCopyAtprotoExplorer = useCallback( async (url: string) => { if (IS_IOS) { await ExpoClipboard.setUrlAsync(url) } else { await ExpoClipboard.setStringAsync(url) } Toast.show(l`Copied to clipboard`, {type: 'success'}) }, [l], ) const shareOrWarn = useCallback( (action: () => void) => { if (showLoggedOutWarning) { pendingShareAction.current = action loggedOutWarningPromptControl.open() } else { action() } }, [loggedOutWarningPromptControl, showLoggedOutWarning], ) const onPressAddRemoveLists = useCallback(() => { addToListsDialogControl.open() }, [addToListsDialogControl]) const onPressMuteAccount = useCallback(async () => { if (profile.viewer?.muted) { try { await queueUnmute() Toast.show(l({message: 'Account unmuted', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to unmute account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } else { try { await queueMute() Toast.show(l({message: 'Account muted', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to mute account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } }, [ax, profile.viewer?.muted, queueUnmute, l, queueMute]) const onPressMuteReposts = useCallback(async () => { if (profile.viewer?.mutedOnlyReposts) { try { await queueUnmuteReposts() Toast.show( l({message: 'Reposts will be shown in feeds', context: 'toast'}), ) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to show reposts', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } else { try { await queueMuteReposts() Toast.show( l({message: 'Reposts will be hidden in feeds', context: 'toast'}), ) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to hide reposts', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } }, [ax, profile.viewer, queueUnmuteReposts, l, queueMuteReposts]) const blockAccount = useCallback(async () => { if (profile.viewer?.blocking) { try { await queueUnblock() Toast.show(l({message: 'Account unblocked', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to unblock account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } else { try { await queueBlock() Toast.show(l({message: 'Account blocked', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to block account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } } }, [ax, profile.viewer?.blocking, l, queueUnblock, queueBlock]) const confirmFollowUnfollow = useConfirmFollowUnfollow() const followPromptControl = Prompt.usePromptControl() const [confirmationAction, setConfirmationAction] = useState< 'follow' | 'unfollow' >('follow') const executeFollow = useCallback(async () => { try { await queueFollow() Toast.show(l({message: 'Account followed', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to follow account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } }, [l, ax, queueFollow]) const executeUnfollow = useCallback(async () => { try { await queueUnfollow() Toast.show(l({message: 'Account unfollowed', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { ax.logger.error('Failed to unfollow account', {message: e}) Toast.show(l`There was an issue! ${e.toString()}`, { type: 'error', }) } } }, [l, ax, queueUnfollow]) const onPressFollowAccount = useCallback(() => { void executeFollow() }, [confirmFollowUnfollow, executeFollow, followPromptControl]) const onPressUnfollowAccount = useCallback(() => { if (confirmFollowUnfollow) { setConfirmationAction('unfollow') followPromptControl.open() } else { void executeUnfollow() } }, [confirmFollowUnfollow, executeUnfollow, followPromptControl]) const onConfirmFollowAction = useCallback(() => { if (confirmationAction === 'follow') { void executeFollow() } else { void executeUnfollow() } }, [confirmationAction, executeFollow, executeUnfollow]) const onPressReportAccount = useCallback(() => { reportDialogControl.open() }, [reportDialogControl]) const onPressShareATUri = useCallback(() => { void shareText(`at://${profile.did}`) }, [profile.did]) const onPressShareDID = useCallback(() => { void shareText(profile.did) }, [profile.did]) const onPressSearch = useCallback(() => { navigation.navigate('ProfileSearch', {name: profile.handle}) }, [navigation, profile.handle]) const onOpenProfileInAtprotoExplorer = () => { openLink( toAtprotoExplorerUrl( atprotoExplorer, `at://${profile.did}/app.bsky.actor.profile/self`, ), ) } const onOpenRepoInAtprotoExplorer = () => { openLink(toAtprotoExplorerUrl(atprotoExplorer, `at://${profile.did}`)) } const onOpenProfileInSkyTrace = () => { openLink(`https://skytrace.aly.town/profile/${profile.did}`) } const verificationCreatePromptControl = Prompt.usePromptControl() const verificationRemovePromptControl = Prompt.usePromptControl() const currentAccountVerifications = profile.verification?.verifications?.filter(v => { return v.issuer === currentAccount?.did }) ?? [] return ( {({props}) => { return ( ) }} { shareOrWarn(() => { void onPressCopy() }) }}> Copy link to profile Share }> { shareOrWarn(() => { if (IS_WEB || copyLinksRef.current) { void onPressCopyHandle() } else { onPressShareHandle() } }) }}> Handle { shareOrWarn(() => { if (!IS_WEB && copyLinksRef.current) { void onPressCopyBsky() } else { onPressShareBsky() } }) }}> Bluesky { shareOrWarn(() => { if (IS_WEB || copyLinksRef.current) { void onPressCopyAtprotoExplorer( atprotoExplorerRepositoryUrl, ) } else { void shareUrl(atprotoExplorerRepositoryUrl) } }) }}> Repository {!IS_WEB && ( { copyLinksRef.current = value }}> Copy )} Open }> Repository Profile SkyTrace Search posts {hasSession && ( <> {!isSelf && ( <> {(isLabeler || isFollowingBlockedAccount) && ( void onPressUnfollowAccount() : () => void onPressFollowAccount() }> {isFollowing ? ( isFollowedBy ? ( Divorce mutual ) : ( Unfollow account ) ) : ( Follow account )} )} )} Add to Starter Packs Add to lists {!isSelf && deerVerificationEnabled && (deerVerificationTrusted ? ( setDeerVerificationTrust.remove(profile.did) }> Remove trust ) : ( setDeerVerificationTrust.add(profile.did)}> Trust verifier ))} {isSelf && canGoLive && ( { if (status.isDisabled) { goLiveDisabledDialogControl.open() } else { goLiveDialogControl.open() } }}> {status.isDisabled ? ( Go live (disabled) ) : status.isActive ? ( Edit live status ) : ( Go live )} )} {verification.viewer.role === 'verifier' && !verification.profile.isViewer && (verification.viewer.hasIssuedVerification ? ( verificationRemovePromptControl.open()}> Remove verification ) : ( verificationCreatePromptControl.open()}> Verify account ))} {!isSelf && ( <> {!profile.viewer?.blocking && !profile.viewer?.mutedByList && ( <> {!profile.viewer?.muted && ( void onPressMuteReposts()}> {profile.viewer?.mutedOnlyReposts ? ( Show reposts in feeds ) : ( Hide reposts in feeds )} )} void onPressMuteAccount()}> {profile.viewer?.muted ? ( Unmute account ) : ( Mute account )} )} {!profile.viewer?.blockingByList && ( blockPromptControl.open()}> {profile.viewer?.blocking ? ( Unblock account ) : ( Block account )} )} Report account )} )} {devModeEnabled ? ( <> Copy at:// URI Copy DID ) : null} {confirmFollowUnfollow && ( )} pendingShareAction.current()} confirmButtonCta={l`Share anyway`} /> {status.isDisabled ? ( ) : status.isActive ? ( ) : ( )} ) } ProfileMenu = memo(ProfileMenu) export {ProfileMenu}