about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json3
-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
-rw-r--r--yarn.lock17
6 files changed, 171 insertions, 123 deletions
diff --git a/package.json b/package.json
index 23d7149de..57a6d02ac 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 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>
diff --git a/yarn.lock b/yarn.lock
index 8685a0d4e..f1cb70cf8 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"