diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx index 3b719861f..f33e84ca1 100644 --- a/src/components/ProfileCard.tsx +++ b/src/components/ProfileCard.tsx @@ -1,4 +1,4 @@ -import {useMemo} from 'react' +import {useEffect, useMemo, useState} from 'react' import { type GestureResponderEvent, type StyleProp, @@ -20,6 +20,7 @@ import {NON_BREAKING_SPACE} from '#/lib/strings/constants' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {useShowFollowsYouBadge} from '#/state/preferences/show-follows-you-badge' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {useSession} from '#/state/session' @@ -46,6 +47,8 @@ import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus import {Link as InternalLink, type LinkProps} from '#/components/Link' import * as Pills from '#/components/Pills' import {ProfileBadges} from '#/components/ProfileBadges' +import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' +import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' @@ -502,10 +505,11 @@ export function FollowButtonInner({ const hasAlternateAccounts = accounts.some( account => account.did !== currentAccount?.did, ) + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const promptControl = Prompt.usePromptControl() + const [confirmationAction, setConfirmationAction] = useState<'follow' | 'unfollow'>('follow') - const onPressFollow = async (e: GestureResponderEvent) => { - e.preventDefault() - e.stopPropagation() + const executeFollow = async (e: GestureResponderEvent) => { try { await queueFollow() Toast.show( @@ -526,9 +530,7 @@ export function FollowButtonInner({ } } - const onPressUnfollow = async (e: GestureResponderEvent) => { - e.preventDefault() - e.stopPropagation() + const executeUnfollow = async (e: GestureResponderEvent) => { try { await queueUnfollow() Toast.show( @@ -548,6 +550,36 @@ export function FollowButtonInner({ } } + const onPressFollow = (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + if (confirmFollowUnfollow) { + setConfirmationAction('follow') + promptControl.open() + } else { + void executeFollow(e) + } + } + + const onPressUnfollow = (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + if (confirmFollowUnfollow) { + setConfirmationAction('unfollow') + promptControl.open() + } else { + void executeUnfollow(e) + } + } + + const onConfirm = (e: GestureResponderEvent) => { + if (confirmationAction === 'follow') { + void executeFollow(e) + } else if (confirmationAction === 'unfollow') { + void executeUnfollow(e) + } + } + const unfollowLabel = profile.viewer?.followedBy ? l({ message: 'Mutuals', @@ -631,6 +663,18 @@ export function FollowButtonInner({ ) : ( renderFollowButton() )} + {confirmFollowUnfollow && ( + + )} ) } diff --git a/src/components/ProfileHoverCard/index.web.tsx b/src/components/ProfileHoverCard/index.web.tsx index 6e2371b9c..a826ea853 100644 --- a/src/components/ProfileHoverCard/index.web.tsx +++ b/src/components/ProfileHoverCard/index.web.tsx @@ -1,4 +1,4 @@ -import {memo, useCallback, useEffect, useMemo, useReducer, useRef} from 'react' +import {memo, useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -16,6 +16,7 @@ import {type NavigationProp} from '#/lib/routes/types' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' import {useDisableFollowersMetrics} from '#/state/preferences/disable-followers-metrics' import {useDisableFollowingMetrics} from '#/state/preferences/disable-following-metrics' @@ -44,6 +45,11 @@ import {Loader} from '#/components/Loader' import * as Pills from '#/components/Pills' import {Portal} from '#/components/Portal' import {ProfileBadges} from '#/components/ProfileBadges' +import * as Prompt from '#/components/Prompt' +import { + FollowConfirmationDialog, + type FollowActionType, +} from '#/components/dialogs/FollowConfirmationDialog' import {RichText} from '#/components/RichText' import {Text} from '#/components/Typography' import {IS_WEB_TOUCH_DEVICE} from '#/env' @@ -123,6 +129,14 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { middleware: floatingMiddlewares, }) + const followPromptControl = Prompt.usePromptControl() + const [followConfirmState, setFollowConfirmState] = useState<{ + actionType: FollowActionType + onConfirm: () => void + displayName: string + handle: string + } | null>(null) + const [currentState, dispatch] = useReducer( // Tip: console.log(state, action) when debugging. (state: State, action: Action): State => { @@ -347,11 +361,29 @@ export function ProfileHoverCardInner(props: ProfileHoverCardProps) { onPointerEnter={onPointerEnterCard} onPointerLeave={onPointerLeaveCard}>
- + { + setFollowConfirmState(params) + followPromptControl.open() + }} + />
)} + { + followConfirmState?.onConfirm() + setFollowConfirmState(null) + }} + /> ) } @@ -360,10 +392,17 @@ let Card = ({ did, hide, navigation, + onRequestFollowConfirmation, }: { did: string hide: () => void navigation: NavigationProp + onRequestFollowConfirmation?: (params: { + actionType: FollowActionType + onConfirm: () => void + displayName: string + handle: string + }) => void }): React.ReactNode => { const t = useTheme() @@ -408,7 +447,12 @@ let Card = ({ onPressOpenProfile={onPressOpenProfile} /> ) : ( - + ) ) : ( void + onRequestFollowConfirmation?: (params: { + actionType: FollowActionType + onConfirm: () => void + displayName: string + handle: string + }) => void }) { const t = useTheme() const {_, i18n} = useLingui() @@ -448,6 +499,7 @@ function Inner({ profile: profileShadow, logContext: 'ProfileHoverCard', }) + const confirmFollowUnfollow = useConfirmFollowUnfollow() const isBlockedUser = profile.viewer?.blocking || profile.viewer?.blockedBy || @@ -479,6 +531,52 @@ function Inner({ const disableFollowingMetrics = useDisableFollowingMetrics() const disableFollowedByMetrics = useDisableFollowedByMetrics() + const handleFollow = useCallback(() => { + if (confirmFollowUnfollow && onRequestFollowConfirmation) { + onRequestFollowConfirmation({ + actionType: 'follow', + onConfirm: follow, + displayName: sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ), + handle: profile.handle, + }) + } else { + follow() + } + }, [ + confirmFollowUnfollow, + follow, + onRequestFollowConfirmation, + profile.displayName, + profile.handle, + moderation, + ]) + + const handleUnfollow = useCallback(() => { + if (confirmFollowUnfollow && onRequestFollowConfirmation) { + onRequestFollowConfirmation({ + actionType: 'unfollow', + onConfirm: unfollow, + displayName: sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ), + handle: profile.handle, + }) + } else { + unfollow() + } + }, [ + confirmFollowUnfollow, + unfollow, + onRequestFollowConfirmation, + profile.displayName, + profile.handle, + moderation, + ]) + return ( @@ -519,7 +617,7 @@ function Inner({ : _(msg`Follow`) } style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]} - onPress={profileShadow.viewer?.following ? unfollow : follow}> + onPress={profileShadow.viewer?.following ? handleUnfollow : handleFollow}> void +} + +export function FollowConfirmationDialog({ + control, + displayName, + handle, + actionType, + onConfirm, +}: FollowConfirmationDialogProps) { + const {t: l} = useLingui() + const confirmFollowUnfollow = useConfirmFollowUnfollow() + + if (!confirmFollowUnfollow) { + return null + } + + const isFollowing = actionType === 'follow' + const title = isFollowing + ? l({ + message: `Follow ${displayName}?`, + comment: 'Title for follow confirmation dialog', + }) + : l({ + message: `Unfollow ${displayName}?`, + comment: 'Title for unfollow confirmation dialog', + }) + + const description = isFollowing + ? l({ + message: `You are about to follow @${handle}.`, + comment: 'Description for follow confirmation dialog', + }) + : l({ + message: `You are about to unfollow @${handle}.`, + comment: 'Description for unfollow confirmation dialog', + }) + + const confirmLabel = isFollowing + ? l({ + message: 'Follow', + comment: 'Confirm button label for follow action', + }) + : l({ + message: 'Unfollow', + comment: 'Confirm button label for unfollow action', + }) + + return ( + + ) +} diff --git a/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx index 70009f87e..d4f0c8491 100644 --- a/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx @@ -6,7 +6,9 @@ import {Trans} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {logger} from '#/logger' +import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import { useProfileFollowMutationQueue, @@ -22,6 +24,8 @@ import { DoubleCheck_Stroke2_Corner0_Rounded as DoubleCheckIcon, } from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus' +import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' +import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {IS_IOS} from '#/env' import {GrowthHack} from './GrowthHack' @@ -79,6 +83,10 @@ function PostThreadFollowBtnLoaded({ profile, logContext: 'PostThreadItem', }) + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const promptControl = Prompt.usePromptControl() + const [confirmationAction, setConfirmationAction] = + useState<'follow' | 'unfollow'>('follow') const isFollowing = !!profile.viewer?.following const isFollowedBy = !!profile.viewer?.followedBy @@ -122,35 +130,68 @@ function PostThreadFollowBtnLoaded({ } }, [isFollowing, wasFollowing, navigation]) + const executeFollow = useCallback(async () => { + try { + await queueFollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), { + type: 'error', + }) + } + } + }, [queueFollow, _]) + + const executeUnfollow = useCallback(async () => { + try { + await queueUnfollow() + } catch (e: any) { + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), { + type: 'error', + }) + } + } + }, [queueUnfollow, _]) + + const onConfirm = useCallback(() => { + if (confirmationAction === 'follow') { + void executeFollow() + } else { + void executeUnfollow() + } + }, [confirmationAction, executeFollow, executeUnfollow]) + const onPress = useCallback(() => { if (!isFollowing) { - requireAuth(async () => { - try { - await queueFollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { - type: 'error', - }) - } + requireAuth(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('follow') + promptControl.open() + } else { + void executeFollow() } }) } else { - requireAuth(async () => { - try { - await queueUnfollow() - } catch (e: any) { - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { - type: 'error', - }) - } + requireAuth(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('unfollow') + promptControl.open() + } else { + void executeUnfollow() } }) } - }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow]) + }, [ + isFollowing, + requireAuth, + confirmFollowUnfollow, + executeFollow, + executeUnfollow, + promptControl, + ]) if (!showFollowBtn) return null @@ -191,19 +232,32 @@ function PostThreadFollowBtnLoaded({ ) - return currentAccount && hasAlternateAccounts ? ( - { - void onSelectEphemeralAccount(account) - }} - renderTrigger={({triggerProps}) => - renderFollowButton(triggerProps.onLongPress) - } - /> - ) : ( - renderFollowButton() + return ( + <> + {currentAccount && hasAlternateAccounts ? ( + { + void onSelectEphemeralAccount(account) + }} + renderTrigger={({triggerProps}) => + renderFollowButton(triggerProps.onLongPress) + } + /> + ) : ( + renderFollowButton() + )} + {confirmFollowUnfollow && ( + + )} + ) } diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index 0b6604954..be85f039c 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -1,4 +1,4 @@ -import {memo, useMemo, useState} from 'react' +import {memo, useEffect, useMemo, useState} from 'react' import {View} from 'react-native' import { type AppBskyActorDefs, @@ -21,6 +21,7 @@ import { } from '#/lib/strings/website' import {logger} from '#/logger' import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {useDisableFollowedByMetrics} from '#/state/preferences/disable-followed-by-metrics' import {useHideScaryFollowButtons} from '#/state/preferences/hide-scary-follow-buttons' import { @@ -59,6 +60,7 @@ import { import {Link} from '#/components/Link' import {ProfileBadges} from '#/components/ProfileBadges' import * as Prompt from '#/components/Prompt' +import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' import {RichText} from '#/components/RichText' import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' @@ -335,6 +337,11 @@ export function HeaderStandardButtons({ const editProfileControl = useDialogControl() const unblockPromptControl = Prompt.usePromptControl() const hideScaryFollowButtons = useHideScaryFollowButtons() + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const followPromptControl = Prompt.usePromptControl() + const [confirmationAction, setConfirmationAction] = + useState<'follow' | 'unfollow'>('follow') + const onSelectEphemeralAccount = useEphemeralFollowAction({ profile, logContext: 'ProfileHeader', @@ -347,59 +354,85 @@ export function HeaderStandardButtons({ const isMe = currentAccount?.did === profile.did + const executeFollow = async () => { + try { + await queueFollow() + onFollow?.() + Toast.show( + _( + msg`Following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + ) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + logger.error('Failed to follow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), { + type: 'error', + }) + } + } + } + + const executeUnfollow = async () => { + try { + await queueUnfollow() + onUnfollow?.() + Toast.show( + _( + msg`No longer following ${sanitizeDisplayName( + profile.displayName || profile.handle, + moderation.ui('displayName'), + )}`, + ), + {type: 'default'}, + ) + } catch (err) { + const e = err as Error + if (e?.name !== 'AbortError') { + logger.error('Failed to unfollow', {message: String(e)}) + Toast.show(_(msg`There was an issue! ${e.toString()}`), { + type: 'error', + }) + } + } + } + const onPressFollow = () => { playHaptic() - requireAuth(async () => { - try { - await queueFollow() - onFollow?.() - Toast.show( - _( - msg`Following ${sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - )}`, - ), - ) - } catch (err) { - const e = err as Error - if (e?.name !== 'AbortError') { - logger.error('Failed to follow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { - type: 'error', - }) - } + requireAuth(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('follow') + followPromptControl.open() + } else { + void executeFollow() } }) } const onPressUnfollow = () => { playHaptic() - requireAuth(async () => { - try { - await queueUnfollow() - onUnfollow?.() - Toast.show( - _( - msg`No longer following ${sanitizeDisplayName( - profile.displayName || profile.handle, - moderation.ui('displayName'), - )}`, - ), - {type: 'default'}, - ) - } catch (err) { - const e = err as Error - if (e?.name !== 'AbortError') { - logger.error('Failed to unfollow', {message: String(e)}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), { - type: 'error', - }) - } + requireAuth(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('unfollow') + followPromptControl.open() + } else { + void executeUnfollow() } }) } + const onConfirmFollowAction = () => { + if (confirmationAction === 'follow') { + void executeFollow() + } else { + void executeUnfollow() + } + } + const unblockAccount = async () => { try { await queueUnblock() @@ -577,6 +610,18 @@ export function HeaderStandardButtons({ confirmButtonCta={_(msg`Unblock`)} confirmButtonColor="negative" /> + {confirmFollowUnfollow && ( + + )} ) } diff --git a/src/screens/Settings/RunesSettings/UsabilitySettings.tsx b/src/screens/Settings/RunesSettings/UsabilitySettings.tsx index 4a0fccce6..d6b78d507 100644 --- a/src/screens/Settings/RunesSettings/UsabilitySettings.tsx +++ b/src/screens/Settings/RunesSettings/UsabilitySettings.tsx @@ -1,6 +1,10 @@ import {Trans, useLingui} from '@lingui/react/macro' import {useGoLinksEnabled, useSetGoLinksEnabled} from '#/state/preferences' +import { + useConfirmFollowUnfollow, + useSetConfirmFollowUnfollow, +} from '#/state/preferences/confirm-follow-unfollow' import { useDisableVerifyEmailReminder, useSetDisableVerifyEmailReminder, @@ -18,6 +22,7 @@ import {atoms as a} from '#/alf' import {Admonition} from '#/components/Admonition' import * as Toggle from '#/components/forms/Toggle' import {ArrowShareRight_Stroke2_Corner2_Rounded as ArrowShareRightIcon} from '#/components/icons/ArrowShareRight' +import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope' import {Newspaper_Stroke2_Corner2_Rounded as NewspaperIcon} from '#/components/icons/Newspaper' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' @@ -36,6 +41,9 @@ export function RunesUsabilitySettingsScreen() { const hideScaryFollowButtons = useHideScaryFollowButtons() const setHideScaryFollowButtons = useSetHideScaryFollowButtons() + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const setConfirmFollowUnfollow = useSetConfirmFollowUnfollow() + const disableVerifyEmailReminder = useDisableVerifyEmailReminder() const setDisableVerifyEmailReminder = useSetDisableVerifyEmailReminder() @@ -90,6 +98,19 @@ export function RunesUsabilitySettingsScreen() { + setConfirmFollowUnfollow(value)}> + + + + Confirm before following or unfollowing + + + + accounts.some(account => account.did !== currentAccount?.did), [accounts, currentAccount?.did], ) + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const promptControl = Prompt.usePromptControl() + const [confirmationAction, setConfirmationAction] = + useState<'follow' | 'unfollow'>('follow') + + const executeFollow = useCallback(async () => { + await queueFollow() + }, [queueFollow]) + + const executeUnfollow = useCallback(async () => { + await queueUnfollow() + }, [queueUnfollow]) + + const handleFollow = useCallback(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('follow') + promptControl.open() + } else { + void executeFollow() + } + }, [confirmFollowUnfollow, executeFollow, promptControl]) + + const handleUnfollow = useCallback(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('unfollow') + promptControl.open() + } else { + void executeUnfollow() + } + }, [confirmFollowUnfollow, executeUnfollow, promptControl]) + + const onConfirm = useCallback(() => { + if (confirmationAction === 'follow') { + void executeFollow() + } else { + void executeUnfollow() + } + }, [confirmationAction, executeFollow, executeUnfollow]) const rkey = new AtUri(post.uri).rkey const record = bsky.dangerousIsType( @@ -789,6 +830,7 @@ function Overlay({ }, [openComposer, post, record]) return ( + <> @@ -876,8 +918,8 @@ function Overlay({ style={[a.mb_xs]} onPress={() => profile.viewer?.following - ? void queueUnfollow() - : void queueFollow() + ? handleUnfollow() + : handleFollow() }> {!!profile.viewer?.following && ( @@ -908,8 +950,8 @@ function Overlay({ style={[a.mb_xs]} onPress={() => profile.viewer?.following - ? void queueUnfollow() - : void queueFollow() + ? handleUnfollow() + : handleFollow() }> {!!profile.viewer?.following && ( @@ -980,6 +1022,18 @@ function Overlay({ */} + {confirmFollowUnfollow && ( + + )} + ) } diff --git a/src/state/persisted/schema.ts b/src/state/persisted/schema.ts index c4278be9a..944baff7a 100644 --- a/src/state/persisted/schema.ts +++ b/src/state/persisted/schema.ts @@ -194,6 +194,7 @@ const schema = z.object({ showFollowsYouBadge: z.boolean().optional(), hideSimilarAccountsRecomm: z.boolean().optional(), hideScaryFollowButtons: z.boolean().optional(), + confirmFollowUnfollow: z.boolean().optional(), discoverContextEnabled: z.boolean().optional(), enableSquareAvatars: z.boolean().optional(), enableSquareButtons: z.boolean().optional(), @@ -336,6 +337,7 @@ export const defaults: Schema = { showFollowsYouBadge: false, hideSimilarAccountsRecomm: true, hideScaryFollowButtons: false, + confirmFollowUnfollow: true, discoverContextEnabled: false, enableSquareAvatars: true, enableSquareButtons: true, diff --git a/src/state/preferences/confirm-follow-unfollow.tsx b/src/state/preferences/confirm-follow-unfollow.tsx new file mode 100644 index 000000000..df7c4301e --- /dev/null +++ b/src/state/preferences/confirm-follow-unfollow.tsx @@ -0,0 +1,54 @@ +import { + createContext, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useState, +} from 'react' + +import * as persisted from '#/state/persisted' + +type StateContext = persisted.Schema['confirmFollowUnfollow'] +type SetContext = (v: persisted.Schema['confirmFollowUnfollow']) => void + +const stateContext = createContext( + persisted.defaults.confirmFollowUnfollow, +) +const setContext = createContext( + (_: persisted.Schema['confirmFollowUnfollow']) => {}, +) + +export function Provider({children}: PropsWithChildren<{}>) { + const [state, setState] = useState(persisted.get('confirmFollowUnfollow')) + + const setStateWrapped = useCallback( + (confirmFollowUnfollow: persisted.Schema['confirmFollowUnfollow']) => { + setState(confirmFollowUnfollow) + persisted.write('confirmFollowUnfollow', confirmFollowUnfollow) + }, + [setState], + ) + + useEffect(() => { + return persisted.onUpdate('confirmFollowUnfollow', nextValue => { + setState(nextValue) + }) + }, [setStateWrapped]) + + return ( + + + {children} + + + ) +} + +export function useConfirmFollowUnfollow() { + return useContext(stateContext) ?? persisted.defaults.confirmFollowUnfollow +} + +export function useSetConfirmFollowUnfollow() { + return useContext(setContext) +} diff --git a/src/state/preferences/index.tsx b/src/state/preferences/index.tsx index c72d728b4..1f37ff5d2 100644 --- a/src/state/preferences/index.tsx +++ b/src/state/preferences/index.tsx @@ -7,6 +7,7 @@ import {Provider as AutoCompactAccountSwitcherProvider} from './auto-compact-acc import {Provider as AutoLikeOnRepostProvider} from './auto-like-on-repost' import {Provider as AutoplayProvider} from './autoplay' import {Provider as CompactAccountSwitcherProvider} from './compact-account-switcher' +import {Provider as ConfirmFollowUnfollowProvider} from './confirm-follow-unfollow' import {Provider as ConstellationProvider} from './constellation-enabled' import {Provider as ConstellationInstanceProvider} from './constellation-instance' import {Provider as DeerVerificationProvider} from './deer-verification' @@ -76,6 +77,10 @@ export { useSetRequireAltTextEnabled, } from './alt-text-required' export {useAutoplayDisabled, useSetAutoplayDisabled} from './autoplay' +export { + useConfirmFollowUnfollow, + useSetConfirmFollowUnfollow, +} from './confirm-follow-unfollow' export { useDisableComposerPrompt, useSetDisableComposerPrompt, @@ -184,7 +189,8 @@ export function Provider({children}: PropsWithChildren<{}>) { - + + @@ -215,6 +221,7 @@ export function Provider({children}: PropsWithChildren<{}>) { + diff --git a/src/state/preferences/settings-sync.tsx b/src/state/preferences/settings-sync.tsx index a4516739c..d219e7791 100644 --- a/src/state/preferences/settings-sync.tsx +++ b/src/state/preferences/settings-sync.tsx @@ -63,6 +63,7 @@ export const SYNCED_PREFS_KEYS = [ 'showFollowsYouBadge', 'hideSimilarAccountsRecomm', 'hideScaryFollowButtons', + 'confirmFollowUnfollow', 'discoverContextEnabled', 'enableSquareAvatars', 'enableSquareButtons', diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 327e05a34..703ec050b 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -35,6 +35,7 @@ import {niceDate} from '#/lib/strings/time' import {s} from '#/lib/styles' import {logger} from '#/logger' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {type FeedNotification} from '#/state/queries/notifications/feed' import {useProfileFollowMutationQueue} from '#/state/queries/profile' import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' @@ -72,6 +73,8 @@ import {InlineLinkText, Link} from '#/components/Link' import * as MediaPreview from '#/components/MediaPreview' import {ProfileBadges} from '#/components/ProfileBadges' import {ProfileHoverCard} from '#/components/ProfileHoverCard' +import * as Prompt from '#/components/Prompt' +import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' import {Notification as StarterPackCard} from '#/components/StarterPack/StarterPackCard' import {SubtleHover} from '#/components/SubtleHover' import * as Toast from '#/components/Toast' @@ -758,16 +761,17 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { profileShadow, 'ProfileCard', ) + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const promptControl = Prompt.usePromptControl() + const [confirmationAction, setConfirmationAction] = + useState<'follow' | 'unfollow'>('follow') // Don't show button if not logged in or for own profile if (!hasSession || profile.did === currentAccount?.did) { return null } - const onPressFollow = async (e: GestureResponderEvent) => { - e.preventDefault() - e.stopPropagation() - + const executeFollow = async () => { try { await queueFollow() Toast.show( @@ -786,10 +790,7 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { } } - const onPressUnfollow = async (e: GestureResponderEvent) => { - e.preventDefault() - e.stopPropagation() - + const executeUnfollow = async () => { try { await queueUnfollow() Toast.show( @@ -808,6 +809,38 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { } } + const onPressFollow = (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + if (confirmFollowUnfollow) { + setConfirmationAction('follow') + promptControl.open() + } else { + void executeFollow() + } + } + + const onPressUnfollow = (e: GestureResponderEvent) => { + e.preventDefault() + e.stopPropagation() + + if (confirmFollowUnfollow) { + setConfirmationAction('unfollow') + promptControl.open() + } else { + void executeUnfollow() + } + } + + const onConfirm = () => { + if (confirmationAction === 'follow') { + void executeFollow() + } else { + void executeUnfollow() + } + } + // Don't show button if viewer data is missing or user is blocked if (!profileShadow.viewer) { return null @@ -856,6 +889,15 @@ function FollowBackButton({profile}: {profile: AppBskyActorDefs.ProfileView}) { )} + {confirmFollowUnfollow && ( + + )} ) } diff --git a/src/view/com/posts/AviFollowButton.tsx b/src/view/com/posts/AviFollowButton.tsx index 7d449c4d0..2f35677e7 100644 --- a/src/view/com/posts/AviFollowButton.tsx +++ b/src/view/com/posts/AviFollowButton.tsx @@ -1,4 +1,4 @@ -import {type ReactNode} from 'react' +import {type ReactNode, useEffect, useState} from 'react' import {View} from 'react-native' import {type AppBskyActorDefs, type ModerationDecision} from '@atproto/api' import {msg} from '@lingui/core/macro' @@ -6,6 +6,7 @@ import {useLingui} from '@lingui/react' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {useProfileShadow} from '#/state/cache/profile-shadow' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import {useShowAvatarFollowButton} from '#/state/preferences/show-avatar-follow-button' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' @@ -13,6 +14,8 @@ import {atoms as a, select, useTheme} from '#/alf' import {Button} from '#/components/Button' import {useFollowMethods} from '#/components/hooks/useFollowMethods' import {PlusSmall_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' +import * as Prompt from '#/components/Prompt' export function AviFollowButton({ author, @@ -27,11 +30,13 @@ export function AviFollowButton({ const t = useTheme() const profile = useProfileShadow(author) const showAvatarFollowButton = useShowAvatarFollowButton() + const confirmFollowUnfollow = useConfirmFollowUnfollow() const {follow} = useFollowMethods({ profile, logContext: 'AvatarButton', }) const {currentAccount, hasSession} = useSession() + const promptControl = Prompt.usePromptControl() const name = sanitizeDisplayName( profile.displayName || profile.handle, @@ -40,11 +45,20 @@ export function AviFollowButton({ const isFollowing = Boolean(profile.viewer?.following) || profile.did === currentAccount?.did - function onPress() { + function onConfirm() { follow() Toast.show(_(msg`Following ${name}`)) } + function onPress() { + if (confirmFollowUnfollow) { + promptControl.open() + } else { + follow() + Toast.show(_(msg`Following ${name}`)) + } + } + if (!hasSession || !showAvatarFollowButton) { return children } @@ -105,6 +119,15 @@ export function AviFollowButton({ )} + {confirmFollowUnfollow && ( + + )} ) } diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 466829076..6b7716877 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -1,4 +1,4 @@ -import {memo, useCallback, useMemo} from 'react' +import {memo, useCallback, useEffect, useMemo, useState} from 'react' import {type AppBskyActorDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -8,12 +8,14 @@ import {useQueryClient} from '@tanstack/react-query' import {HITSLOP_20} from '#/lib/constants' import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {sanitizeDisplayName} from '#/lib/strings/display-names' import {makeProfileLink} from '#/lib/routes/links' import {type NavigationProp} from '#/lib/routes/types' import {shareText, shareUrl} from '#/lib/sharing' import {toShareUrl, toShareUrlBsky} from '#/lib/strings/url-helpers' import {type Shadow} from '#/state/cache/types' import {useModalControls} from '#/state/modals' +import {useConfirmFollowUnfollow} from '#/state/preferences/confirm-follow-unfollow' import { useDeerVerificationEnabled, useDeerVerificationTrusted, @@ -59,6 +61,7 @@ import { ReportDialog, useReportDialogControl, } from '#/components/moderation/ReportDialog' +import {FollowConfirmationDialog} from '#/components/dialogs/FollowConfirmationDialog' import * as Prompt from '#/components/Prompt' import * as Toast from '#/components/Toast' import {useFullVerificationState} from '#/components/verification' @@ -218,7 +221,12 @@ let ProfileMenu = ({ } }, [ax, profile.viewer?.blocking, _, queueUnblock, queueBlock]) - const onPressFollowAccount = useCallback(async () => { + const confirmFollowUnfollow = useConfirmFollowUnfollow() + const followPromptControl = Prompt.usePromptControl() + const [confirmationAction, setConfirmationAction] = + useState<'follow' | 'unfollow'>('follow') + + const executeFollow = useCallback(async () => { try { await queueFollow() Toast.show(_(msg({message: 'Account followed', context: 'toast'}))) @@ -232,7 +240,7 @@ let ProfileMenu = ({ } }, [_, ax, queueFollow]) - const onPressUnfollowAccount = useCallback(async () => { + const executeUnfollow = useCallback(async () => { try { await queueUnfollow() Toast.show(_(msg({message: 'Account unfollowed', context: 'toast'}))) @@ -246,6 +254,32 @@ let ProfileMenu = ({ } }, [_, ax, queueUnfollow]) + const onPressFollowAccount = useCallback(() => { + if (confirmFollowUnfollow) { + setConfirmationAction('follow') + followPromptControl.open() + } else { + 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]) @@ -683,6 +717,16 @@ let ProfileMenu = ({ confirmButtonColor={profile.viewer?.blocking ? undefined : 'negative'} /> + {confirmFollowUnfollow && ( + + )} +