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