From 1472bd4f173a483e5f1a91514244fecaec23808f Mon Sep 17 00:00:00 2001 From: Ansh Date: Wed, 19 Apr 2023 13:58:24 -0700 Subject: [PATCH] #420: add updateDataOptimistically utility to disallow like counter out of sync (#446) * add isLikedPressed flag to disallow like counter out of sync * create revertible helper for updateDataOptimistically * test implementation * Update updateDataOptimistically() and apply to reposts --------- Co-authored-by: Paul Frazee --- package.json | 3 + src/lib/async/revertible.ts | 52 +++++++++++++++++ src/state/models/content/post-thread.ts | 76 ++++++++++++++----------- src/state/models/feeds/posts.ts | 76 ++++++++++++++----------- src/view/com/util/PostCtrls.tsx | 74 +++++------------------- yarn.lock | 17 ++++++ 6 files changed, 173 insertions(+), 125 deletions(-) create mode 100644 src/lib/async/revertible.ts diff --git a/package.json b/package.json index 23d7149d..57a6d02a 100644 --- a/package.json +++ b/package.json @@ -84,10 +84,12 @@ "lodash.isequal": "^4.5.0", "lodash.omit": "^4.5.0", "lodash.samplesize": "^4.2.0", + "lodash.set": "^4.3.2", "lodash.shuffle": "^4.2.0", "lru_map": "^0.4.1", "mobx": "^6.6.1", "mobx-react-lite": "^3.4.0", + "mobx-utils": "^6.0.6", "normalize-url": "^8.0.0", "patch-package": "^6.5.1", "postinstall-postinstall": "^2.1.0", @@ -143,6 +145,7 @@ "@types/lodash.isequal": "^4.5.6", "@types/lodash.omit": "^4.5.7", "@types/lodash.samplesize": "^4.2.7", + "@types/lodash.set": "^4.3.7", "@types/lodash.shuffle": "^4.2.7", "@types/react-avatar-editor": "^13.0.0", "@types/react-native": "^0.67.3", diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts new file mode 100644 index 00000000..3c8e3e8f --- /dev/null +++ b/src/lib/async/revertible.ts @@ -0,0 +1,52 @@ +import {runInAction} from 'mobx' +import {deepObserve} from 'mobx-utils' +import set from 'lodash.set' + +const ongoingActions = new Set() + +export const updateDataOptimistically = async < + T extends Record, + U, +>( + model: T, + preUpdate: () => void, + serverUpdate: () => Promise, + postUpdate?: (res: U) => void, +): Promise => { + if (ongoingActions.has(model)) { + return + } + ongoingActions.add(model) + + const prevState: Map = new Map() + const dispose = deepObserve(model, (change, path) => { + if (change.observableKind === 'object') { + if (change.type === 'update') { + prevState.set( + [path, change.name].filter(Boolean).join('.'), + change.oldValue, + ) + } else if (change.type === 'add') { + prevState.set([path, change.name].filter(Boolean).join('.'), undefined) + } + } + }) + preUpdate() + dispose() + + try { + const res = await serverUpdate() + runInAction(() => { + postUpdate?.(res) + }) + } catch (error) { + runInAction(() => { + prevState.forEach((value, path) => { + set(model, path, value) + }) + }) + throw error + } finally { + ongoingActions.delete(model) + } +} diff --git a/src/state/models/content/post-thread.ts b/src/state/models/content/post-thread.ts index d3e77367..794beae2 100644 --- a/src/state/models/content/post-thread.ts +++ b/src/state/models/content/post-thread.ts @@ -9,6 +9,7 @@ import {AtUri} from '@atproto/api' import {RootStoreModel} from '../root-store' import * as apilib from 'lib/api/index' import {cleanError} from 'lib/strings/errors' +import {updateDataOptimistically} from 'lib/async/revertible' function* reactKeyGenerator(): Generator { let counter = 0 @@ -134,45 +135,56 @@ export class PostThreadItemModel { } async toggleLike() { - if (this.post.viewer?.like) { - await this.rootStore.agent.deleteLike(this.post.viewer.like) - runInAction(() => { - this.post.likeCount = this.post.likeCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.likeCount-- - this.post.viewer.like = undefined - }) + this.post.viewer = this.post.viewer || {} + if (this.post.viewer.like) { + const url = this.post.viewer.like + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) - 1 + this.post.viewer!.like = undefined + }, + () => this.rootStore.agent.deleteLike(url), + ) } else { - const res = await this.rootStore.agent.like(this.post.uri, this.post.cid) - runInAction(() => { - this.post.likeCount = this.post.likeCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.likeCount++ - this.post.viewer.like = res.uri - }) + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) + 1 + this.post.viewer!.like = 'pending' + }, + () => this.rootStore.agent.like(this.post.uri, this.post.cid), + res => { + this.post.viewer!.like = res.uri + }, + ) } } async toggleRepost() { + this.post.viewer = this.post.viewer || {} if (this.post.viewer?.repost) { - await this.rootStore.agent.deleteRepost(this.post.viewer.repost) - runInAction(() => { - this.post.repostCount = this.post.repostCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.repostCount-- - this.post.viewer.repost = undefined - }) - } else { - const res = await this.rootStore.agent.repost( - this.post.uri, - this.post.cid, + const url = this.post.viewer.repost + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) - 1 + this.post.viewer!.repost = undefined + }, + () => this.rootStore.agent.deleteRepost(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) + 1 + this.post.viewer!.repost = 'pending' + }, + () => this.rootStore.agent.repost(this.post.uri, this.post.cid), + res => { + this.post.viewer!.repost = res.uri + }, ) - runInAction(() => { - this.post.repostCount = this.post.repostCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.repostCount++ - this.post.viewer.repost = res.uri - }) } } diff --git a/src/state/models/feeds/posts.ts b/src/state/models/feeds/posts.ts index c8245394..e3328c71 100644 --- a/src/state/models/feeds/posts.ts +++ b/src/state/models/feeds/posts.ts @@ -19,6 +19,7 @@ import { mergePosts, } from 'lib/api/build-suggested-posts' import {FeedTuner, FeedViewPostsSlice} from 'lib/api/feed-manip' +import {updateDataOptimistically} from 'lib/async/revertible' type FeedViewPost = AppBskyFeedDefs.FeedViewPost type ReasonRepost = AppBskyFeedDefs.ReasonRepost @@ -91,45 +92,56 @@ export class PostsFeedItemModel { } async toggleLike() { - if (this.post.viewer?.like) { - await this.rootStore.agent.deleteLike(this.post.viewer.like) - runInAction(() => { - this.post.likeCount = this.post.likeCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.likeCount-- - this.post.viewer.like = undefined - }) + this.post.viewer = this.post.viewer || {} + if (this.post.viewer.like) { + const url = this.post.viewer.like + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) - 1 + this.post.viewer!.like = undefined + }, + () => this.rootStore.agent.deleteLike(url), + ) } else { - const res = await this.rootStore.agent.like(this.post.uri, this.post.cid) - runInAction(() => { - this.post.likeCount = this.post.likeCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.likeCount++ - this.post.viewer.like = res.uri - }) + await updateDataOptimistically( + this.post, + () => { + this.post.likeCount = (this.post.likeCount || 0) + 1 + this.post.viewer!.like = 'pending' + }, + () => this.rootStore.agent.like(this.post.uri, this.post.cid), + res => { + this.post.viewer!.like = res.uri + }, + ) } } async toggleRepost() { + this.post.viewer = this.post.viewer || {} if (this.post.viewer?.repost) { - await this.rootStore.agent.deleteRepost(this.post.viewer.repost) - runInAction(() => { - this.post.repostCount = this.post.repostCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.repostCount-- - this.post.viewer.repost = undefined - }) - } else { - const res = await this.rootStore.agent.repost( - this.post.uri, - this.post.cid, + const url = this.post.viewer.repost + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) - 1 + this.post.viewer!.repost = undefined + }, + () => this.rootStore.agent.deleteRepost(url), + ) + } else { + await updateDataOptimistically( + this.post, + () => { + this.post.repostCount = (this.post.repostCount || 0) + 1 + this.post.viewer!.repost = 'pending' + }, + () => this.rootStore.agent.repost(this.post.uri, this.post.cid), + res => { + this.post.viewer!.repost = res.uri + }, ) - runInAction(() => { - this.post.repostCount = this.post.repostCount || 0 - this.post.viewer = this.post.viewer || {} - this.post.repostCount++ - this.post.viewer.repost = res.uri - }) } } diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx index 4497e705..6441d3c7 100644 --- a/src/view/com/util/PostCtrls.tsx +++ b/src/view/com/util/PostCtrls.tsx @@ -103,8 +103,6 @@ export function PostCtrls(opts: PostCtrlsOpts) { }), [theme], ) as StyleProp - const [repostMod, setRepostMod] = React.useState(0) - const [likeMod, setLikeMod] = React.useState(0) // DISABLED see #135 // const repostRef = React.useRef(null) // const likeRef = React.useRef(null) @@ -112,11 +110,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { store.shell.closeModal() if (!opts.isReposted) { ReactNativeHapticFeedback.trigger('impactMedium') - setRepostMod(1) - opts - .onPressToggleRepost() - .catch(_e => undefined) - .then(() => setRepostMod(0)) + opts.onPressToggleRepost().catch(_e => undefined) // DISABLED see #135 // repostRef.current?.trigger( // {start: ctrlAnimStart, style: ctrlAnimStyle}, @@ -126,11 +120,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { // }, // ) } else { - setRepostMod(-1) - opts - .onPressToggleRepost() - .catch(_e => undefined) - .then(() => setRepostMod(0)) + opts.onPressToggleRepost().catch(_e => undefined) } } @@ -157,14 +147,10 @@ export function PostCtrls(opts: PostCtrlsOpts) { }) } - const onPressToggleLikeWrapper = () => { + const onPressToggleLikeWrapper = async () => { if (!opts.isLiked) { ReactNativeHapticFeedback.trigger('impactMedium') - setLikeMod(1) - opts - .onPressToggleLike() - .catch(_e => undefined) - .then(() => setLikeMod(0)) + await opts.onPressToggleLike().catch(_e => undefined) // DISABLED see #135 // likeRef.current?.trigger( // {start: ctrlAnimStart, style: ctrlAnimStyle}, @@ -173,12 +159,10 @@ export function PostCtrls(opts: PostCtrlsOpts) { // setLikeMod(0) // }, // ) + // setIsLikedPressed(false) } else { - setLikeMod(-1) - opts - .onPressToggleLike() - .catch(_e => undefined) - .then(() => setLikeMod(0)) + await opts.onPressToggleLike().catch(_e => undefined) + // setIsLikedPressed(false) } } @@ -210,35 +194,22 @@ export function PostCtrls(opts: PostCtrlsOpts) { style={styles.ctrl}> 0 + opts.isReposted ? (styles.ctrlIconReposted as StyleProp) : defaultCtrlColor } strokeWidth={2.4} size={opts.big ? 24 : 20} /> - { - undefined /*DISABLED see #135 - - */ - } {typeof opts.repostCount !== 'undefined' ? ( 0 + opts.isReposted ? [s.bold, s.green3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.repostCount + repostMod} + {opts.repostCount} ) : undefined} @@ -249,7 +220,7 @@ export function PostCtrls(opts: PostCtrlsOpts) { style={styles.ctrl} hitSlop={HITSLOP} onPress={onPressToggleLikeWrapper}> - {opts.isLiked || likeMod > 0 ? ( + {opts.isLiked ? ( } size={opts.big ? 22 : 16} @@ -261,34 +232,15 @@ export function PostCtrls(opts: PostCtrlsOpts) { size={opts.big ? 20 : 16} /> )} - { - undefined /*DISABLED see #135 - {opts.isLiked || likeMod > 0 ? ( - - ) : ( - - )} - */ - } {typeof opts.likeCount !== 'undefined' ? ( 0 + opts.isLiked ? [s.bold, s.red3, s.f15, s.ml5] : [defaultCtrlColor, s.f15, s.ml5] }> - {opts.likeCount + likeMod} + {opts.likeCount} ) : undefined} diff --git a/yarn.lock b/yarn.lock index 8685a0d4..f1cb70cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4540,6 +4540,13 @@ dependencies: "@types/lodash" "*" +"@types/lodash.set@^4.3.7": + version "4.3.7" + resolved "https://registry.yarnpkg.com/@types/lodash.set/-/lodash.set-4.3.7.tgz#784fccea3fbef4d0949d1897a780f592da700942" + integrity sha512-bS5Wkg/nrT82YUfkNYPSccFrNZRL+irl7Yt4iM6OTSQ0VZJED2oUIVm15NkNtUAQ8SRhCe+axqERUV6MJgkeEg== + dependencies: + "@types/lodash" "*" + "@types/lodash.shuffle@^4.2.7": version "4.2.7" resolved "https://registry.yarnpkg.com/@types/lodash.shuffle/-/lodash.shuffle-4.2.7.tgz#b714d829af948a266b0df1477d629c70de2f4c72" @@ -11767,6 +11774,11 @@ lodash.samplesize@^4.2.0: resolved "https://registry.yarnpkg.com/lodash.samplesize/-/lodash.samplesize-4.2.0.tgz#460762fbb2b342290517499e90d51586db465ff9" integrity sha512-1ZhKV7/nuISuaQdxfCqrs4HHxXIYN+0Z4f7NMQn2PHkxFZJGavJQ1j/paxyJnLJmN2ZamNN6SMepneV+dCgQTA== +lodash.set@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" + integrity sha512-4hNPN5jlm/N/HLMCO43v8BXKq9Z7QdAGc/VGrRD61w8gN9g/6jF9A4L1pbUgBLCffi0w9VsXfTOij5x8iTyFvg== + lodash.shuffle@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/lodash.shuffle/-/lodash.shuffle-4.2.0.tgz#145b5053cf875f6f5c2a33f48b6e9948c6ec7b4b" @@ -12514,6 +12526,11 @@ mobx-react-lite@^3.4.0: resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-3.4.3.tgz#3a4c22c30bfaa8b1b2aa48d12b2ba811c0947ab7" integrity sha512-NkJREyFTSUXR772Qaai51BnE1voWx56LOL80xG7qkZr6vo8vEaLF3sz1JNUVh+rxmUzxYaqOhfuxTfqUh0FXUg== +mobx-utils@^6.0.6: + version "6.0.6" + resolved "https://registry.yarnpkg.com/mobx-utils/-/mobx-utils-6.0.6.tgz#99a2e0d54e958e4c9de4b35729e0c3768b6afc43" + integrity sha512-lzJtxOWgj3Dp2HeXviInV3ZRY4YhThzRHXuy90oKXDH2g+ymJGIts4bdjb7NQuSi34V25cMZoQX7TkHJQuKLOQ== + mobx@^6.6.1: version "6.8.0" resolved "https://registry.yarnpkg.com/mobx/-/mobx-6.8.0.tgz#59051755fdb5c8a9f3f2e0a9b6abaf86bab7f843"