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 && (
+
+ )}
+