about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorAnsh <anshnanda10@gmail.com>2023-04-19 13:58:24 -0700
committerGitHub <noreply@github.com>2023-04-19 15:58:24 -0500
commit1472bd4f173a483e5f1a91514244fecaec23808f (patch)
treefc33a3e90d4024939ef022a2c468f7b58df5fc37 /src
parentbe83d2933cfce1035573bc14108a87451cf48c2d (diff)
downloadvoidsky-1472bd4f173a483e5f1a91514244fecaec23808f.tar.zst
#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 <pfrazee@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/lib/async/revertible.ts52
-rw-r--r--src/state/models/content/post-thread.ts74
-rw-r--r--src/state/models/feeds/posts.ts74
-rw-r--r--src/view/com/util/PostCtrls.tsx74
4 files changed, 151 insertions, 123 deletions
diff --git a/src/lib/async/revertible.ts b/src/lib/async/revertible.ts
new file mode 100644
index 000000000..3c8e3e8f9
--- /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<any>()
+
+export const updateDataOptimistically = async <
+  T extends Record<string, any>,
+  U,
+>(
+  model: T,
+  preUpdate: () => void,
+  serverUpdate: () => Promise<U>,
+  postUpdate?: (res: U) => void,
+): Promise<void> => {
+  if (ongoingActions.has(model)) {
+    return
+  }
+  ongoingActions.add(model)
+
+  const prevState: Map<string, any> = new Map<string, any>()
+  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 d3e773673..794beae20 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<string> {
   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
-      })
+      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 {
-      const res = await this.rootStore.agent.repost(
-        this.post.uri,
-        this.post.cid,
+      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 c82453946..e3328c71a 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
-      })
+      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 {
-      const res = await this.rootStore.agent.repost(
-        this.post.uri,
-        this.post.cid,
+      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 4497e7058..6441d3c77 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<ViewStyle>
-  const [repostMod, setRepostMod] = React.useState<number>(0)
-  const [likeMod, setLikeMod] = React.useState<number>(0)
   // DISABLED see #135
   // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
   // const likeRef = React.useRef<TriggerableAnimatedRef | null>(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}>
           <RepostIcon
             style={
-              opts.isReposted || repostMod > 0
+              opts.isReposted
                 ? (styles.ctrlIconReposted as StyleProp<ViewStyle>)
                 : defaultCtrlColor
             }
             strokeWidth={2.4}
             size={opts.big ? 24 : 20}
           />
-          {
-            undefined /*DISABLED see #135 <TriggerableAnimated ref={repostRef}>
-            <RepostIcon
-              style={
-                (opts.isReposted
-                  ? styles.ctrlIconReposted
-                  : defaultCtrlColor) as ViewStyle
-              }
-              strokeWidth={2.4}
-              size={opts.big ? 24 : 20}
-            />
-            </TriggerableAnimated>*/
-          }
           {typeof opts.repostCount !== 'undefined' ? (
             <Text
               testID="repostCount"
               style={
-                opts.isReposted || repostMod > 0
+                opts.isReposted
                   ? [s.bold, s.green3, s.f15, s.ml5]
                   : [defaultCtrlColor, s.f15, s.ml5]
               }>
-              {opts.repostCount + repostMod}
+              {opts.repostCount}
             </Text>
           ) : undefined}
         </TouchableOpacity>
@@ -249,7 +220,7 @@ export function PostCtrls(opts: PostCtrlsOpts) {
           style={styles.ctrl}
           hitSlop={HITSLOP}
           onPress={onPressToggleLikeWrapper}>
-          {opts.isLiked || likeMod > 0 ? (
+          {opts.isLiked ? (
             <HeartIconSolid
               style={styles.ctrlIconLiked as StyleProp<ViewStyle>}
               size={opts.big ? 22 : 16}
@@ -261,34 +232,15 @@ export function PostCtrls(opts: PostCtrlsOpts) {
               size={opts.big ? 20 : 16}
             />
           )}
-          {
-            undefined /*DISABLED see #135 <TriggerableAnimated ref={likeRef}>
-            {opts.isLiked || likeMod > 0 ? (
-              <HeartIconSolid
-                style={styles.ctrlIconLiked as ViewStyle}
-                size={opts.big ? 22 : 16}
-              />
-            ) : (
-              <HeartIcon
-                style={[
-                  defaultCtrlColor as ViewStyle,
-                  opts.big ? styles.mt1 : undefined,
-                ]}
-                strokeWidth={3}
-                size={opts.big ? 20 : 16}
-              />
-            )}
-            </TriggerableAnimated>*/
-          }
           {typeof opts.likeCount !== 'undefined' ? (
             <Text
               testID="likeCount"
               style={
-                opts.isLiked || likeMod > 0
+                opts.isLiked
                   ? [s.bold, s.red3, s.f15, s.ml5]
                   : [defaultCtrlColor, s.f15, s.ml5]
               }>
-              {opts.likeCount + likeMod}
+              {opts.likeCount}
             </Text>
           ) : undefined}
         </TouchableOpacity>