diff --git a/native/chat/swipeable-message.react.js b/native/chat/swipeable-message.react.js index bdaa01269..b876aa715 100644 --- a/native/chat/swipeable-message.react.js +++ b/native/chat/swipeable-message.react.js @@ -1,362 +1,366 @@ // @flow import { GestureHandlerRefContext } from '@react-navigation/stack'; import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { View } from 'react-native'; import { PanGestureHandler } from 'react-native-gesture-handler'; import Animated, { useAnimatedGestureHandler, useSharedValue, useAnimatedStyle, runOnJS, withSpring, interpolate, cancelAnimation, Extrapolate, type SharedValue, } from 'react-native-reanimated'; import type { IconProps } from 'react-native-vector-icons'; import tinycolor from 'tinycolor2'; import CommIcon from '../components/comm-icon.react'; import { colors } from '../themes/colors'; import type { ViewStyle } from '../types/styles'; import { useMessageListScreenWidth } from './composed-message-width'; const primaryThreshold = 40; const secondaryThreshold = 120; function dividePastDistance(value, distance, factor) { 'worklet'; const absValue = Math.abs(value); if (absValue < distance) { return value; } const absFactor = value >= 0 ? 1 : -1; return absFactor * (distance + (absValue - distance) / factor); } function makeSpringConfig(velocity) { 'worklet'; return { stiffness: 257.1370588235294, damping: 19.003038357561845, mass: 1, overshootClamping: true, restDisplacementThreshold: 0.001, restSpeedThreshold: 0.001, velocity, }; } function interpolateOpacityForViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [-20, -5], [1, 0], Extrapolate.CLAMP); } function interpolateOpacityForNonViewerPrimarySnake(translateX) { 'worklet'; return interpolate(translateX, [5, 20], [0, 1], Extrapolate.CLAMP); } function interpolateTranslateXForViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [-130, -120, -60, 0], [-130, -120, -5, 20]); } function interpolateTranslateXForNonViewerSecondarySnake(translateX) { 'worklet'; return interpolate(translateX, [0, 80, 120, 130], [0, 30, 120, 130]); } type SwipeSnakeProps = { +isViewer: boolean, +translateX: SharedValue, +color: string, +children: React.Element>>, +opacityInterpolator?: number => number, // must be worklet +translateXInterpolator?: number => number, // must be worklet }; function SwipeSnake( props: SwipeSnakeProps, ): React.Node { const { translateX, isViewer, opacityInterpolator, translateXInterpolator, } = props; const transformStyle = useAnimatedStyle(() => { const opacity = opacityInterpolator ? opacityInterpolator(translateX.value) : undefined; const translate = translateXInterpolator ? translateXInterpolator(translateX.value) : translateX.value; return { transform: [ { translateX: translate, }, ], opacity, }; }, [isViewer, translateXInterpolator, opacityInterpolator]); const animationPosition = isViewer ? styles.right0 : styles.left0; const animationContainerStyle = React.useMemo(() => { return [styles.animationContainer, animationPosition]; }, [animationPosition]); const iconPosition = isViewer ? styles.left0 : styles.right0; const swipeSnakeContainerStyle = React.useMemo(() => { return [styles.swipeSnakeContainer, transformStyle, iconPosition]; }, [transformStyle, iconPosition]); const iconAlign = isViewer ? styles.alignStart : styles.alignEnd; const screenWidth = useMessageListScreenWidth(); const { color } = props; const swipeSnakeStyle = React.useMemo(() => { return [ styles.swipeSnake, iconAlign, { width: screenWidth, backgroundColor: color, }, ]; }, [iconAlign, screenWidth, color]); const { children } = props; const iconColor = tinycolor(color).isDark() ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const coloredIcon = React.useMemo( () => React.cloneElement(children, { color: iconColor, }), [children, iconColor], ); return ( {coloredIcon} ); } type Props = { +triggerReply?: () => void, +triggerSidebar?: () => void, +isViewer: boolean, +messageBoxStyle: ViewStyle, +threadColor: string, +children: React.Node, }; function SwipeableMessage(props: Props): React.Node { const { isViewer, triggerReply, triggerSidebar } = props; const secondaryActionExists = triggerReply && triggerSidebar; const onPassPrimaryThreshold = React.useCallback(() => { const impactStrength = secondaryActionExists ? Haptics.ImpactFeedbackStyle.Medium : Haptics.ImpactFeedbackStyle.Heavy; Haptics.impactAsync(impactStrength); }, [secondaryActionExists]); const onPassSecondaryThreshold = React.useCallback(() => { if (secondaryActionExists) { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); } }, [secondaryActionExists]); const primaryAction = React.useCallback(() => { if (triggerReply) { triggerReply(); } else if (triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const secondaryAction = React.useCallback(() => { if (triggerReply && triggerSidebar) { triggerSidebar(); } }, [triggerReply, triggerSidebar]); const translateX = useSharedValue(0); const swipeEvent = useAnimatedGestureHandler( { onStart: (event, ctx) => { ctx.translationAtStart = translateX.value; cancelAnimation(translateX.value); }, onActive: (event, ctx) => { const translationX = ctx.translationAtStart + event.translationX; const baseActiveTranslation = isViewer ? Math.min(translationX, 0) : Math.max(translationX, 0); translateX.value = dividePastDistance( baseActiveTranslation, primaryThreshold, 2, ); const absValue = Math.abs(translateX.value); const pastPrimaryThreshold = absValue >= primaryThreshold; if (pastPrimaryThreshold && !ctx.prevPastPrimaryThreshold) { runOnJS(onPassPrimaryThreshold)(); } ctx.prevPastPrimaryThreshold = pastPrimaryThreshold; const pastSecondaryThreshold = absValue >= secondaryThreshold; if (pastSecondaryThreshold && !ctx.prevPastSecondaryThreshold) { runOnJS(onPassSecondaryThreshold)(); } ctx.prevPastSecondaryThreshold = pastSecondaryThreshold; }, onEnd: event => { const absValue = Math.abs(translateX.value); if (absValue >= secondaryThreshold && secondaryActionExists) { runOnJS(secondaryAction)(); } else if (absValue >= primaryThreshold) { runOnJS(primaryAction)(); } translateX.value = withSpring(0, makeSpringConfig(event.velocityX)); }, }, [ isViewer, onPassPrimaryThreshold, onPassSecondaryThreshold, primaryAction, secondaryAction, secondaryActionExists, ], ); const transformMessageBoxStyle = useAnimatedStyle( () => ({ transform: [{ translateX: translateX.value }], }), [], ); const { messageBoxStyle, children } = props; const reactNavGestureHandlerRef = React.useContext(GestureHandlerRefContext); const waitFor = reactNavGestureHandlerRef ?? undefined; if (!triggerReply && !triggerSidebar) { - return {children}; + return ( + + {children} + + ); } const threadColor = `#${props.threadColor}`; const tinyThreadColor = tinycolor(threadColor); const snakes = []; if (triggerReply) { const replySnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } if (triggerReply && triggerSidebar) { const sidebarSnakeTranslateXInterpolator = isViewer ? interpolateTranslateXForViewerSecondarySnake : interpolateTranslateXForNonViewerSecondarySnake; const darkerThreadColor = tinyThreadColor .darken(tinyThreadColor.isDark() ? 10 : 20) .toString(); snakes.push( , ); } else if (triggerSidebar) { const sidebarSnakeOpacityInterpolator = isViewer ? interpolateOpacityForViewerPrimarySnake : interpolateOpacityForNonViewerPrimarySnake; snakes.push( , ); } snakes.push( {children} , ); return snakes; } const styles = { swipeSnakeContainer: { marginHorizontal: 20, justifyContent: 'center', position: 'absolute', top: 0, bottom: 0, }, animationContainer: { position: 'absolute', top: 0, bottom: 0, }, swipeSnake: { paddingHorizontal: 15, flex: 1, borderRadius: 25, height: 30, justifyContent: 'center', maxHeight: 50, }, left0: { left: 0, }, right0: { right: 0, }, alignStart: { alignItems: 'flex-start', }, alignEnd: { alignItems: 'flex-end', }, }; export default SwipeableMessage;