* 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:
parent
be83d2933c
commit
1472bd4f17
6 changed files with 173 additions and 125 deletions
52
src/lib/async/revertible.ts
Normal file
52
src/lib/async/revertible.ts
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue