Fix races for post like/repost toggle (#2617)

zio/stable
dan 2024-01-25 21:25:12 +00:00 committed by GitHub
parent 3b26b32f7f
commit 8d3179f082
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 153 additions and 115 deletions

View File

@ -1,10 +1,11 @@
import React from 'react' import {useCallback} from 'react'
import {AppBskyFeedDefs, AtUri} from '@atproto/api' import {AppBskyFeedDefs, AtUri} from '@atproto/api'
import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
import {Shadow} from '#/state/cache/types'
import {getAgent} from '#/state/session' import {getAgent} from '#/state/session'
import {updatePostShadow} from '#/state/cache/post-shadow' import {updatePostShadow} from '#/state/cache/post-shadow'
import {track} from '#/lib/analytics/analytics' import {track} from '#/lib/analytics/analytics'
import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
export const RQKEY = (postUri: string) => ['post', postUri] export const RQKEY = (postUri: string) => ['post', postUri]
@ -25,7 +26,7 @@ export function usePostQuery(uri: string | undefined) {
export function useGetPost() { export function useGetPost() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return React.useCallback( return useCallback(
async ({uri}: {uri: string}) => { async ({uri}: {uri: string}) => {
return queryClient.fetchQuery({ return queryClient.fetchQuery({
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
@ -55,103 +56,157 @@ export function useGetPost() {
) )
} }
export function usePostLikeMutation() { export function usePostLikeMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const postUri = post.uri
const postCid = post.cid
const initialLikeUri = post.viewer?.like
const likeMutation = usePostLikeMutation()
const unlikeMutation = usePostUnlikeMutation()
const queueToggle = useToggleMutationQueue({
initialState: initialLikeUri,
runMutation: async (prevLikeUri, shouldLike) => {
if (shouldLike) {
const {uri: likeUri} = await likeMutation.mutateAsync({
uri: postUri,
cid: postCid,
})
return likeUri
} else {
if (prevLikeUri) {
await unlikeMutation.mutateAsync({
postUri: postUri,
likeUri: prevLikeUri,
})
}
return undefined
}
},
onSuccess(finalLikeUri) {
// finalize
updatePostShadow(postUri, {
likeUri: finalLikeUri,
})
},
})
const queueLike = useCallback(() => {
// optimistically update
updatePostShadow(postUri, {
likeUri: 'pending',
})
return queueToggle(true)
}, [postUri, queueToggle])
const queueUnlike = useCallback(() => {
// optimistically update
updatePostShadow(postUri, {
likeUri: undefined,
})
return queueToggle(false)
}, [postUri, queueToggle])
return [queueLike, queueUnlike]
}
function usePostLikeMutation() {
return useMutation< return useMutation<
{uri: string}, // responds with the uri of the like {uri: string}, // responds with the uri of the like
Error, Error,
{uri: string; cid: string} // the post's uri and cid {uri: string; cid: string} // the post's uri and cid
>({ >({
mutationFn: post => getAgent().like(post.uri, post.cid), mutationFn: post => getAgent().like(post.uri, post.cid),
onMutate(variables) { onSuccess() {
// optimistically update the post-shadow
updatePostShadow(variables.uri, {
likeUri: 'pending',
})
},
onSuccess(data, variables) {
// finalize the post-shadow with the like URI
updatePostShadow(variables.uri, {
likeUri: data.uri,
})
track('Post:Like') track('Post:Like')
}, },
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.uri, {
likeUri: undefined,
})
},
}) })
} }
export function usePostUnlikeMutation() { function usePostUnlikeMutation() {
return useMutation<void, Error, {postUri: string; likeUri: string}>({ return useMutation<void, Error, {postUri: string; likeUri: string}>({
mutationFn: async ({likeUri}) => { mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri),
await getAgent().deleteLike(likeUri) onSuccess() {
track('Post:Unlike') track('Post:Unlike')
}, },
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
likeUri: undefined,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
likeUri: variables.likeUri,
})
},
}) })
} }
export function usePostRepostMutation() { export function usePostRepostMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>,
) {
const postUri = post.uri
const postCid = post.cid
const initialRepostUri = post.viewer?.repost
const repostMutation = usePostRepostMutation()
const unrepostMutation = usePostUnrepostMutation()
const queueToggle = useToggleMutationQueue({
initialState: initialRepostUri,
runMutation: async (prevRepostUri, shouldRepost) => {
if (shouldRepost) {
const {uri: repostUri} = await repostMutation.mutateAsync({
uri: postUri,
cid: postCid,
})
return repostUri
} else {
if (prevRepostUri) {
await unrepostMutation.mutateAsync({
postUri: postUri,
repostUri: prevRepostUri,
})
}
return undefined
}
},
onSuccess(finalRepostUri) {
// finalize
updatePostShadow(postUri, {
repostUri: finalRepostUri,
})
},
})
const queueRepost = useCallback(() => {
// optimistically update
updatePostShadow(postUri, {
repostUri: 'pending',
})
return queueToggle(true)
}, [postUri, queueToggle])
const queueUnrepost = useCallback(() => {
// optimistically update
updatePostShadow(postUri, {
repostUri: undefined,
})
return queueToggle(false)
}, [postUri, queueToggle])
return [queueRepost, queueUnrepost]
}
function usePostRepostMutation() {
return useMutation< return useMutation<
{uri: string}, // responds with the uri of the repost {uri: string}, // responds with the uri of the repost
Error, Error,
{uri: string; cid: string} // the post's uri and cid {uri: string; cid: string} // the post's uri and cid
>({ >({
mutationFn: post => getAgent().repost(post.uri, post.cid), mutationFn: post => getAgent().repost(post.uri, post.cid),
onMutate(variables) { onSuccess() {
// optimistically update the post-shadow
updatePostShadow(variables.uri, {
repostUri: 'pending',
})
},
onSuccess(data, variables) {
// finalize the post-shadow with the repost URI
updatePostShadow(variables.uri, {
repostUri: data.uri,
})
track('Post:Repost') track('Post:Repost')
}, },
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.uri, {
repostUri: undefined,
})
},
}) })
} }
export function usePostUnrepostMutation() { function usePostUnrepostMutation() {
return useMutation<void, Error, {postUri: string; repostUri: string}>({ return useMutation<void, Error, {postUri: string; repostUri: string}>({
mutationFn: async ({repostUri}) => { mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri),
await getAgent().deleteRepost(repostUri) onSuccess() {
track('Post:Unrepost') track('Post:Unrepost')
}, },
onMutate(variables) {
// optimistically update the post-shadow
updatePostShadow(variables.postUri, {
repostUri: undefined,
})
},
onError(error, variables) {
// revert the optimistic update
updatePostShadow(variables.postUri, {
repostUri: variables.repostUri,
})
},
}) })
} }

View File

@ -22,10 +22,8 @@ import {Haptics} from 'lib/haptics'
import {HITSLOP_10, HITSLOP_20} from 'lib/constants' import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import { import {
usePostLikeMutation, usePostLikeMutationQueue,
usePostUnlikeMutation, usePostRepostMutationQueue,
usePostRepostMutation,
usePostUnrepostMutation,
} from '#/state/queries/post' } from '#/state/queries/post'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {Shadow} from '#/state/cache/types' import {Shadow} from '#/state/cache/types'
@ -54,10 +52,8 @@ let PostCtrls = ({
const {_} = useLingui() const {_} = useLingui()
const {openComposer} = useComposerControls() const {openComposer} = useComposerControls()
const {closeModal} = useModalControls() const {closeModal} = useModalControls()
const postLikeMutation = usePostLikeMutation() const [queueLike, queueUnlike] = usePostLikeMutationQueue(post)
const postUnlikeMutation = usePostUnlikeMutation() const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(post)
const postRepostMutation = usePostRepostMutation()
const postUnrepostMutation = usePostUnrepostMutation()
const requireAuth = useRequireAuth() const requireAuth = useRequireAuth()
const defaultCtrlColor = React.useMemo( const defaultCtrlColor = React.useMemo(
@ -68,48 +64,35 @@ let PostCtrls = ({
) as StyleProp<ViewStyle> ) as StyleProp<ViewStyle>
const onPressToggleLike = React.useCallback(async () => { const onPressToggleLike = React.useCallback(async () => {
if (!post.viewer?.like) { try {
Haptics.default() if (!post.viewer?.like) {
postLikeMutation.mutate({ Haptics.default()
uri: post.uri, await queueLike()
cid: post.cid, } else {
}) await queueUnlike()
} else { }
postUnlikeMutation.mutate({ } catch (e: any) {
postUri: post.uri, if (e?.name !== 'AbortError') {
likeUri: post.viewer.like, throw e
}) }
} }
}, [ }, [post.viewer?.like, queueLike, queueUnlike])
post.viewer?.like,
post.uri,
post.cid,
postLikeMutation,
postUnlikeMutation,
])
const onRepost = useCallback(() => { const onRepost = useCallback(async () => {
closeModal() closeModal()
if (!post.viewer?.repost) { try {
Haptics.default() if (!post.viewer?.repost) {
postRepostMutation.mutate({ Haptics.default()
uri: post.uri, await queueRepost()
cid: post.cid, } else {
}) await queueUnrepost()
} else { }
postUnrepostMutation.mutate({ } catch (e: any) {
postUri: post.uri, if (e?.name !== 'AbortError') {
repostUri: post.viewer.repost, throw e
}) }
} }
}, [ }, [post.viewer?.repost, queueRepost, queueUnrepost, closeModal])
post.uri,
post.cid,
post.viewer?.repost,
closeModal,
postRepostMutation,
postUnrepostMutation,
])
const onQuote = useCallback(() => { const onQuote = useCallback(() => {
closeModal() closeModal()