Bluesky app fork with some witchin' additions 💫

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

authored by danabra.mov and committed by

GitHub 8d3179f0 3b26b32f

+150 -112
+121 -66
src/state/queries/post.ts
··· 1 - import React from 'react' 1 + import {useCallback} from 'react' 2 2 import {AppBskyFeedDefs, AtUri} from '@atproto/api' 3 3 import {useQuery, useMutation, useQueryClient} from '@tanstack/react-query' 4 - 4 + import {Shadow} from '#/state/cache/types' 5 5 import {getAgent} from '#/state/session' 6 6 import {updatePostShadow} from '#/state/cache/post-shadow' 7 7 import {track} from '#/lib/analytics/analytics' 8 + import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 8 9 9 10 export const RQKEY = (postUri: string) => ['post', postUri] 10 11 ··· 25 26 26 27 export function useGetPost() { 27 28 const queryClient = useQueryClient() 28 - return React.useCallback( 29 + return useCallback( 29 30 async ({uri}: {uri: string}) => { 30 31 return queryClient.fetchQuery({ 31 32 queryKey: RQKEY(uri || ''), ··· 55 56 ) 56 57 } 57 58 58 - export function usePostLikeMutation() { 59 + export function usePostLikeMutationQueue( 60 + post: Shadow<AppBskyFeedDefs.PostView>, 61 + ) { 62 + const postUri = post.uri 63 + const postCid = post.cid 64 + const initialLikeUri = post.viewer?.like 65 + const likeMutation = usePostLikeMutation() 66 + const unlikeMutation = usePostUnlikeMutation() 67 + 68 + const queueToggle = useToggleMutationQueue({ 69 + initialState: initialLikeUri, 70 + runMutation: async (prevLikeUri, shouldLike) => { 71 + if (shouldLike) { 72 + const {uri: likeUri} = await likeMutation.mutateAsync({ 73 + uri: postUri, 74 + cid: postCid, 75 + }) 76 + return likeUri 77 + } else { 78 + if (prevLikeUri) { 79 + await unlikeMutation.mutateAsync({ 80 + postUri: postUri, 81 + likeUri: prevLikeUri, 82 + }) 83 + } 84 + return undefined 85 + } 86 + }, 87 + onSuccess(finalLikeUri) { 88 + // finalize 89 + updatePostShadow(postUri, { 90 + likeUri: finalLikeUri, 91 + }) 92 + }, 93 + }) 94 + 95 + const queueLike = useCallback(() => { 96 + // optimistically update 97 + updatePostShadow(postUri, { 98 + likeUri: 'pending', 99 + }) 100 + return queueToggle(true) 101 + }, [postUri, queueToggle]) 102 + 103 + const queueUnlike = useCallback(() => { 104 + // optimistically update 105 + updatePostShadow(postUri, { 106 + likeUri: undefined, 107 + }) 108 + return queueToggle(false) 109 + }, [postUri, queueToggle]) 110 + 111 + return [queueLike, queueUnlike] 112 + } 113 + 114 + function usePostLikeMutation() { 59 115 return useMutation< 60 116 {uri: string}, // responds with the uri of the like 61 117 Error, 62 118 {uri: string; cid: string} // the post's uri and cid 63 119 >({ 64 120 mutationFn: post => getAgent().like(post.uri, post.cid), 65 - onMutate(variables) { 66 - // optimistically update the post-shadow 67 - updatePostShadow(variables.uri, { 68 - likeUri: 'pending', 69 - }) 70 - }, 71 - onSuccess(data, variables) { 72 - // finalize the post-shadow with the like URI 73 - updatePostShadow(variables.uri, { 74 - likeUri: data.uri, 75 - }) 121 + onSuccess() { 76 122 track('Post:Like') 77 123 }, 78 - onError(error, variables) { 79 - // revert the optimistic update 80 - updatePostShadow(variables.uri, { 81 - likeUri: undefined, 82 - }) 83 - }, 84 124 }) 85 125 } 86 126 87 - export function usePostUnlikeMutation() { 127 + function usePostUnlikeMutation() { 88 128 return useMutation<void, Error, {postUri: string; likeUri: string}>({ 89 - mutationFn: async ({likeUri}) => { 90 - await getAgent().deleteLike(likeUri) 129 + mutationFn: ({likeUri}) => getAgent().deleteLike(likeUri), 130 + onSuccess() { 91 131 track('Post:Unlike') 92 132 }, 93 - onMutate(variables) { 94 - // optimistically update the post-shadow 95 - updatePostShadow(variables.postUri, { 96 - likeUri: undefined, 97 - }) 133 + }) 134 + } 135 + 136 + export function usePostRepostMutationQueue( 137 + post: Shadow<AppBskyFeedDefs.PostView>, 138 + ) { 139 + const postUri = post.uri 140 + const postCid = post.cid 141 + const initialRepostUri = post.viewer?.repost 142 + const repostMutation = usePostRepostMutation() 143 + const unrepostMutation = usePostUnrepostMutation() 144 + 145 + const queueToggle = useToggleMutationQueue({ 146 + initialState: initialRepostUri, 147 + runMutation: async (prevRepostUri, shouldRepost) => { 148 + if (shouldRepost) { 149 + const {uri: repostUri} = await repostMutation.mutateAsync({ 150 + uri: postUri, 151 + cid: postCid, 152 + }) 153 + return repostUri 154 + } else { 155 + if (prevRepostUri) { 156 + await unrepostMutation.mutateAsync({ 157 + postUri: postUri, 158 + repostUri: prevRepostUri, 159 + }) 160 + } 161 + return undefined 162 + } 98 163 }, 99 - onError(error, variables) { 100 - // revert the optimistic update 101 - updatePostShadow(variables.postUri, { 102 - likeUri: variables.likeUri, 164 + onSuccess(finalRepostUri) { 165 + // finalize 166 + updatePostShadow(postUri, { 167 + repostUri: finalRepostUri, 103 168 }) 104 169 }, 105 170 }) 171 + 172 + const queueRepost = useCallback(() => { 173 + // optimistically update 174 + updatePostShadow(postUri, { 175 + repostUri: 'pending', 176 + }) 177 + return queueToggle(true) 178 + }, [postUri, queueToggle]) 179 + 180 + const queueUnrepost = useCallback(() => { 181 + // optimistically update 182 + updatePostShadow(postUri, { 183 + repostUri: undefined, 184 + }) 185 + return queueToggle(false) 186 + }, [postUri, queueToggle]) 187 + 188 + return [queueRepost, queueUnrepost] 106 189 } 107 190 108 - export function usePostRepostMutation() { 191 + function usePostRepostMutation() { 109 192 return useMutation< 110 193 {uri: string}, // responds with the uri of the repost 111 194 Error, 112 195 {uri: string; cid: string} // the post's uri and cid 113 196 >({ 114 197 mutationFn: post => getAgent().repost(post.uri, post.cid), 115 - onMutate(variables) { 116 - // optimistically update the post-shadow 117 - updatePostShadow(variables.uri, { 118 - repostUri: 'pending', 119 - }) 120 - }, 121 - onSuccess(data, variables) { 122 - // finalize the post-shadow with the repost URI 123 - updatePostShadow(variables.uri, { 124 - repostUri: data.uri, 125 - }) 198 + onSuccess() { 126 199 track('Post:Repost') 127 200 }, 128 - onError(error, variables) { 129 - // revert the optimistic update 130 - updatePostShadow(variables.uri, { 131 - repostUri: undefined, 132 - }) 133 - }, 134 201 }) 135 202 } 136 203 137 - export function usePostUnrepostMutation() { 204 + function usePostUnrepostMutation() { 138 205 return useMutation<void, Error, {postUri: string; repostUri: string}>({ 139 - mutationFn: async ({repostUri}) => { 140 - await getAgent().deleteRepost(repostUri) 206 + mutationFn: ({repostUri}) => getAgent().deleteRepost(repostUri), 207 + onSuccess() { 141 208 track('Post:Unrepost') 142 - }, 143 - onMutate(variables) { 144 - // optimistically update the post-shadow 145 - updatePostShadow(variables.postUri, { 146 - repostUri: undefined, 147 - }) 148 - }, 149 - onError(error, variables) { 150 - // revert the optimistic update 151 - updatePostShadow(variables.postUri, { 152 - repostUri: variables.repostUri, 153 - }) 154 209 }, 155 210 }) 156 211 }
+29 -46
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 22 22 import {HITSLOP_10, HITSLOP_20} from 'lib/constants' 23 23 import {useModalControls} from '#/state/modals' 24 24 import { 25 - usePostLikeMutation, 26 - usePostUnlikeMutation, 27 - usePostRepostMutation, 28 - usePostUnrepostMutation, 25 + usePostLikeMutationQueue, 26 + usePostRepostMutationQueue, 29 27 } from '#/state/queries/post' 30 28 import {useComposerControls} from '#/state/shell/composer' 31 29 import {Shadow} from '#/state/cache/types' ··· 54 52 const {_} = useLingui() 55 53 const {openComposer} = useComposerControls() 56 54 const {closeModal} = useModalControls() 57 - const postLikeMutation = usePostLikeMutation() 58 - const postUnlikeMutation = usePostUnlikeMutation() 59 - const postRepostMutation = usePostRepostMutation() 60 - const postUnrepostMutation = usePostUnrepostMutation() 55 + const [queueLike, queueUnlike] = usePostLikeMutationQueue(post) 56 + const [queueRepost, queueUnrepost] = usePostRepostMutationQueue(post) 61 57 const requireAuth = useRequireAuth() 62 58 63 59 const defaultCtrlColor = React.useMemo( ··· 68 64 ) as StyleProp<ViewStyle> 69 65 70 66 const onPressToggleLike = React.useCallback(async () => { 71 - if (!post.viewer?.like) { 72 - Haptics.default() 73 - postLikeMutation.mutate({ 74 - uri: post.uri, 75 - cid: post.cid, 76 - }) 77 - } else { 78 - postUnlikeMutation.mutate({ 79 - postUri: post.uri, 80 - likeUri: post.viewer.like, 81 - }) 67 + try { 68 + if (!post.viewer?.like) { 69 + Haptics.default() 70 + await queueLike() 71 + } else { 72 + await queueUnlike() 73 + } 74 + } catch (e: any) { 75 + if (e?.name !== 'AbortError') { 76 + throw e 77 + } 82 78 } 83 - }, [ 84 - post.viewer?.like, 85 - post.uri, 86 - post.cid, 87 - postLikeMutation, 88 - postUnlikeMutation, 89 - ]) 79 + }, [post.viewer?.like, queueLike, queueUnlike]) 90 80 91 - const onRepost = useCallback(() => { 81 + const onRepost = useCallback(async () => { 92 82 closeModal() 93 - if (!post.viewer?.repost) { 94 - Haptics.default() 95 - postRepostMutation.mutate({ 96 - uri: post.uri, 97 - cid: post.cid, 98 - }) 99 - } else { 100 - postUnrepostMutation.mutate({ 101 - postUri: post.uri, 102 - repostUri: post.viewer.repost, 103 - }) 83 + try { 84 + if (!post.viewer?.repost) { 85 + Haptics.default() 86 + await queueRepost() 87 + } else { 88 + await queueUnrepost() 89 + } 90 + } catch (e: any) { 91 + if (e?.name !== 'AbortError') { 92 + throw e 93 + } 104 94 } 105 - }, [ 106 - post.uri, 107 - post.cid, 108 - post.viewer?.repost, 109 - closeModal, 110 - postRepostMutation, 111 - postUnrepostMutation, 112 - ]) 95 + }, [post.viewer?.repost, queueRepost, queueUnrepost, closeModal]) 113 96 114 97 const onQuote = useCallback(() => { 115 98 closeModal()