#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>
This commit is contained in:
Ansh 2023-04-19 13:58:24 -07:00 committed by GitHub
parent be83d2933c
commit 1472bd4f17
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 173 additions and 125 deletions

View file

@ -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)
}
}

View file

@ -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
})
} 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
})
}
}

View file

@ -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
})
}
}

View file

@ -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>