about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-01-25 21:25:12 +0000
committerGitHub <noreply@github.com>2024-01-25 21:25:12 +0000
commit8d3179f0825a8d0bb55566c14e70e02a555ad3bf (patch)
tree2c29cf992f9d893d0481e3ab592e4c6c9b06f141 /src
parent3b26b32f7f8b51b8349754df2b4d12717a9b932e (diff)
downloadvoidsky-8d3179f0825a8d0bb55566c14e70e02a555ad3bf.tar.zst
Fix races for post like/repost toggle (#2617)
Diffstat (limited to 'src')
-rw-r--r--src/state/queries/post.ts187
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx75
2 files changed, 150 insertions, 112 deletions
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 449304ad0..eb59f7da4 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -1,10 +1,11 @@
-import React from 'react'
+import {useCallback} from 'react'
 import {AppBskyFeedDefs, AtUri} from '@atproto/api'
 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query'
-
+import {Shadow} from '#/state/cache/types'
 import {getAgent} from '#/state/session'
 import {updatePostShadow} from '#/state/cache/post-shadow'
 import {track} from '#/lib/analytics/analytics'
+import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
 
 export const RQKEY = (postUri: string) => ['post', postUri]
 
@@ -25,7 +26,7 @@ export function usePostQuery(uri: string | undefined) {
 
 export function useGetPost() {
   const queryClient = useQueryClient()
-  return React.useCallback(
+  return useCallback(
     async ({uri}: {uri: string}) => {
       return queryClient.fetchQuery({
         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<
     {uri: string}, // responds with the uri of the like
     Error,
     {uri: string; cid: string} // the post's uri and cid
   >({
     mutationFn: post => getAgent().like(post.uri, post.cid),
-    onMutate(variables) {
-      // 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,
-      })
+    onSuccess() {
       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}>({
-    mutationFn: async ({likeUri}) => {
-      await getAgent().deleteLike(likeUri)
+    mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri),
+    onSuccess() {
       track('Post:Unlike')
     },
-    onMutate(variables) {
-      // optimistically update the post-shadow
-      updatePostShadow(variables.postUri, {
-        likeUri: undefined,
-      })
+  })
+}
+
+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
+      }
     },
-    onError(error, variables) {
-      // revert the optimistic update
-      updatePostShadow(variables.postUri, {
-        likeUri: variables.likeUri,
+    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]
 }
 
-export function usePostRepostMutation() {
+function usePostRepostMutation() {
   return useMutation<
     {uri: string}, // responds with the uri of the repost
     Error,
     {uri: string; cid: string} // the post's uri and cid
   >({
     mutationFn: post => getAgent().repost(post.uri, post.cid),
-    onMutate(variables) {
-      // 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,
-      })
+    onSuccess() {
       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}>({
-    mutationFn: async ({repostUri}) => {
-      await getAgent().deleteRepost(repostUri)
+    mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri),
+    onSuccess() {
       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,
-      })
-    },
   })
 }
 
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 41f5d80d6..65582ba88 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -22,10 +22,8 @@ import {Haptics} from 'lib/haptics'
 import {HITSLOP_10, HITSLOP_20} from 'lib/constants'
 import {useModalControls} from '#/state/modals'
 import {
-  usePostLikeMutation,
-  usePostUnlikeMutation,
-  usePostRepostMutation,
-  usePostUnrepostMutation,
+  usePostLikeMutationQueue,
+  usePostRepostMutationQueue,
 } from '#/state/queries/post'
 import {useComposerControls} from '#/state/shell/composer'
 import {Shadow} from '#/state/cache/types'
@@ -54,10 +52,8 @@ let PostCtrls = ({
   const {_} = useLingui()
   const {openComposer} = useComposerControls()
   const {closeModal} = useModalControls()
-  const postLikeMutation = usePostLikeMutation()
-  const postUnlikeMutation = usePostUnlikeMutation()
-  const postRepostMutation = usePostRepostMutation()
-  const postUnrepostMutation = usePostUnrepostMutation()
+  const [queueLike, queueUnlike] = usePostLikeMutationQueue(post)
+  const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(post)
   const requireAuth = useRequireAuth()
 
   const defaultCtrlColor = React.useMemo(
@@ -68,48 +64,35 @@ let PostCtrls = ({
   ) as StyleProp<ViewStyle>
 
   const onPressToggleLike = React.useCallback(async () => {
-    if (!post.viewer?.like) {
-      Haptics.default()
-      postLikeMutation.mutate({
-        uri: post.uri,
-        cid: post.cid,
-      })
-    } else {
-      postUnlikeMutation.mutate({
-        postUri: post.uri,
-        likeUri: post.viewer.like,
-      })
+    try {
+      if (!post.viewer?.like) {
+        Haptics.default()
+        await queueLike()
+      } else {
+        await queueUnlike()
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        throw e
+      }
     }
-  }, [
-    post.viewer?.like,
-    post.uri,
-    post.cid,
-    postLikeMutation,
-    postUnlikeMutation,
-  ])
+  }, [post.viewer?.like, queueLike, queueUnlike])
 
-  const onRepost = useCallback(() => {
+  const onRepost = useCallback(async () => {
     closeModal()
-    if (!post.viewer?.repost) {
-      Haptics.default()
-      postRepostMutation.mutate({
-        uri: post.uri,
-        cid: post.cid,
-      })
-    } else {
-      postUnrepostMutation.mutate({
-        postUri: post.uri,
-        repostUri: post.viewer.repost,
-      })
+    try {
+      if (!post.viewer?.repost) {
+        Haptics.default()
+        await queueRepost()
+      } else {
+        await queueUnrepost()
+      }
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        throw e
+      }
     }
-  }, [
-    post.uri,
-    post.cid,
-    post.viewer?.repost,
-    closeModal,
-    postRepostMutation,
-    postUnrepostMutation,
-  ])
+  }, [post.viewer?.repost, queueRepost, queueUnrepost, closeModal])
 
   const onQuote = useCallback(() => {
     closeModal()