Add a mutation queue to fix race conditions in toggles (#1933)
* Prototype a queue * Track both current and pending actions * Skip unnecessary actions * Commit last confirmed state to shadow * Thread state through actions over time * Fix the logic to skip redundant mutations * Track status * Extract an abstraction * Fix standalone mutations * Add types * Move to another file * Return stable function * Clean up * Use queue for muting * Use queue for blocking * Convert other follow buttons * Don't export non-queue mutations * Properly handle canceled tasks * Fix copy paste
This commit is contained in:
parent
54faa7e176
commit
8475312422
6 changed files with 453 additions and 188 deletions
|
@ -13,10 +13,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
|
|||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
import {Trans} from '@lingui/macro'
|
||||
import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {
|
||||
useProfileFollowMutation,
|
||||
useProfileUnfollowMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {logger} from '#/logger'
|
||||
|
||||
type Props = {
|
||||
|
@ -77,35 +74,32 @@ export function ProfileCard({
|
|||
const pal = usePalette('default')
|
||||
const [addingMoreSuggestions, setAddingMoreSuggestions] =
|
||||
React.useState(false)
|
||||
const {mutateAsync: follow} = useProfileFollowMutation()
|
||||
const {mutateAsync: unfollow} = useProfileUnfollowMutation()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
|
||||
const onToggleFollow = React.useCallback(async () => {
|
||||
try {
|
||||
if (
|
||||
profile.viewer?.following &&
|
||||
profile.viewer?.following !== 'pending'
|
||||
) {
|
||||
await unfollow({did: profile.did, followUri: profile.viewer.following})
|
||||
} else if (
|
||||
!profile.viewer?.following &&
|
||||
profile.viewer?.following !== 'pending'
|
||||
) {
|
||||
if (profile.viewer?.following) {
|
||||
await queueUnfollow()
|
||||
} else {
|
||||
setAddingMoreSuggestions(true)
|
||||
await follow({did: profile.did})
|
||||
await queueFollow()
|
||||
await onFollowStateChange({did: profile.did, following: true})
|
||||
setAddingMoreSuggestions(false)
|
||||
track('Onboarding:SuggestedFollowFollowed')
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('RecommendedFollows: failed to toggle following', {error: e})
|
||||
} catch (e: any) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('RecommendedFollows: failed to toggle following', {
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setAddingMoreSuggestions(false)
|
||||
}
|
||||
}, [
|
||||
profile,
|
||||
follow,
|
||||
unfollow,
|
||||
queueFollow,
|
||||
queueUnfollow,
|
||||
setAddingMoreSuggestions,
|
||||
track,
|
||||
onFollowStateChange,
|
||||
|
@ -142,7 +136,6 @@ export function ProfileCard({
|
|||
labelStyle={styles.followButton}
|
||||
onPress={onToggleFollow}
|
||||
label={profile.viewer?.following ? 'Unfollow' : 'Follow'}
|
||||
withLoading={true}
|
||||
/>
|
||||
</View>
|
||||
{profile.description ? (
|
||||
|
|
|
@ -3,10 +3,7 @@ import {StyleProp, TextStyle, View} from 'react-native'
|
|||
import {AppBskyActorDefs} from '@atproto/api'
|
||||
import {Button, ButtonType} from '../util/forms/Button'
|
||||
import * as Toast from '../util/Toast'
|
||||
import {
|
||||
useProfileFollowMutation,
|
||||
useProfileUnfollowMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
import {Shadow} from '#/state/cache/types'
|
||||
|
||||
export function FollowButton({
|
||||
|
@ -20,31 +17,25 @@ export function FollowButton({
|
|||
profile: Shadow<AppBskyActorDefs.ProfileViewBasic>
|
||||
labelStyle?: StyleProp<TextStyle>
|
||||
}) {
|
||||
const followMutation = useProfileFollowMutation()
|
||||
const unfollowMutation = useProfileUnfollowMutation()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
|
||||
const onPressFollow = async () => {
|
||||
if (profile.viewer?.following) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await followMutation.mutateAsync({did: profile.did})
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
Toast.show(`An issue occurred, please try again.`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(`An issue occurred, please try again.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onPressUnfollow = async () => {
|
||||
if (!profile.viewer?.following) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await unfollowMutation.mutateAsync({
|
||||
did: profile.did,
|
||||
followUri: profile.viewer?.following,
|
||||
})
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
Toast.show(`An issue occurred, please try again.`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show(`An issue occurred, please try again.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -59,7 +50,6 @@ export function FollowButton({
|
|||
labelStyle={labelStyle}
|
||||
onPress={onPressUnfollow}
|
||||
label="Unfollow"
|
||||
withLoading={true}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
|
@ -69,7 +59,6 @@ export function FollowButton({
|
|||
labelStyle={labelStyle}
|
||||
onPress={onPressFollow}
|
||||
label="Follow"
|
||||
withLoading={true}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -32,12 +32,9 @@ import {ProfileHeaderSuggestedFollows} from './ProfileHeaderSuggestedFollows'
|
|||
import {useModalControls} from '#/state/modals'
|
||||
import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox'
|
||||
import {
|
||||
useProfileFollowMutation,
|
||||
useProfileUnfollowMutation,
|
||||
useProfileMuteMutation,
|
||||
useProfileUnmuteMutation,
|
||||
useProfileBlockMutation,
|
||||
useProfileUnblockMutation,
|
||||
useProfileMuteMutationQueue,
|
||||
useProfileBlockMutationQueue,
|
||||
useProfileFollowMutationQueue,
|
||||
} from '#/state/queries/profile'
|
||||
import {usePalette} from 'lib/hooks/usePalette'
|
||||
import {useAnalytics} from 'lib/analytics/analytics'
|
||||
|
@ -130,12 +127,9 @@ function ProfileHeaderLoaded({
|
|||
: undefined,
|
||||
[profile],
|
||||
)
|
||||
const followMutation = useProfileFollowMutation()
|
||||
const unfollowMutation = useProfileUnfollowMutation()
|
||||
const muteMutation = useProfileMuteMutation()
|
||||
const unmuteMutation = useProfileUnmuteMutation()
|
||||
const blockMutation = useProfileBlockMutation()
|
||||
const unblockMutation = useProfileUnblockMutation()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile)
|
||||
const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile)
|
||||
|
||||
const onPressBack = React.useCallback(() => {
|
||||
if (navigation.canGoBack()) {
|
||||
|
@ -154,44 +148,39 @@ function ProfileHeaderLoaded({
|
|||
}
|
||||
}, [openLightbox, profile, moderation])
|
||||
|
||||
const onPressFollow = React.useCallback(async () => {
|
||||
if (profile.viewer?.following) {
|
||||
return
|
||||
}
|
||||
const onPressFollow = async () => {
|
||||
try {
|
||||
track('ProfileHeader:FollowButtonClicked')
|
||||
await followMutation.mutateAsync({did: profile.did})
|
||||
await queueFollow()
|
||||
Toast.show(
|
||||
`Following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
)}`,
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to follow', {error: String(e)})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to follow', {error: String(e)})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [followMutation, profile, track])
|
||||
}
|
||||
|
||||
const onPressUnfollow = React.useCallback(async () => {
|
||||
if (!profile.viewer?.following) {
|
||||
return
|
||||
}
|
||||
const onPressUnfollow = async () => {
|
||||
try {
|
||||
track('ProfileHeader:UnfollowButtonClicked')
|
||||
await unfollowMutation.mutateAsync({
|
||||
did: profile.did,
|
||||
followUri: profile.viewer?.following,
|
||||
})
|
||||
await queueUnfollow()
|
||||
Toast.show(
|
||||
`No longer following ${sanitizeDisplayName(
|
||||
profile.displayName || profile.handle,
|
||||
)}`,
|
||||
)
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to unfollow', {error: String(e)})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unfollow', {error: String(e)})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [unfollowMutation, profile, track])
|
||||
}
|
||||
|
||||
const onPressEditProfile = React.useCallback(() => {
|
||||
track('ProfileHeader:EditProfileButtonClicked')
|
||||
|
@ -218,24 +207,28 @@ function ProfileHeaderLoaded({
|
|||
const onPressMuteAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:MuteAccountButtonClicked')
|
||||
try {
|
||||
await muteMutation.mutateAsync({did: profile.did})
|
||||
await queueMute()
|
||||
Toast.show('Account muted')
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to mute account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to mute account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [track, muteMutation, profile])
|
||||
}, [track, queueMute])
|
||||
|
||||
const onPressUnmuteAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:UnmuteAccountButtonClicked')
|
||||
try {
|
||||
await unmuteMutation.mutateAsync({did: profile.did})
|
||||
await queueUnmute()
|
||||
Toast.show('Account unmuted')
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to unmute account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unmute account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
}, [track, unmuteMutation, profile])
|
||||
}, [track, queueUnmute])
|
||||
|
||||
const onPressBlockAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:BlockAccountButtonClicked')
|
||||
|
@ -245,19 +238,18 @@ function ProfileHeaderLoaded({
|
|||
message:
|
||||
'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.',
|
||||
onPressConfirm: async () => {
|
||||
if (profile.viewer?.blocking) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await blockMutation.mutateAsync({did: profile.did})
|
||||
await queueBlock()
|
||||
Toast.show('Account blocked')
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to block account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to block account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [track, blockMutation, profile, openModal])
|
||||
}, [track, queueBlock, openModal])
|
||||
|
||||
const onPressUnblockAccount = React.useCallback(async () => {
|
||||
track('ProfileHeader:UnblockAccountButtonClicked')
|
||||
|
@ -267,22 +259,18 @@ function ProfileHeaderLoaded({
|
|||
message:
|
||||
'The account will be able to interact with you after unblocking.',
|
||||
onPressConfirm: async () => {
|
||||
if (!profile.viewer?.blocking) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await unblockMutation.mutateAsync({
|
||||
did: profile.did,
|
||||
blockUri: profile.viewer.blocking,
|
||||
})
|
||||
await queueUnblock()
|
||||
Toast.show('Account unblocked')
|
||||
} catch (e: any) {
|
||||
logger.error('Failed to unblock account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
if (e?.name !== 'AbortError') {
|
||||
logger.error('Failed to unblock account', {error: e})
|
||||
Toast.show(`There was an issue! ${e.toString()}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [track, unblockMutation, profile, openModal])
|
||||
}, [track, queueUnblock, openModal])
|
||||
|
||||
const onPressReportAccount = React.useCallback(() => {
|
||||
track('ProfileHeader:ReportAccountButtonClicked')
|
||||
|
|
|
@ -26,10 +26,7 @@ import {isWeb} from 'platform/detection'
|
|||
import {useModerationOpts} from '#/state/queries/preferences'
|
||||
import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {
|
||||
useProfileFollowMutation,
|
||||
useProfileUnfollowMutation,
|
||||
} from '#/state/queries/profile'
|
||||
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
|
||||
|
||||
const OUTER_PADDING = 10
|
||||
const INNER_PADDING = 14
|
||||
|
@ -208,34 +205,28 @@ function SuggestedFollow({
|
|||
const pal = usePalette('default')
|
||||
const moderationOpts = useModerationOpts()
|
||||
const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt)
|
||||
const followMutation = useProfileFollowMutation()
|
||||
const unfollowMutation = useProfileUnfollowMutation()
|
||||
const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile)
|
||||
|
||||
const onPressFollow = React.useCallback(async () => {
|
||||
if (profile.viewer?.following) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
track('ProfileHeader:SuggestedFollowFollowed')
|
||||
await followMutation.mutateAsync({did: profile.did})
|
||||
await queueFollow()
|
||||
} catch (e: any) {
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
}, [followMutation, profile, track])
|
||||
}, [queueFollow, track])
|
||||
|
||||
const onPressUnfollow = React.useCallback(async () => {
|
||||
if (!profile.viewer?.following) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await unfollowMutation.mutateAsync({
|
||||
did: profile.did,
|
||||
followUri: profile.viewer?.following,
|
||||
})
|
||||
await queueUnfollow()
|
||||
} catch (e: any) {
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
if (e?.name !== 'AbortError') {
|
||||
Toast.show('An issue occurred, please try again.')
|
||||
}
|
||||
}
|
||||
}, [unfollowMutation, profile])
|
||||
}, [queueUnfollow])
|
||||
|
||||
if (!moderationOpts) {
|
||||
return null
|
||||
|
@ -284,7 +275,6 @@ function SuggestedFollow({
|
|||
type="inverted"
|
||||
labelStyle={{textAlign: 'center'}}
|
||||
onPress={following ? onPressUnfollow : onPressFollow}
|
||||
withLoading
|
||||
/>
|
||||
</View>
|
||||
</Link>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue