diff --git a/src/lib/custom-animations/CountWheel.tsx b/src/lib/custom-animations/CountWheel.tsx
new file mode 100644
index 0000000000..ae55abe9e5
--- /dev/null
+++ b/src/lib/custom-animations/CountWheel.tsx
@@ -0,0 +1,177 @@
+import React from 'react'
+import {View} from 'react-native'
+import Animated, {
+ Easing,
+ LayoutAnimationConfig,
+ useReducedMotion,
+ withTiming,
+} from 'react-native-reanimated'
+import {i18n} from '@lingui/core'
+
+import {decideShouldRoll} from 'lib/custom-animations/util'
+import {s} from 'lib/styles'
+import {formatCount} from 'view/com/util/numeric/format'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+
+const animationConfig = {
+ duration: 400,
+ easing: Easing.out(Easing.cubic),
+}
+
+function EnteringUp() {
+ 'worklet'
+ const animations = {
+ opacity: withTiming(1, animationConfig),
+ transform: [{translateY: withTiming(0, animationConfig)}],
+ }
+ const initialValues = {
+ opacity: 0,
+ transform: [{translateY: 18}],
+ }
+ return {
+ animations,
+ initialValues,
+ }
+}
+
+function EnteringDown() {
+ 'worklet'
+ const animations = {
+ opacity: withTiming(1, animationConfig),
+ transform: [{translateY: withTiming(0, animationConfig)}],
+ }
+ const initialValues = {
+ opacity: 0,
+ transform: [{translateY: -18}],
+ }
+ return {
+ animations,
+ initialValues,
+ }
+}
+
+function ExitingUp() {
+ 'worklet'
+ const animations = {
+ opacity: withTiming(0, animationConfig),
+ transform: [
+ {
+ translateY: withTiming(-18, animationConfig),
+ },
+ ],
+ }
+ const initialValues = {
+ opacity: 1,
+ transform: [{translateY: 0}],
+ }
+ return {
+ animations,
+ initialValues,
+ }
+}
+
+function ExitingDown() {
+ 'worklet'
+ const animations = {
+ opacity: withTiming(0, animationConfig),
+ transform: [{translateY: withTiming(18, animationConfig)}],
+ }
+ const initialValues = {
+ opacity: 1,
+ transform: [{translateY: 0}],
+ }
+ return {
+ animations,
+ initialValues,
+ }
+}
+
+export function CountWheel({
+ likeCount,
+ big,
+ isLiked,
+}: {
+ likeCount: number
+ big?: boolean
+ isLiked: boolean
+}) {
+ const t = useTheme()
+ const shouldAnimate = !useReducedMotion()
+ const shouldRoll = decideShouldRoll(isLiked, likeCount)
+
+ // Incrementing the key will cause the `Animated.View` to re-render, with the newly selected entering/exiting
+ // animation
+ // The initial entering/exiting animations will get skipped, since these will happen on screen mounts and would
+ // be unnecessary
+ const [key, setKey] = React.useState(0)
+ const [prevCount, setPrevCount] = React.useState(likeCount)
+ const prevIsLiked = React.useRef(isLiked)
+ const formattedCount = formatCount(likeCount)
+ const formattedPrevCount = formatCount(prevCount)
+
+ React.useEffect(() => {
+ if (isLiked === prevIsLiked.current) {
+ return
+ }
+
+ const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
+ setKey(prev => prev + 1)
+ setPrevCount(newPrevCount)
+ prevIsLiked.current = isLiked
+ }, [isLiked, likeCount])
+
+ const enteringAnimation =
+ shouldAnimate && shouldRoll
+ ? isLiked
+ ? EnteringUp
+ : EnteringDown
+ : undefined
+ const exitingAnimation =
+ shouldAnimate && shouldRoll
+ ? isLiked
+ ? ExitingUp
+ : ExitingDown
+ : undefined
+
+ return (
+
+ {likeCount > 0 ? (
+
+
+
+ {formattedCount}
+
+
+ {shouldAnimate ? (
+
+
+ {formattedPrevCount}
+
+
+ ) : null}
+
+ ) : null}
+
+ )
+}
diff --git a/src/lib/custom-animations/CountWheel.web.tsx b/src/lib/custom-animations/CountWheel.web.tsx
new file mode 100644
index 0000000000..77f21e755f
--- /dev/null
+++ b/src/lib/custom-animations/CountWheel.web.tsx
@@ -0,0 +1,121 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useReducedMotion} from 'react-native-reanimated'
+import {i18n} from '@lingui/core'
+
+import {decideShouldRoll} from 'lib/custom-animations/util'
+import {s} from 'lib/styles'
+import {formatCount} from 'view/com/util/numeric/format'
+import {Text} from 'view/com/util/text/Text'
+import {atoms as a, useTheme} from '#/alf'
+
+const animationConfig = {
+ duration: 400,
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ fill: 'forwards' as FillMode,
+}
+
+const enteringUpKeyframe = [
+ {opacity: 0, transform: 'translateY(18px)'},
+ {opacity: 1, transform: 'translateY(0)'},
+]
+
+const enteringDownKeyframe = [
+ {opacity: 0, transform: 'translateY(-18px)'},
+ {opacity: 1, transform: 'translateY(0)'},
+]
+
+const exitingUpKeyframe = [
+ {opacity: 1, transform: 'translateY(0)'},
+ {opacity: 0, transform: 'translateY(-18px)'},
+]
+
+const exitingDownKeyframe = [
+ {opacity: 1, transform: 'translateY(0)'},
+ {opacity: 0, transform: 'translateY(18px)'},
+]
+
+export function CountWheel({
+ likeCount,
+ big,
+ isLiked,
+}: {
+ likeCount: number
+ big?: boolean
+ isLiked: boolean
+}) {
+ const t = useTheme()
+ const shouldAnimate = !useReducedMotion()
+ const shouldRoll = decideShouldRoll(isLiked, likeCount)
+
+ const countView = React.useRef(null)
+ const prevCountView = React.useRef(null)
+
+ const [prevCount, setPrevCount] = React.useState(likeCount)
+ const prevIsLiked = React.useRef(isLiked)
+ const formattedCount = formatCount(likeCount)
+ const formattedPrevCount = formatCount(prevCount)
+
+ React.useEffect(() => {
+ if (isLiked === prevIsLiked.current) {
+ return
+ }
+
+ const newPrevCount = isLiked ? likeCount - 1 : likeCount + 1
+ if (shouldAnimate && shouldRoll) {
+ countView.current?.animate?.(
+ isLiked ? enteringUpKeyframe : enteringDownKeyframe,
+ animationConfig,
+ )
+ prevCountView.current?.animate?.(
+ isLiked ? exitingUpKeyframe : exitingDownKeyframe,
+ animationConfig,
+ )
+ setPrevCount(newPrevCount)
+ }
+ prevIsLiked.current = isLiked
+ }, [isLiked, likeCount, shouldAnimate, shouldRoll])
+
+ if (likeCount < 1) {
+ return null
+ }
+
+ return (
+
+
+
+ {formattedCount}
+
+
+ {shouldAnimate ? (
+
+
+ {formattedPrevCount}
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/lib/custom-animations/LikeIcon.tsx b/src/lib/custom-animations/LikeIcon.tsx
new file mode 100644
index 0000000000..06d5c28508
--- /dev/null
+++ b/src/lib/custom-animations/LikeIcon.tsx
@@ -0,0 +1,139 @@
+import React from 'react'
+import {View} from 'react-native'
+import Animated, {
+ Keyframe,
+ LayoutAnimationConfig,
+ useReducedMotion,
+} from 'react-native-reanimated'
+
+import {s} from 'lib/styles'
+import {useTheme} from '#/alf'
+import {
+ Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
+ Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
+} from '#/components/icons/Heart2'
+
+const keyframe = new Keyframe({
+ 0: {
+ transform: [{scale: 1}],
+ },
+ 10: {
+ transform: [{scale: 0.7}],
+ },
+ 40: {
+ transform: [{scale: 1.2}],
+ },
+ 100: {
+ transform: [{scale: 1}],
+ },
+})
+
+const circle1Keyframe = new Keyframe({
+ 0: {
+ opacity: 0,
+ transform: [{scale: 0}],
+ },
+ 10: {
+ opacity: 0.4,
+ },
+ 40: {
+ transform: [{scale: 1.5}],
+ },
+ 95: {
+ opacity: 0.4,
+ },
+ 100: {
+ opacity: 0,
+ transform: [{scale: 1.5}],
+ },
+})
+
+const circle2Keyframe = new Keyframe({
+ 0: {
+ opacity: 0,
+ transform: [{scale: 0}],
+ },
+ 10: {
+ opacity: 1,
+ },
+ 40: {
+ transform: [{scale: 0}],
+ },
+ 95: {
+ opacity: 1,
+ },
+ 100: {
+ opacity: 0,
+ transform: [{scale: 1.5}],
+ },
+})
+
+export function AnimatedLikeIcon({
+ isLiked,
+ big,
+}: {
+ isLiked: boolean
+ big?: boolean
+}) {
+ const t = useTheme()
+ const size = big ? 22 : 18
+ const shouldAnimate = !useReducedMotion()
+
+ return (
+
+
+ {isLiked ? (
+
+
+
+ ) : (
+
+ )}
+ {isLiked ? (
+ <>
+
+
+ >
+ ) : null}
+
+
+ )
+}
diff --git a/src/lib/custom-animations/LikeIcon.web.tsx b/src/lib/custom-animations/LikeIcon.web.tsx
new file mode 100644
index 0000000000..c131dcf67d
--- /dev/null
+++ b/src/lib/custom-animations/LikeIcon.web.tsx
@@ -0,0 +1,115 @@
+import React from 'react'
+import {View} from 'react-native'
+import {useReducedMotion} from 'react-native-reanimated'
+
+import {s} from 'lib/styles'
+import {useTheme} from '#/alf'
+import {
+ Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
+ Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
+} from '#/components/icons/Heart2'
+
+const animationConfig = {
+ duration: 400,
+ easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
+ fill: 'forwards' as FillMode,
+}
+
+const keyframe = [
+ {transform: 'scale(1)'},
+ {transform: 'scale(0.7)'},
+ {transform: 'scale(1.2)'},
+ {transform: 'scale(1)'},
+]
+
+const circle1Keyframe = [
+ {opacity: 0, transform: 'scale(0)'},
+ {opacity: 0.4},
+ {transform: 'scale(1.5)'},
+ {opacity: 0.4},
+ {opacity: 0, transform: 'scale(1.5)'},
+]
+
+const circle2Keyframe = [
+ {opacity: 0, transform: 'scale(0)'},
+ {opacity: 1},
+ {transform: 'scale(0)'},
+ {opacity: 1},
+ {opacity: 0, transform: 'scale(1.5)'},
+]
+
+export function AnimatedLikeIcon({
+ isLiked,
+ big,
+}: {
+ isLiked: boolean
+ big?: boolean
+}) {
+ const t = useTheme()
+ const size = big ? 22 : 18
+ const shouldAnimate = !useReducedMotion()
+ const prevIsLiked = React.useRef(isLiked)
+
+ const likeIconRef = React.useRef(null)
+ const circle1Ref = React.useRef(null)
+ const circle2Ref = React.useRef(null)
+
+ React.useEffect(() => {
+ if (prevIsLiked.current === isLiked) {
+ return
+ }
+
+ if (shouldAnimate && isLiked) {
+ likeIconRef.current?.animate?.(keyframe, animationConfig)
+ circle1Ref.current?.animate?.(circle1Keyframe, animationConfig)
+ circle2Ref.current?.animate?.(circle2Keyframe, animationConfig)
+ }
+ prevIsLiked.current = isLiked
+ }, [shouldAnimate, isLiked])
+
+ return (
+
+ {isLiked ? (
+ // @ts-expect-error is div
+
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/src/lib/custom-animations/util.ts b/src/lib/custom-animations/util.ts
new file mode 100644
index 0000000000..0aebab57bb
--- /dev/null
+++ b/src/lib/custom-animations/util.ts
@@ -0,0 +1,21 @@
+// It should roll when:
+// - We're going from 1 to 0 (roll backwards)
+// - The count is anywhere between 1 and 999
+// - The count is going up and is a multiple of 100
+// - The count is going down and is 1 less than a multiple of 100
+export function decideShouldRoll(isSet: boolean, count: number) {
+ let shouldRoll = false
+ if (!isSet && count === 0) {
+ shouldRoll = true
+ } else if (count > 0 && count < 1000) {
+ shouldRoll = true
+ } else if (count > 0) {
+ const mod = count % 100
+ if (isSet && mod === 0) {
+ shouldRoll = true
+ } else if (!isSet && mod === 99) {
+ shouldRoll = true
+ }
+ }
+ return shouldRoll
+}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index a0cef8692d..cdcecfed06 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -23,7 +23,6 @@ import {makeProfileLink} from '#/lib/routes/links'
import {shareUrl} from '#/lib/sharing'
import {useGate} from '#/lib/statsig/statsig'
import {toShareUrl} from '#/lib/strings/url-helpers'
-import {s} from '#/lib/styles'
import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
@@ -36,14 +35,12 @@ import {
ProgressGuideAction,
useProgressGuideControls,
} from '#/state/shell/progress-guide'
+import {CountWheel} from 'lib/custom-animations/CountWheel'
+import {AnimatedLikeIcon} from 'lib/custom-animations/LikeIcon'
import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as ArrowOutOfBox} from '#/components/icons/ArrowOutOfBox'
import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble'
-import {
- Heart2_Filled_Stroke2_Corner0_Rounded as HeartIconFilled,
- Heart2_Stroke2_Corner0_Rounded as HeartIconOutline,
-} from '#/components/icons/Heart2'
import * as Prompt from '#/components/Prompt'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
@@ -104,9 +101,13 @@ let PostCtrls = ({
[t],
) as StyleProp
+ const likeValue = post.viewer?.like ? 1 : 0
+ const nextExpectedLikeValue = React.useRef(likeValue)
+
const onPressToggleLike = React.useCallback(async () => {
try {
if (!post.viewer?.like) {
+ nextExpectedLikeValue.current = 1
playHaptic()
sendInteraction({
item: post.uri,
@@ -116,6 +117,7 @@ let PostCtrls = ({
captureAction(ProgressGuideAction.Like)
await queueLike()
} else {
+ nextExpectedLikeValue.current = 0
await queueUnlike()
}
} catch (e: any) {
@@ -207,8 +209,8 @@ let PostCtrls = ({
a.gap_xs,
a.rounded_full,
a.flex_row,
- a.align_center,
a.justify_center,
+ a.align_center,
{padding: 5},
(pressed || hovered) && t.atoms.bg_contrast_25,
],
@@ -280,29 +282,12 @@ let PostCtrls = ({
}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
- {post.viewer?.like ? (
-
- ) : (
-
- )}
- {typeof post.likeCount !== 'undefined' && post.likeCount > 0 ? (
-
- {formatCount(post.likeCount)}
-
- ) : undefined}
+
+
{big && (