diff --git a/src/lib/custom-animations/GestureActionView.tsx b/src/lib/custom-animations/GestureActionView.tsx
new file mode 100644
index 0000000000..d1dc973316
--- /dev/null
+++ b/src/lib/custom-animations/GestureActionView.tsx
@@ -0,0 +1,397 @@
+import React from 'react'
+import {ColorValue, Dimensions, StyleSheet, View} from 'react-native'
+import {Gesture, GestureDetector} from 'react-native-gesture-handler'
+import Animated, {
+ clamp,
+ interpolate,
+ interpolateColor,
+ runOnJS,
+ useAnimatedReaction,
+ useAnimatedStyle,
+ useDerivedValue,
+ useSharedValue,
+ withSequence,
+ withTiming,
+} from 'react-native-reanimated'
+
+import {useHaptics} from 'lib/haptics'
+import absoluteFill = StyleSheet.absoluteFill
+
+interface GestureAction {
+ color: ColorValue
+ action: () => void
+ threshold: number
+ icon: React.ElementType
+}
+
+interface GestureActions {
+ leftFirst?: GestureAction
+ leftSecond?: GestureAction
+ rightFirst?: GestureAction
+ rightSecond?: GestureAction
+}
+
+const MAX_WIDTH = Dimensions.get('screen').width
+const ICON_SIZE = 42
+
+export function GestureActionView({
+ children,
+ actions,
+}: {
+ children: React.ReactNode
+ actions: GestureActions
+}) {
+ if (
+ (actions.leftSecond && !actions.leftFirst) ||
+ (actions.rightSecond && !actions.rightFirst)
+ ) {
+ throw new Error(
+ 'You must provide the first action before the second action',
+ )
+ }
+
+ const [activeAction, setActiveAction] = React.useState<
+ 'leftFirst' | 'leftSecond' | 'rightFirst' | 'rightSecond' | null
+ >(null)
+
+ const haptic = useHaptics()
+
+ const transX = useSharedValue(0)
+ const clampedTransX = useDerivedValue(() => {
+ const min = actions.leftFirst ? -MAX_WIDTH : 0
+ const max = actions.rightFirst ? MAX_WIDTH : 0
+ return clamp(transX.value, min, max)
+ })
+ const iconScale = useSharedValue(1)
+
+ const isActive = useSharedValue(false)
+ const hitFirst = useSharedValue(false)
+ const hitSecond = useSharedValue(false)
+
+ const runPopAnimation = () => {
+ 'worklet'
+ iconScale.value = withSequence(
+ withTiming(1.2, {duration: 175}),
+ withTiming(1, {duration: 100}),
+ )
+ }
+
+ useAnimatedReaction(
+ () => transX,
+ () => {
+ if (transX.value === 0) {
+ runOnJS(setActiveAction)(null)
+ } else if (transX.value < 0) {
+ if (
+ actions.leftSecond &&
+ transX.value <= -actions.leftSecond.threshold
+ ) {
+ if (activeAction !== 'leftSecond') {
+ runOnJS(setActiveAction)('leftSecond')
+ }
+ } else if (activeAction !== 'leftFirst') {
+ runOnJS(setActiveAction)('leftFirst')
+ }
+ } else if (transX.value > 0) {
+ if (
+ actions.rightSecond &&
+ transX.value > actions.rightSecond.threshold
+ ) {
+ if (activeAction !== 'rightSecond') {
+ runOnJS(setActiveAction)('rightSecond')
+ }
+ } else if (activeAction !== 'rightFirst') {
+ runOnJS(setActiveAction)('rightFirst')
+ }
+ }
+ },
+ )
+
+ const panGesture = Gesture.Pan()
+ .activeOffsetX([-10, 10])
+ // Absurdly high value so it doesn't interfere with the pan gestures above (i.e., scroll)
+ // reanimated doesn't offer great support for disabling y/x axes :/
+ .activeOffsetY([-200, 200])
+ .onStart(() => {
+ isActive.value = true
+ })
+ .onChange(e => {
+ transX.value = e.translationX
+
+ if (e.translationX < 0) {
+ // Left side
+ if (actions.leftSecond) {
+ if (
+ e.translationX <= -actions.leftSecond.threshold &&
+ !hitSecond.value
+ ) {
+ runPopAnimation()
+ runOnJS(haptic)()
+ hitSecond.value = true
+ } else if (
+ hitSecond.value &&
+ e.translationX > -actions.leftSecond.threshold
+ ) {
+ runPopAnimation()
+ hitSecond.value = false
+ }
+ }
+
+ if (!hitSecond.value && actions.leftFirst) {
+ if (
+ e.translationX <= -actions.leftFirst.threshold &&
+ !hitFirst.value
+ ) {
+ runPopAnimation()
+ runOnJS(haptic)()
+ hitFirst.value = true
+ } else if (
+ hitFirst.value &&
+ e.translationX > -actions.leftFirst.threshold
+ ) {
+ hitFirst.value = false
+ }
+ }
+ } else if (e.translationX > 0) {
+ // Right side
+ if (actions.rightSecond) {
+ if (
+ e.translationX >= actions.rightSecond.threshold &&
+ !hitSecond.value
+ ) {
+ runPopAnimation()
+ runOnJS(haptic)()
+ hitSecond.value = true
+ } else if (
+ hitSecond.value &&
+ e.translationX < actions.rightSecond.threshold
+ ) {
+ runPopAnimation()
+ hitSecond.value = false
+ }
+ }
+
+ if (!hitSecond.value && actions.rightFirst) {
+ if (
+ e.translationX >= actions.rightFirst.threshold &&
+ !hitFirst.value
+ ) {
+ runPopAnimation()
+ runOnJS(haptic)()
+ hitFirst.value = true
+ } else if (
+ hitFirst.value &&
+ e.translationX < actions.rightFirst.threshold
+ ) {
+ hitFirst.value = false
+ }
+ }
+ }
+ })
+ .onEnd(e => {
+ if (e.translationX < 0) {
+ if (hitSecond.value && actions.leftSecond) {
+ runOnJS(actions.leftSecond.action)()
+ } else if (hitFirst.value && actions.leftFirst) {
+ runOnJS(actions.leftFirst.action)()
+ }
+ } else if (e.translationX > 0) {
+ if (hitSecond.value && actions.rightSecond) {
+ runOnJS(actions.rightSecond.action)()
+ } else if (hitSecond.value && actions.rightFirst) {
+ runOnJS(actions.rightFirst.action)()
+ }
+ }
+ transX.value = withTiming(0, {duration: 200})
+ hitFirst.value = false
+ hitSecond.value = false
+ isActive.value = false
+ })
+
+ const composedGesture = Gesture.Simultaneous(panGesture)
+
+ const animatedSliderStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{translateX: clampedTransX.value}],
+ }
+ })
+
+ const leftSideInterpolation = React.useMemo(() => {
+ return createInterpolation({
+ firstColor: actions.leftFirst?.color,
+ secondColor: actions.leftSecond?.color,
+ firstThreshold: actions.leftFirst?.threshold,
+ secondThreshold: actions.leftSecond?.threshold,
+ side: 'left',
+ })
+ }, [actions.leftFirst, actions.leftSecond])
+
+ const rightSideInterpolation = React.useMemo(() => {
+ return createInterpolation({
+ firstColor: actions.rightFirst?.color,
+ secondColor: actions.rightSecond?.color,
+ firstThreshold: actions.rightFirst?.threshold,
+ secondThreshold: actions.rightSecond?.threshold,
+ side: 'right',
+ })
+ }, [actions.rightFirst, actions.rightSecond])
+
+ const interpolation = React.useMemo<{
+ inputRange: number[]
+ outputRange: ColorValue[]
+ }>(() => {
+ if (!actions.leftFirst) {
+ return rightSideInterpolation!
+ } else if (!actions.rightFirst) {
+ return leftSideInterpolation!
+ } else {
+ return {
+ inputRange: [
+ ...leftSideInterpolation.inputRange,
+ ...rightSideInterpolation.inputRange,
+ ],
+ outputRange: [
+ ...leftSideInterpolation.outputRange,
+ ...rightSideInterpolation.outputRange,
+ ],
+ }
+ }
+ }, [
+ leftSideInterpolation,
+ rightSideInterpolation,
+ actions.leftFirst,
+ actions.rightFirst,
+ ])
+
+ const animatedBackgroundStyle = useAnimatedStyle(() => {
+ return {
+ backgroundColor: interpolateColor(
+ clampedTransX.value,
+ interpolation.inputRange,
+ // @ts-expect-error - Weird type expected by reanimated, but this is okay
+ interpolation.outputRange,
+ ),
+ }
+ })
+
+ const animatedIconStyle = useAnimatedStyle(() => {
+ const absTransX = Math.abs(clampedTransX.value)
+ return {
+ opacity: interpolate(absTransX, [0, 75], [0.15, 1]),
+ transform: [{scale: iconScale.value}],
+ }
+ })
+
+ return (
+
+
+
+
+
+ {activeAction === 'leftFirst' && actions.leftFirst?.icon ? (
+
+ ) : activeAction === 'leftSecond' && actions.leftSecond?.icon ? (
+
+ ) : activeAction === 'rightFirst' && actions.rightFirst?.icon ? (
+
+ ) : activeAction === 'rightSecond' &&
+ actions.rightSecond?.icon ? (
+
+ ) : null}
+
+
+
+ {children}
+
+
+ )
+}
+
+function createInterpolation({
+ firstColor,
+ secondColor,
+ firstThreshold,
+ secondThreshold,
+ side,
+}: {
+ firstColor?: ColorValue
+ secondColor?: ColorValue
+ firstThreshold?: number
+ secondThreshold?: number
+ side: 'left' | 'right'
+}): {
+ inputRange: number[]
+ outputRange: ColorValue[]
+} {
+ if ((secondThreshold && !secondColor) || (!secondThreshold && secondColor)) {
+ throw new Error(
+ 'You must provide a second color if you provide a second threshold',
+ )
+ }
+
+ if (!firstThreshold) {
+ return {
+ inputRange: [0],
+ outputRange: ['transparent'],
+ }
+ }
+
+ const offset = side === 'left' ? -15 : 15
+
+ if (side === 'left') {
+ firstThreshold = -firstThreshold
+
+ if (secondThreshold) {
+ secondThreshold = -secondThreshold
+ }
+ }
+
+ let res
+ if (secondThreshold) {
+ res = {
+ inputRange: [0, firstThreshold + offset, secondThreshold + offset],
+ outputRange: ['transparent', firstColor!, secondColor!],
+ }
+ } else {
+ res = {
+ inputRange: [0, firstThreshold + offset],
+ outputRange: ['transparent', firstColor!],
+ }
+ }
+
+ if (side === 'left') {
+ res = {
+ inputRange: res.inputRange.toReversed(),
+ outputRange: res.outputRange.toReversed(),
+ }
+ }
+
+ return res
+}
diff --git a/src/lib/custom-animations/GestureActionView.web.tsx b/src/lib/custom-animations/GestureActionView.web.tsx
new file mode 100644
index 0000000000..23179ac4b6
--- /dev/null
+++ b/src/lib/custom-animations/GestureActionView.web.tsx
@@ -0,0 +1,5 @@
+import React from 'react'
+
+export function SwipeActionItem({children}: {children: React.ReactNode}) {
+ return children
+}
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 8cd6e70be2..d24a540e1b 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -19,6 +19,7 @@ import {ThreadPost} from '#/state/queries/post-thread'
import {useComposerControls} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {MAX_POST_LINES} from 'lib/constants'
+import {GestureActionView} from 'lib/custom-animations/GestureActionView'
import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
import {makeProfileLink} from 'lib/routes/links'
@@ -28,9 +29,12 @@ import {countLines} from 'lib/strings/helpers'
import {niceDate} from 'lib/strings/time'
import {s} from 'lib/styles'
import {isWeb} from 'platform/detection'
+import {usePostLikeMutationQueue} from 'state/queries/post'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
-import {atoms as a} from '#/alf'
+import {atoms as a, useTheme} from '#/alf'
+import {Bubble_Stroke2_Corner2_Rounded} from '#/components/icons/Bubble'
+import {Heart2_Filled_Stroke2_Corner0_Rounded} from '#/components/icons/Heart2'
import {AppModerationCause} from '#/components/Pills'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../components/moderation/ContentHider'
@@ -181,12 +185,17 @@ let PostThreadItemLoaded = ({
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const pal = usePalette('default')
+ const t = useTheme()
const {_, i18n} = useLingui()
const langPrefs = useLanguagePrefs()
const {openComposer} = useComposerControls()
const [limitLines, setLimitLines] = React.useState(
() => countLines(richText?.text) >= MAX_POST_LINES,
)
+ const [queueLike, queueUnlike] = usePostLikeMutationQueue(
+ post,
+ 'PostThreadItem',
+ )
const {currentAccount} = useSession()
const rootUri = record.reply?.root?.uri || post.uri
const postHref = React.useMemo(() => {
@@ -465,178 +474,204 @@ let PostThreadItemLoaded = ({
const isThreadedChildAdjacentBot =
isThreadedChild && nextPost?.ctx.depth === depth
return (
-
-
-
-
- {!isThreadedChild && showParentReplyLine && (
-
- )}
-
-
-
-
- {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
- {!isThreadedChild && (
-
-
-
- {showChildReplyLine && (
+ {
+ if (!post.viewer?.like) {
+ queueLike()
+ } else {
+ queueUnlike()
+ }
+ },
+ threshold: 85,
+ icon: Heart2_Filled_Stroke2_Corner0_Rounded,
+ },
+ leftSecond: {
+ color: t.palette.primary_500,
+ action: () => {
+ onPressReply()
+ },
+ threshold: 170,
+ icon: Bubble_Stroke2_Corner2_Rounded,
+ },
+ }}>
+
+
+
+
+ {!isThreadedChild && showParentReplyLine && (
)}
- )}
+
-
-
-
- {richText?.text ? (
-
-
-
- ) : undefined}
- {limitLines ? (
-
- ) : undefined}
- {post.embed && (
-
-
+ {/* If we are in threaded mode, the avatar is rendered in PostMeta */}
+ {!isThreadedChild && (
+
+
+
+ {showChildReplyLine && (
+
+ )}
)}
-
+
+
+
+
+
+ {richText?.text ? (
+
+
+
+ ) : undefined}
+ {limitLines ? (
+
+ ) : undefined}
+ {post.embed && (
+
+
+
+ )}
+
+
-
- {hasMore ? (
-
-
- More
-
-
-
- ) : undefined}
-
-
+ {hasMore ? (
+
+
+ More
+
+
+
+ ) : undefined}
+
+
+
)
}
}