Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useMemo, useState} from 'react'
2import {type AppBskyActorDefs} from '@atproto/api'
3import {msg} from '@lingui/core/macro'
4import {useLingui} from '@lingui/react'
5import {Trans} from '@lingui/react/macro'
6import {useNavigation} from '@react-navigation/native'
7
8import {logger} from '#/logger'
9import {useProfileShadow} from '#/state/cache/profile-shadow'
10import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
11import {
12 useProfileFollowMutationQueue,
13 useProfileQuery,
14} from '#/state/queries/profile'
15import {useRequireAuth, useSession} from '#/state/session'
16import {atoms as a, useBreakpoints} from '#/alf'
17import {Button, ButtonIcon, ButtonText} from '#/components/Button'
18import {EphemeralAccountSwitcher} from '#/components/EphemeralAccountSwitcher'
19import {useEphemeralFollowAction} from '#/components/hooks/useEphemeralFollowAction'
20import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
21import {PlusLarge_Stroke2_Corner0_Rounded as PlusIcon} from '#/components/icons/Plus'
22import * as Toast from '#/components/Toast'
23import {IS_IOS} from '#/env'
24import {GrowthHack} from './GrowthHack'
25
26export function ThreadItemAnchorFollowButton({
27 did,
28 enabled = true,
29}: {
30 did: string
31 enabled?: boolean
32}) {
33 if (IS_IOS) {
34 return (
35 <GrowthHack>
36 <ThreadItemAnchorFollowButtonInner did={did} enabled={enabled} />
37 </GrowthHack>
38 )
39 }
40
41 return <ThreadItemAnchorFollowButtonInner did={did} enabled={enabled} />
42}
43
44export function ThreadItemAnchorFollowButtonInner({
45 did,
46 enabled = true,
47}: {
48 did: string
49 enabled?: boolean
50}) {
51 const {data: profile, isLoading} = useProfileQuery({did})
52
53 // We will never hit this - the profile will always be cached or loaded above
54 // but it keeps the typechecker happy
55 if (!enabled || isLoading || !profile) return null
56
57 return <PostThreadFollowBtnLoaded profile={profile} />
58}
59
60function PostThreadFollowBtnLoaded({
61 profile: profileUnshadowed,
62}: {
63 profile: AppBskyActorDefs.ProfileViewDetailed
64}) {
65 const navigation = useNavigation()
66 const {_} = useLingui()
67 const {gtMobile} = useBreakpoints()
68 const profile = useProfileShadow(profileUnshadowed)
69 const {accounts, currentAccount} = useSession()
70 const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(
71 profile,
72 'PostThreadItem',
73 )
74 const requireAuth = useRequireAuth()
75 const onSelectEphemeralAccount = useEphemeralFollowAction({
76 profile,
77 logContext: 'PostThreadItem',
78 })
79
80 const isFollowing = !!profile.viewer?.following
81 const isFollowedBy = !!profile.viewer?.followedBy
82 const [wasFollowing, setWasFollowing] = useState<boolean>(isFollowing)
83
84 const enableSquareButtons = useEnableSquareButtons()
85 const hasAlternateAccounts = accounts.some(
86 account => account.did !== currentAccount?.did,
87 )
88
89 // This prevents the button from disappearing as soon as we follow.
90 const showFollowBtn = useMemo(
91 () => !isFollowing || !wasFollowing,
92 [isFollowing, wasFollowing],
93 )
94
95 /**
96 * We want this button to stay visible even after following, so that the user can unfollow if they want.
97 * However, we need it to disappear after we push to a screen and then come back. We also need it to
98 * show up if we view the post while following, go to the profile and unfollow, then come back to the
99 * post.
100 *
101 * We want to update wasFollowing both on blur and on focus so that we hit all these cases. On native,
102 * we could do this only on focus because the transition animation gives us time to not notice the
103 * sudden rendering of the button. However, on web if we do this, there's an obvious flicker once the
104 * button renders. So, we update the state in both cases.
105 */
106 useEffect(() => {
107 const updateWasFollowing = () => {
108 if (wasFollowing !== isFollowing) {
109 setWasFollowing(isFollowing)
110 }
111 }
112
113 const unsubscribeFocus = navigation.addListener('focus', updateWasFollowing)
114 const unsubscribeBlur = navigation.addListener('blur', updateWasFollowing)
115
116 return () => {
117 unsubscribeFocus()
118 unsubscribeBlur()
119 }
120 }, [isFollowing, wasFollowing, navigation])
121
122 const onPress = useCallback(() => {
123 if (!isFollowing) {
124 requireAuth(async () => {
125 try {
126 await queueFollow()
127 } catch (e: any) {
128 if (e?.name !== 'AbortError') {
129 logger.error('Failed to follow', {message: String(e)})
130 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
131 type: 'error',
132 })
133 }
134 }
135 })
136 } else {
137 requireAuth(async () => {
138 try {
139 await queueUnfollow()
140 } catch (e: any) {
141 if (e?.name !== 'AbortError') {
142 logger.error('Failed to unfollow', {message: String(e)})
143 Toast.show(_(msg`There was an issue! ${e.toString()}`), {
144 type: 'error',
145 })
146 }
147 }
148 })
149 }
150 }, [isFollowing, requireAuth, queueFollow, _, queueUnfollow])
151
152 if (!showFollowBtn) return null
153
154 const renderFollowButton = (onLongPress?: () => void) => (
155 <Button
156 testID="followBtn"
157 label={_(msg`Follow ${profile.handle}`)}
158 onLongPress={onLongPress}
159 onPress={onPress}
160 size="small"
161 color={isFollowing ? 'secondary' : 'secondary_inverted'}
162 style={enableSquareButtons ? [a.rounded_sm] : [a.rounded_full]}>
163 {gtMobile && (
164 <ButtonIcon icon={isFollowing ? CheckIcon : PlusIcon} size="sm" />
165 )}
166 <ButtonText maxFontSizeMultiplier={2}>
167 {!isFollowing ? (
168 isFollowedBy ? (
169 <Trans>Follow back</Trans>
170 ) : (
171 <Trans>Follow</Trans>
172 )
173 ) : isFollowedBy ? (
174 <Trans>Mutuals</Trans>
175 ) : (
176 <Trans>Following</Trans>
177 )}
178 </ButtonText>
179 </Button>
180 )
181
182 return (
183 currentAccount && hasAlternateAccounts ? (
184 <EphemeralAccountSwitcher
185 selectedDid={currentAccount.did}
186 title={_(msg`Follow as`)}
187 triggerBehavior="longPress"
188 onSelectAccount={account => {
189 void onSelectEphemeralAccount(account)
190 }}
191 renderTrigger={({triggerProps}) =>
192 renderFollowButton(triggerProps.onLongPress)
193 }
194 />
195 ) : (
196 renderFollowButton()
197 )
198 )
199}