diff --git a/src/lib/custom-animations/CountWheel.tsx b/src/lib/custom-animations/CountWheel.tsx
new file mode 100644
index 00000000..dfa69791
--- /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(i18n, likeCount)
+ const formattedPrevCount = formatCount(i18n, 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 00000000..618dcb1a
--- /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(i18n, likeCount)
+ const formattedPrevCount = formatCount(i18n, 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 00000000..06d5c285
--- /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 00000000..c131dcf6
--- /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 00000000..0aebab57
--- /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 49c9229a..6a58a562 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -6,14 +6,6 @@ import {
View,
type ViewStyle,
} from 'react-native'
-import Animated, {
- Easing,
- interpolate,
- SharedValue,
- useAnimatedStyle,
- useSharedValue,
- withTiming,
-} from 'react-native-reanimated'
import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
@@ -31,8 +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 {isWeb} from '#/platform/detection'
import {Shadow} from '#/state/cache/types'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {
@@ -45,16 +35,13 @@ 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 {PlatformInfo} from '../../../../../modules/expo-bluesky-swiss-army'
import {PostDropdownBtn} from '../forms/PostDropdownBtn'
import {formatCount} from '../numeric/format'
import {Text} from '../text/Text'
@@ -120,17 +107,7 @@ let PostCtrls = ({
) as StyleProp
const likeValue = post.viewer?.like ? 1 : 0
- const likeIconAnimValue = useSharedValue(likeValue)
- const likeTextAnimValue = useSharedValue(likeValue)
const nextExpectedLikeValue = React.useRef(likeValue)
- React.useEffect(() => {
- // Catch nonlocal changes (e.g. shadow update) and always reflect them.
- if (likeValue !== nextExpectedLikeValue.current) {
- nextExpectedLikeValue.current = likeValue
- likeIconAnimValue.value = likeValue
- likeTextAnimValue.value = likeValue
- }
- }, [likeValue, likeIconAnimValue, likeTextAnimValue])
const onPressToggleLike = React.useCallback(async () => {
if (isBlocked) {
@@ -144,19 +121,6 @@ let PostCtrls = ({
try {
if (!post.viewer?.like) {
nextExpectedLikeValue.current = 1
- if (PlatformInfo.getIsReducedMotionEnabled()) {
- likeIconAnimValue.value = 1
- likeTextAnimValue.value = 1
- } else {
- likeIconAnimValue.value = withTiming(1, {
- duration: 400,
- easing: Easing.out(Easing.cubic),
- })
- likeTextAnimValue.value = withTiming(1, {
- duration: 400,
- easing: Easing.out(Easing.cubic),
- })
- }
playHaptic()
sendInteraction({
item: post.uri,
@@ -167,15 +131,6 @@ let PostCtrls = ({
await queueLike()
} else {
nextExpectedLikeValue.current = 0
- likeIconAnimValue.value = 0 // Intentionally not animated
- if (PlatformInfo.getIsReducedMotionEnabled()) {
- likeTextAnimValue.value = 0
- } else {
- likeTextAnimValue.value = withTiming(0, {
- duration: 400,
- easing: Easing.out(Easing.cubic),
- })
- }
await queueUnlike()
}
} catch (e: any) {
@@ -185,8 +140,6 @@ let PostCtrls = ({
}
}, [
_,
- likeIconAnimValue,
- likeTextAnimValue,
playHaptic,
post.uri,
post.viewer?.like,
@@ -291,8 +244,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,
],
@@ -364,13 +317,11 @@ let PostCtrls = ({
}
accessibilityHint=""
hitSlop={POST_CTRL_HITSLOP}>
-
+
@@ -450,194 +401,3 @@ let PostCtrls = ({
}
PostCtrls = memo(PostCtrls)
export {PostCtrls}
-
-function AnimatedLikeIcon({
- big,
- likeIconAnimValue,
- likeTextAnimValue,
- defaultCtrlColor,
- isLiked,
- likeCount,
-}: {
- big: boolean
- likeIconAnimValue: SharedValue
- likeTextAnimValue: SharedValue
- defaultCtrlColor: StyleProp
- isLiked: boolean
- likeCount: number
-}) {
- const t = useTheme()
- const {i18n} = useLingui()
- const likeStyle = useAnimatedStyle(() => ({
- transform: [
- {
- scale: interpolate(
- likeIconAnimValue.value,
- [0, 0.1, 0.4, 1],
- [1, 0.7, 1.2, 1],
- 'clamp',
- ),
- },
- ],
- }))
- const circle1Style = useAnimatedStyle(() => ({
- opacity: interpolate(
- likeIconAnimValue.value,
- [0, 0.1, 0.95, 1],
- [0, 0.4, 0.4, 0],
- 'clamp',
- ),
- transform: [
- {
- scale: interpolate(
- likeIconAnimValue.value,
- [0, 0.4, 1],
- [0, 1.5, 1.5],
- 'clamp',
- ),
- },
- ],
- }))
- const circle2Style = useAnimatedStyle(() => ({
- opacity: interpolate(
- likeIconAnimValue.value,
- [0, 0.1, 0.95, 1],
- [0, 1, 1, 0],
- 'clamp',
- ),
- transform: [
- {
- scale: interpolate(
- likeIconAnimValue.value,
- [0, 0.4, 1],
- [0, 0, 1.5],
- 'clamp',
- ),
- },
- ],
- }))
- const countStyle = useAnimatedStyle(() => ({
- transform: [
- {
- translateY: interpolate(
- likeTextAnimValue.value,
- [0, 1],
- [0, big ? -22 : -18],
- 'clamp',
- ),
- },
- ],
- }))
-
- const prevFormattedCount = formatCount(
- i18n,
- isLiked ? likeCount - 1 : likeCount,
- )
- const nextFormattedCount = formatCount(
- i18n,
- isLiked ? likeCount : likeCount + 1,
- )
- const shouldRollLike =
- prevFormattedCount !== nextFormattedCount && prevFormattedCount !== '0'
-
- return (
- <>
-
-
-
-
- {isLiked ? (
-
- ) : (
-
- )}
-
-
-
-
- {likeCount > 0 ? formatCount(i18n, likeCount) : ''}
-
-
-
- {prevFormattedCount}
-
-
- {nextFormattedCount}
-
-
-
- >
- )
-}