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:
dan 2023-11-16 22:01:01 +00:00 committed by GitHub
parent 54faa7e176
commit 8475312422
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 453 additions and 188 deletions

View file

@ -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 ? (

View file

@ -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}
/>
)
}

View file

@ -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')

View file

@ -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>