Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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

authored by

dan and committed by
GitHub
84753124 54faa7e1

+452 -187
+98
src/lib/hooks/useToggleMutationQueue.ts
··· 1 + import {useState, useRef, useEffect, useCallback} from 'react' 2 + 3 + type Task<TServerState> = { 4 + isOn: boolean 5 + resolve: (serverState: TServerState) => void 6 + reject: (e: unknown) => void 7 + } 8 + 9 + type TaskQueue<TServerState> = { 10 + activeTask: Task<TServerState> | null 11 + queuedTask: Task<TServerState> | null 12 + } 13 + 14 + function AbortError() { 15 + const e = new Error() 16 + e.name = 'AbortError' 17 + return e 18 + } 19 + 20 + export function useToggleMutationQueue<TServerState>({ 21 + initialState, 22 + runMutation, 23 + onSuccess, 24 + }: { 25 + initialState: TServerState 26 + runMutation: ( 27 + prevState: TServerState, 28 + nextIsOn: boolean, 29 + ) => Promise<TServerState> 30 + onSuccess: (finalState: TServerState) => void 31 + }) { 32 + // We use the queue as a mutable object. 33 + // This is safe becuase it is not used for rendering. 34 + const [queue] = useState<TaskQueue<TServerState>>({ 35 + activeTask: null, 36 + queuedTask: null, 37 + }) 38 + 39 + async function processQueue() { 40 + if (queue.activeTask) { 41 + // There is another active processQueue call iterating over tasks. 42 + // It will handle any newly added tasks, so we should exit early. 43 + return 44 + } 45 + // To avoid relying on the rendered state, capture it once at the start. 46 + // From that point on, and until the queue is drained, we'll use the real server state. 47 + let confirmedState: TServerState = initialState 48 + try { 49 + while (queue.queuedTask) { 50 + const prevTask = queue.activeTask 51 + const nextTask = queue.queuedTask 52 + queue.activeTask = nextTask 53 + queue.queuedTask = null 54 + if (prevTask?.isOn === nextTask.isOn) { 55 + // Skip multiple requests to update to the same value in a row. 56 + prevTask.reject(new (AbortError as any)()) 57 + continue 58 + } 59 + try { 60 + // The state received from the server feeds into the next task. 61 + // This lets us queue deletions of not-yet-created resources. 62 + confirmedState = await runMutation(confirmedState, nextTask.isOn) 63 + nextTask.resolve(confirmedState) 64 + } catch (e) { 65 + nextTask.reject(e) 66 + } 67 + } 68 + } finally { 69 + onSuccess(confirmedState) 70 + queue.activeTask = null 71 + queue.queuedTask = null 72 + } 73 + } 74 + 75 + function queueToggle(isOn: boolean): Promise<TServerState> { 76 + return new Promise((resolve, reject) => { 77 + // This is a toggle, so the next queued value can safely replace the queued one. 78 + if (queue.queuedTask) { 79 + queue.queuedTask.reject(new (AbortError as any)()) 80 + } 81 + queue.queuedTask = {isOn, resolve, reject} 82 + processQueue() 83 + }) 84 + } 85 + 86 + const queueToggleRef = useRef(queueToggle) 87 + useEffect(() => { 88 + queueToggleRef.current = queueToggle 89 + }) 90 + const queueToggleStable = useCallback( 91 + (isOn: boolean): Promise<TServerState> => { 92 + const queueToggleLatest = queueToggleRef.current 93 + return queueToggleLatest(isOn) 94 + }, 95 + [], 96 + ) 97 + return queueToggleStable 98 + }
+274 -67
src/state/queries/profile.ts
··· 1 + import {useCallback} from 'react' 1 2 import { 2 3 AtUri, 3 4 AppBskyActorDefs, ··· 11 12 import {updateProfileShadow} from '../cache/profile-shadow' 12 13 import {uploadBlob} from '#/lib/api' 13 14 import {until} from '#/lib/async/until' 15 + import {Shadow} from '#/state/cache/types' 16 + import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 14 17 import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' 15 18 import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' 16 19 ··· 99 102 }) 100 103 } 101 104 102 - export function useProfileFollowMutation() { 105 + export function useProfileFollowMutationQueue( 106 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, 107 + ) { 108 + const did = profile.did 109 + const initialFollowingUri = profile.viewer?.following 110 + const followMutation = useProfileFollowMutation() 111 + const unfollowMutation = useProfileUnfollowMutation() 112 + 113 + const queueToggle = useToggleMutationQueue({ 114 + initialState: initialFollowingUri, 115 + runMutation: async (prevFollowingUri, shouldFollow) => { 116 + if (shouldFollow) { 117 + const {uri} = await followMutation.mutateAsync({ 118 + did, 119 + skipOptimistic: true, 120 + }) 121 + return uri 122 + } else { 123 + if (prevFollowingUri) { 124 + await unfollowMutation.mutateAsync({ 125 + did, 126 + followUri: prevFollowingUri, 127 + skipOptimistic: true, 128 + }) 129 + } 130 + return undefined 131 + } 132 + }, 133 + onSuccess(finalFollowingUri) { 134 + // finalize 135 + updateProfileShadow(did, { 136 + followingUri: finalFollowingUri, 137 + }) 138 + }, 139 + }) 140 + 141 + const queueFollow = useCallback(() => { 142 + // optimistically update 143 + updateProfileShadow(did, { 144 + followingUri: 'pending', 145 + }) 146 + return queueToggle(true) 147 + }, [did, queueToggle]) 148 + 149 + const queueUnfollow = useCallback(() => { 150 + // optimistically update 151 + updateProfileShadow(did, { 152 + followingUri: undefined, 153 + }) 154 + return queueToggle(false) 155 + }, [did, queueToggle]) 156 + 157 + return [queueFollow, queueUnfollow] 158 + } 159 + 160 + function useProfileFollowMutation() { 103 161 const {agent} = useSession() 104 - return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 162 + return useMutation< 163 + {uri: string; cid: string}, 164 + Error, 165 + {did: string; skipOptimistic?: boolean} 166 + >({ 105 167 mutationFn: async ({did}) => { 106 168 return await agent.follow(did) 107 169 }, 108 170 onMutate(variables) { 109 - // optimstically update 110 - updateProfileShadow(variables.did, { 111 - followingUri: 'pending', 112 - }) 171 + if (!variables.skipOptimistic) { 172 + // optimistically update 173 + updateProfileShadow(variables.did, { 174 + followingUri: 'pending', 175 + }) 176 + } 113 177 }, 114 178 onSuccess(data, variables) { 115 - // finalize 116 - updateProfileShadow(variables.did, { 117 - followingUri: data.uri, 118 - }) 179 + if (!variables.skipOptimistic) { 180 + // finalize 181 + updateProfileShadow(variables.did, { 182 + followingUri: data.uri, 183 + }) 184 + } 119 185 }, 120 186 onError(error, variables) { 121 - // revert the optimistic update 122 - updateProfileShadow(variables.did, { 123 - followingUri: undefined, 124 - }) 187 + if (!variables.skipOptimistic) { 188 + // revert the optimistic update 189 + updateProfileShadow(variables.did, { 190 + followingUri: undefined, 191 + }) 192 + } 125 193 }, 126 194 }) 127 195 } 128 196 129 - export function useProfileUnfollowMutation() { 197 + function useProfileUnfollowMutation() { 130 198 const {agent} = useSession() 131 - return useMutation<void, Error, {did: string; followUri: string}>({ 199 + return useMutation< 200 + void, 201 + Error, 202 + {did: string; followUri: string; skipOptimistic?: boolean} 203 + >({ 132 204 mutationFn: async ({followUri}) => { 133 205 return await agent.deleteFollow(followUri) 134 206 }, 135 207 onMutate(variables) { 136 - // optimstically update 137 - updateProfileShadow(variables.did, { 138 - followingUri: undefined, 139 - }) 208 + if (!variables.skipOptimistic) { 209 + // optimistically update 210 + updateProfileShadow(variables.did, { 211 + followingUri: undefined, 212 + }) 213 + } 140 214 }, 141 215 onError(error, variables) { 142 - // revert the optimistic update 143 - updateProfileShadow(variables.did, { 144 - followingUri: variables.followUri, 145 - }) 216 + if (!variables.skipOptimistic) { 217 + // revert the optimistic update 218 + updateProfileShadow(variables.did, { 219 + followingUri: variables.followUri, 220 + }) 221 + } 146 222 }, 147 223 }) 148 224 } 149 225 150 - export function useProfileMuteMutation() { 226 + export function useProfileMuteMutationQueue( 227 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, 228 + ) { 229 + const did = profile.did 230 + const initialMuted = profile.viewer?.muted 231 + const muteMutation = useProfileMuteMutation() 232 + const unmuteMutation = useProfileUnmuteMutation() 233 + 234 + const queueToggle = useToggleMutationQueue({ 235 + initialState: initialMuted, 236 + runMutation: async (_prevMuted, shouldMute) => { 237 + if (shouldMute) { 238 + await muteMutation.mutateAsync({ 239 + did, 240 + skipOptimistic: true, 241 + }) 242 + return true 243 + } else { 244 + await unmuteMutation.mutateAsync({ 245 + did, 246 + skipOptimistic: true, 247 + }) 248 + return false 249 + } 250 + }, 251 + onSuccess(finalMuted) { 252 + // finalize 253 + updateProfileShadow(did, {muted: finalMuted}) 254 + }, 255 + }) 256 + 257 + const queueMute = useCallback(() => { 258 + // optimistically update 259 + updateProfileShadow(did, { 260 + muted: true, 261 + }) 262 + return queueToggle(true) 263 + }, [did, queueToggle]) 264 + 265 + const queueUnmute = useCallback(() => { 266 + // optimistically update 267 + updateProfileShadow(did, { 268 + muted: false, 269 + }) 270 + return queueToggle(false) 271 + }, [did, queueToggle]) 272 + 273 + return [queueMute, queueUnmute] 274 + } 275 + 276 + function useProfileMuteMutation() { 151 277 const {agent} = useSession() 152 278 const queryClient = useQueryClient() 153 - return useMutation<void, Error, {did: string}>({ 279 + return useMutation<void, Error, {did: string; skipOptimistic?: boolean}>({ 154 280 mutationFn: async ({did}) => { 155 281 await agent.mute(did) 156 282 }, 157 283 onMutate(variables) { 158 - // optimstically update 159 - updateProfileShadow(variables.did, { 160 - muted: true, 161 - }) 284 + if (!variables.skipOptimistic) { 285 + // optimistically update 286 + updateProfileShadow(variables.did, { 287 + muted: true, 288 + }) 289 + } 162 290 }, 163 291 onSuccess() { 164 292 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 165 293 }, 166 294 onError(error, variables) { 167 - // revert the optimistic update 168 - updateProfileShadow(variables.did, { 169 - muted: false, 170 - }) 295 + if (!variables.skipOptimistic) { 296 + // revert the optimistic update 297 + updateProfileShadow(variables.did, { 298 + muted: false, 299 + }) 300 + } 171 301 }, 172 302 }) 173 303 } 174 304 175 - export function useProfileUnmuteMutation() { 305 + function useProfileUnmuteMutation() { 176 306 const {agent} = useSession() 177 - return useMutation<void, Error, {did: string}>({ 307 + return useMutation<void, Error, {did: string; skipOptimistic?: boolean}>({ 178 308 mutationFn: async ({did}) => { 179 309 await agent.unmute(did) 180 310 }, 181 311 onMutate(variables) { 182 - // optimstically update 183 - updateProfileShadow(variables.did, { 184 - muted: false, 185 - }) 312 + if (!variables.skipOptimistic) { 313 + // optimistically update 314 + updateProfileShadow(variables.did, { 315 + muted: false, 316 + }) 317 + } 186 318 }, 187 319 onError(error, variables) { 188 - // revert the optimistic update 189 - updateProfileShadow(variables.did, { 190 - muted: true, 320 + if (!variables.skipOptimistic) { 321 + // revert the optimistic update 322 + updateProfileShadow(variables.did, { 323 + muted: true, 324 + }) 325 + } 326 + }, 327 + }) 328 + } 329 + 330 + export function useProfileBlockMutationQueue( 331 + profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>, 332 + ) { 333 + const did = profile.did 334 + const initialBlockingUri = profile.viewer?.blocking 335 + const blockMutation = useProfileBlockMutation() 336 + const unblockMutation = useProfileUnblockMutation() 337 + 338 + const queueToggle = useToggleMutationQueue({ 339 + initialState: initialBlockingUri, 340 + runMutation: async (prevBlockUri, shouldFollow) => { 341 + if (shouldFollow) { 342 + const {uri} = await blockMutation.mutateAsync({ 343 + did, 344 + skipOptimistic: true, 345 + }) 346 + return uri 347 + } else { 348 + if (prevBlockUri) { 349 + await unblockMutation.mutateAsync({ 350 + did, 351 + blockUri: prevBlockUri, 352 + skipOptimistic: true, 353 + }) 354 + } 355 + return undefined 356 + } 357 + }, 358 + onSuccess(finalBlockingUri) { 359 + // finalize 360 + updateProfileShadow(did, { 361 + blockingUri: finalBlockingUri, 191 362 }) 192 363 }, 193 364 }) 365 + 366 + const queueBlock = useCallback(() => { 367 + // optimistically update 368 + updateProfileShadow(did, { 369 + blockingUri: 'pending', 370 + }) 371 + return queueToggle(true) 372 + }, [did, queueToggle]) 373 + 374 + const queueUnblock = useCallback(() => { 375 + // optimistically update 376 + updateProfileShadow(did, { 377 + blockingUri: undefined, 378 + }) 379 + return queueToggle(false) 380 + }, [did, queueToggle]) 381 + 382 + return [queueBlock, queueUnblock] 194 383 } 195 384 196 - export function useProfileBlockMutation() { 385 + function useProfileBlockMutation() { 197 386 const {agent, currentAccount} = useSession() 198 387 const queryClient = useQueryClient() 199 - return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 388 + return useMutation< 389 + {uri: string; cid: string}, 390 + Error, 391 + {did: string; skipOptimistic?: boolean} 392 + >({ 200 393 mutationFn: async ({did}) => { 201 394 if (!currentAccount) { 202 395 throw new Error('Not signed in') ··· 207 400 ) 208 401 }, 209 402 onMutate(variables) { 210 - // optimstically update 211 - updateProfileShadow(variables.did, { 212 - blockingUri: 'pending', 213 - }) 403 + if (!variables.skipOptimistic) { 404 + // optimistically update 405 + updateProfileShadow(variables.did, { 406 + blockingUri: 'pending', 407 + }) 408 + } 214 409 }, 215 410 onSuccess(data, variables) { 216 - // finalize 217 - updateProfileShadow(variables.did, { 218 - blockingUri: data.uri, 219 - }) 411 + if (!variables.skipOptimistic) { 412 + // finalize 413 + updateProfileShadow(variables.did, { 414 + blockingUri: data.uri, 415 + }) 416 + } 220 417 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) 221 418 }, 222 419 onError(error, variables) { 223 - // revert the optimistic update 224 - updateProfileShadow(variables.did, { 225 - blockingUri: undefined, 226 - }) 420 + if (!variables.skipOptimistic) { 421 + // revert the optimistic update 422 + updateProfileShadow(variables.did, { 423 + blockingUri: undefined, 424 + }) 425 + } 227 426 }, 228 427 }) 229 428 } 230 429 231 - export function useProfileUnblockMutation() { 430 + function useProfileUnblockMutation() { 232 431 const {agent, currentAccount} = useSession() 233 - return useMutation<void, Error, {did: string; blockUri: string}>({ 432 + return useMutation< 433 + void, 434 + Error, 435 + {did: string; blockUri: string; skipOptimistic?: boolean} 436 + >({ 234 437 mutationFn: async ({blockUri}) => { 235 438 if (!currentAccount) { 236 439 throw new Error('Not signed in') ··· 242 445 }) 243 446 }, 244 447 onMutate(variables) { 245 - // optimstically update 246 - updateProfileShadow(variables.did, { 247 - blockingUri: undefined, 248 - }) 448 + if (!variables.skipOptimistic) { 449 + // optimistically update 450 + updateProfileShadow(variables.did, { 451 + blockingUri: undefined, 452 + }) 453 + } 249 454 }, 250 455 onError(error, variables) { 251 - // revert the optimistic update 252 - updateProfileShadow(variables.did, { 253 - blockingUri: variables.blockUri, 254 - }) 456 + if (!variables.skipOptimistic) { 457 + // revert the optimistic update 458 + updateProfileShadow(variables.did, { 459 + blockingUri: variables.blockUri, 460 + }) 461 + } 255 462 }, 256 463 }) 257 464 }
+14 -21
src/view/com/auth/onboarding/RecommendedFollowsItem.tsx
··· 13 13 import {useAnalytics} from 'lib/analytics/analytics' 14 14 import {Trans} from '@lingui/macro' 15 15 import {Shadow, useProfileShadow} from '#/state/cache/profile-shadow' 16 - import { 17 - useProfileFollowMutation, 18 - useProfileUnfollowMutation, 19 - } from '#/state/queries/profile' 16 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 20 17 import {logger} from '#/logger' 21 18 22 19 type Props = { ··· 77 74 const pal = usePalette('default') 78 75 const [addingMoreSuggestions, setAddingMoreSuggestions] = 79 76 React.useState(false) 80 - const {mutateAsync: follow} = useProfileFollowMutation() 81 - const {mutateAsync: unfollow} = useProfileUnfollowMutation() 77 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) 82 78 83 79 const onToggleFollow = React.useCallback(async () => { 84 80 try { 85 - if ( 86 - profile.viewer?.following && 87 - profile.viewer?.following !== 'pending' 88 - ) { 89 - await unfollow({did: profile.did, followUri: profile.viewer.following}) 90 - } else if ( 91 - !profile.viewer?.following && 92 - profile.viewer?.following !== 'pending' 93 - ) { 81 + if (profile.viewer?.following) { 82 + await queueUnfollow() 83 + } else { 94 84 setAddingMoreSuggestions(true) 95 - await follow({did: profile.did}) 85 + await queueFollow() 96 86 await onFollowStateChange({did: profile.did, following: true}) 97 87 setAddingMoreSuggestions(false) 98 88 track('Onboarding:SuggestedFollowFollowed') 99 89 } 100 - } catch (e) { 101 - logger.error('RecommendedFollows: failed to toggle following', {error: e}) 90 + } catch (e: any) { 91 + if (e?.name !== 'AbortError') { 92 + logger.error('RecommendedFollows: failed to toggle following', { 93 + error: e, 94 + }) 95 + } 102 96 } finally { 103 97 setAddingMoreSuggestions(false) 104 98 } 105 99 }, [ 106 100 profile, 107 - follow, 108 - unfollow, 101 + queueFollow, 102 + queueUnfollow, 109 103 setAddingMoreSuggestions, 110 104 track, 111 105 onFollowStateChange, ··· 142 136 labelStyle={styles.followButton} 143 137 onPress={onToggleFollow} 144 138 label={profile.viewer?.following ? 'Unfollow' : 'Follow'} 145 - withLoading={true} 146 139 /> 147 140 </View> 148 141 {profile.description ? (
+10 -21
src/view/com/profile/FollowButton.tsx
··· 3 3 import {AppBskyActorDefs} from '@atproto/api' 4 4 import {Button, ButtonType} from '../util/forms/Button' 5 5 import * as Toast from '../util/Toast' 6 - import { 7 - useProfileFollowMutation, 8 - useProfileUnfollowMutation, 9 - } from '#/state/queries/profile' 6 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 10 7 import {Shadow} from '#/state/cache/types' 11 8 12 9 export function FollowButton({ ··· 20 17 profile: Shadow<AppBskyActorDefs.ProfileViewBasic> 21 18 labelStyle?: StyleProp<TextStyle> 22 19 }) { 23 - const followMutation = useProfileFollowMutation() 24 - const unfollowMutation = useProfileUnfollowMutation() 20 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) 25 21 26 22 const onPressFollow = async () => { 27 - if (profile.viewer?.following) { 28 - return 29 - } 30 23 try { 31 - await followMutation.mutateAsync({did: profile.did}) 24 + await queueFollow() 32 25 } catch (e: any) { 33 - Toast.show(`An issue occurred, please try again.`) 26 + if (e?.name !== 'AbortError') { 27 + Toast.show(`An issue occurred, please try again.`) 28 + } 34 29 } 35 30 } 36 31 37 32 const onPressUnfollow = async () => { 38 - if (!profile.viewer?.following) { 39 - return 40 - } 41 33 try { 42 - await unfollowMutation.mutateAsync({ 43 - did: profile.did, 44 - followUri: profile.viewer?.following, 45 - }) 34 + await queueUnfollow() 46 35 } catch (e: any) { 47 - Toast.show(`An issue occurred, please try again.`) 36 + if (e?.name !== 'AbortError') { 37 + Toast.show(`An issue occurred, please try again.`) 38 + } 48 39 } 49 40 } 50 41 ··· 59 50 labelStyle={labelStyle} 60 51 onPress={onPressUnfollow} 61 52 label="Unfollow" 62 - withLoading={true} 63 53 /> 64 54 ) 65 55 } else { ··· 69 59 labelStyle={labelStyle} 70 60 onPress={onPressFollow} 71 61 label="Follow" 72 - withLoading={true} 73 62 /> 74 63 ) 75 64 }
+44 -56
src/view/com/profile/ProfileHeader.tsx
··· 32 32 import {useModalControls} from '#/state/modals' 33 33 import {useLightboxControls, ProfileImageLightbox} from '#/state/lightbox' 34 34 import { 35 - useProfileFollowMutation, 36 - useProfileUnfollowMutation, 37 - useProfileMuteMutation, 38 - useProfileUnmuteMutation, 39 - useProfileBlockMutation, 40 - useProfileUnblockMutation, 35 + useProfileMuteMutationQueue, 36 + useProfileBlockMutationQueue, 37 + useProfileFollowMutationQueue, 41 38 } from '#/state/queries/profile' 42 39 import {usePalette} from 'lib/hooks/usePalette' 43 40 import {useAnalytics} from 'lib/analytics/analytics' ··· 130 127 : undefined, 131 128 [profile], 132 129 ) 133 - const followMutation = useProfileFollowMutation() 134 - const unfollowMutation = useProfileUnfollowMutation() 135 - const muteMutation = useProfileMuteMutation() 136 - const unmuteMutation = useProfileUnmuteMutation() 137 - const blockMutation = useProfileBlockMutation() 138 - const unblockMutation = useProfileUnblockMutation() 130 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) 131 + const [queueMute, queueUnmute] = useProfileMuteMutationQueue(profile) 132 + const [queueBlock, queueUnblock] = useProfileBlockMutationQueue(profile) 139 133 140 134 const onPressBack = React.useCallback(() => { 141 135 if (navigation.canGoBack()) { ··· 154 148 } 155 149 }, [openLightbox, profile, moderation]) 156 150 157 - const onPressFollow = React.useCallback(async () => { 158 - if (profile.viewer?.following) { 159 - return 160 - } 151 + const onPressFollow = async () => { 161 152 try { 162 153 track('ProfileHeader:FollowButtonClicked') 163 - await followMutation.mutateAsync({did: profile.did}) 154 + await queueFollow() 164 155 Toast.show( 165 156 `Following ${sanitizeDisplayName( 166 157 profile.displayName || profile.handle, 167 158 )}`, 168 159 ) 169 160 } catch (e: any) { 170 - logger.error('Failed to follow', {error: String(e)}) 171 - Toast.show(`There was an issue! ${e.toString()}`) 161 + if (e?.name !== 'AbortError') { 162 + logger.error('Failed to follow', {error: String(e)}) 163 + Toast.show(`There was an issue! ${e.toString()}`) 164 + } 172 165 } 173 - }, [followMutation, profile, track]) 166 + } 174 167 175 - const onPressUnfollow = React.useCallback(async () => { 176 - if (!profile.viewer?.following) { 177 - return 178 - } 168 + const onPressUnfollow = async () => { 179 169 try { 180 170 track('ProfileHeader:UnfollowButtonClicked') 181 - await unfollowMutation.mutateAsync({ 182 - did: profile.did, 183 - followUri: profile.viewer?.following, 184 - }) 171 + await queueUnfollow() 185 172 Toast.show( 186 173 `No longer following ${sanitizeDisplayName( 187 174 profile.displayName || profile.handle, 188 175 )}`, 189 176 ) 190 177 } catch (e: any) { 191 - logger.error('Failed to unfollow', {error: String(e)}) 192 - Toast.show(`There was an issue! ${e.toString()}`) 178 + if (e?.name !== 'AbortError') { 179 + logger.error('Failed to unfollow', {error: String(e)}) 180 + Toast.show(`There was an issue! ${e.toString()}`) 181 + } 193 182 } 194 - }, [unfollowMutation, profile, track]) 183 + } 195 184 196 185 const onPressEditProfile = React.useCallback(() => { 197 186 track('ProfileHeader:EditProfileButtonClicked') ··· 218 207 const onPressMuteAccount = React.useCallback(async () => { 219 208 track('ProfileHeader:MuteAccountButtonClicked') 220 209 try { 221 - await muteMutation.mutateAsync({did: profile.did}) 210 + await queueMute() 222 211 Toast.show('Account muted') 223 212 } catch (e: any) { 224 - logger.error('Failed to mute account', {error: e}) 225 - Toast.show(`There was an issue! ${e.toString()}`) 213 + if (e?.name !== 'AbortError') { 214 + logger.error('Failed to mute account', {error: e}) 215 + Toast.show(`There was an issue! ${e.toString()}`) 216 + } 226 217 } 227 - }, [track, muteMutation, profile]) 218 + }, [track, queueMute]) 228 219 229 220 const onPressUnmuteAccount = React.useCallback(async () => { 230 221 track('ProfileHeader:UnmuteAccountButtonClicked') 231 222 try { 232 - await unmuteMutation.mutateAsync({did: profile.did}) 223 + await queueUnmute() 233 224 Toast.show('Account unmuted') 234 225 } catch (e: any) { 235 - logger.error('Failed to unmute account', {error: e}) 236 - Toast.show(`There was an issue! ${e.toString()}`) 226 + if (e?.name !== 'AbortError') { 227 + logger.error('Failed to unmute account', {error: e}) 228 + Toast.show(`There was an issue! ${e.toString()}`) 229 + } 237 230 } 238 - }, [track, unmuteMutation, profile]) 231 + }, [track, queueUnmute]) 239 232 240 233 const onPressBlockAccount = React.useCallback(async () => { 241 234 track('ProfileHeader:BlockAccountButtonClicked') ··· 245 238 message: 246 239 'Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.', 247 240 onPressConfirm: async () => { 248 - if (profile.viewer?.blocking) { 249 - return 250 - } 251 241 try { 252 - await blockMutation.mutateAsync({did: profile.did}) 242 + await queueBlock() 253 243 Toast.show('Account blocked') 254 244 } catch (e: any) { 255 - logger.error('Failed to block account', {error: e}) 256 - Toast.show(`There was an issue! ${e.toString()}`) 245 + if (e?.name !== 'AbortError') { 246 + logger.error('Failed to block account', {error: e}) 247 + Toast.show(`There was an issue! ${e.toString()}`) 248 + } 257 249 } 258 250 }, 259 251 }) 260 - }, [track, blockMutation, profile, openModal]) 252 + }, [track, queueBlock, openModal]) 261 253 262 254 const onPressUnblockAccount = React.useCallback(async () => { 263 255 track('ProfileHeader:UnblockAccountButtonClicked') ··· 267 259 message: 268 260 'The account will be able to interact with you after unblocking.', 269 261 onPressConfirm: async () => { 270 - if (!profile.viewer?.blocking) { 271 - return 272 - } 273 262 try { 274 - await unblockMutation.mutateAsync({ 275 - did: profile.did, 276 - blockUri: profile.viewer.blocking, 277 - }) 263 + await queueUnblock() 278 264 Toast.show('Account unblocked') 279 265 } catch (e: any) { 280 - logger.error('Failed to unblock account', {error: e}) 281 - Toast.show(`There was an issue! ${e.toString()}`) 266 + if (e?.name !== 'AbortError') { 267 + logger.error('Failed to unblock account', {error: e}) 268 + Toast.show(`There was an issue! ${e.toString()}`) 269 + } 282 270 } 283 271 }, 284 272 }) 285 - }, [track, unblockMutation, profile, openModal]) 273 + }, [track, queueUnblock, openModal]) 286 274 287 275 const onPressReportAccount = React.useCallback(() => { 288 276 track('ProfileHeader:ReportAccountButtonClicked')
+12 -22
src/view/com/profile/ProfileHeaderSuggestedFollows.tsx
··· 26 26 import {useModerationOpts} from '#/state/queries/preferences' 27 27 import {useSuggestedFollowsByActorQuery} from '#/state/queries/suggested-follows' 28 28 import {useProfileShadow} from '#/state/cache/profile-shadow' 29 - import { 30 - useProfileFollowMutation, 31 - useProfileUnfollowMutation, 32 - } from '#/state/queries/profile' 29 + import {useProfileFollowMutationQueue} from '#/state/queries/profile' 33 30 34 31 const OUTER_PADDING = 10 35 32 const INNER_PADDING = 14 ··· 208 205 const pal = usePalette('default') 209 206 const moderationOpts = useModerationOpts() 210 207 const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt) 211 - const followMutation = useProfileFollowMutation() 212 - const unfollowMutation = useProfileUnfollowMutation() 208 + const [queueFollow, queueUnfollow] = useProfileFollowMutationQueue(profile) 213 209 214 210 const onPressFollow = React.useCallback(async () => { 215 - if (profile.viewer?.following) { 216 - return 217 - } 218 211 try { 219 212 track('ProfileHeader:SuggestedFollowFollowed') 220 - await followMutation.mutateAsync({did: profile.did}) 213 + await queueFollow() 221 214 } catch (e: any) { 222 - Toast.show('An issue occurred, please try again.') 215 + if (e?.name !== 'AbortError') { 216 + Toast.show('An issue occurred, please try again.') 217 + } 223 218 } 224 - }, [followMutation, profile, track]) 219 + }, [queueFollow, track]) 225 220 226 221 const onPressUnfollow = React.useCallback(async () => { 227 - if (!profile.viewer?.following) { 228 - return 229 - } 230 222 try { 231 - await unfollowMutation.mutateAsync({ 232 - did: profile.did, 233 - followUri: profile.viewer?.following, 234 - }) 223 + await queueUnfollow() 235 224 } catch (e: any) { 236 - Toast.show('An issue occurred, please try again.') 225 + if (e?.name !== 'AbortError') { 226 + Toast.show('An issue occurred, please try again.') 227 + } 237 228 } 238 - }, [unfollowMutation, profile]) 229 + }, [queueUnfollow]) 239 230 240 231 if (!moderationOpts) { 241 232 return null ··· 284 275 type="inverted" 285 276 labelStyle={{textAlign: 'center'}} 286 277 onPress={following ? onPressUnfollow : onPressFollow} 287 - withLoading 288 278 /> 289 279 </View> 290 280 </Link>