Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 103 additions & 129 deletions package/src/components/Message/MessageItemView/MessageBubble.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { SetStateAction, useMemo, useState } from 'react';
import React, { ReactNode, useMemo, useState } from 'react';
import { StyleSheet, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

Expand All @@ -15,7 +15,6 @@ import { MessageItemViewPropsWithContext } from './MessageItemView';

import { MessagesContextValue, useTheme } from '../../../contexts';

import { useStableCallback } from '../../../hooks';
import { NativeHandlers } from '../../../native';
import { MessageStatusTypes } from '../../../utils/utils';

Expand All @@ -34,18 +33,14 @@ export type MessageBubbleProps = Pick<
| 'messageGroupedSingleOrBottom'
| 'noBorder'
| 'message'
| 'setMessageContentWidth'
> &
Pick<MessageItemViewPropsWithContext, 'alignment'> & {
messageContentWidth: number;
};
Pick<MessageItemViewPropsWithContext, 'alignment'>;

export const MessageBubble = React.memo(
({
alignment,
reactionListPosition,
reactionListType,
setMessageContentWidth,
MessageContent,
ReactionListTop,
backgroundColor,
Expand All @@ -72,7 +67,6 @@ export const MessageBubble = React.memo(
isVeryLastMessage={isVeryLastMessage}
messageGroupedSingleOrBottom={messageGroupedSingleOrBottom}
noBorder={noBorder}
setMessageContentWidth={setMessageContentWidth}
/>

{isMessageErrorType ? (
Expand All @@ -88,130 +82,110 @@ export const MessageBubble = React.memo(

const AnimatedWrapper = Animated.createAnimatedComponent(View);

export const SwipableMessageBubble = React.memo(
(
props: MessageBubbleProps &
Pick<MessagesContextValue, 'MessageSwipeContent'> &
Pick<
MessageItemViewPropsWithContext,
'shouldRenderSwipeableWrapper' | 'messageSwipeToReplyHitSlop'
> & { onSwipe: () => void },
) => {
const { MessageSwipeContent, messageSwipeToReplyHitSlop, onSwipe, ...messageBubbleProps } =
props;

const styles = useStyles({ alignment: props.alignment });

const translateX = useSharedValue(0);
const touchStart = useSharedValue<{ x: number; y: number } | null>(null);
const isSwiping = useSharedValue<boolean>(false);
const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState<boolean>(false);

const SWIPABLE_THRESHOLD = 25;
const MINIMUM_DISTANCE = 8;

const triggerHaptic = NativeHandlers.triggerHaptic;

const setMessageContentWidth = useStableCallback((valueOrCallback: SetStateAction<number>) => {
if (typeof valueOrCallback === 'number') {
props.setMessageContentWidth(Math.ceil(valueOrCallback));
return;
}
props.setMessageContentWidth(valueOrCallback);
});

const swipeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop(messageSwipeToReplyHitSlop)
.onBegin((event) => {
touchStart.value = { x: event.x, y: event.y };
})
.onTouchesMove((event, state) => {
if (!touchStart.value || !event.changedTouches.length) {
state.fail();
return;
}

const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x);
const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y);
const isHorizontalPanning = xDiff > yDiff;
const hasMinimumDistance = xDiff > MINIMUM_DISTANCE || yDiff > MINIMUM_DISTANCE;
type SwipableMessageWrapperProps = Pick<MessagesContextValue, 'MessageSwipeContent'> &
Pick<MessageItemViewPropsWithContext, 'alignment' | 'messageSwipeToReplyHitSlop'> & {
children: ReactNode;
onSwipe: () => void;
};

// Only activate if there's significant horizontal movement
if (isHorizontalPanning && hasMinimumDistance) {
state.activate();
if (!isSwiping.value) {
runOnJS(setShouldRenderAnimatedWrapper)(true);
}
isSwiping.value = true;
} else if (hasMinimumDistance) {
// If there's significant movement but not horizontal, fail the gesture
state.fail();
}
})
.onStart(() => {
translateX.value = 0;
})
.onChange(({ translationX }) => {
if (translationX > 0) {
translateX.value = translationX;
export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => {
const { MessageSwipeContent, children, messageSwipeToReplyHitSlop, onSwipe } = props;

const styles = useStyles({ alignment: props.alignment });

const translateX = useSharedValue(0);
const touchStart = useSharedValue<{ x: number; y: number } | null>(null);
const isSwiping = useSharedValue<boolean>(false);
const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState<boolean>(false);

const SWIPABLE_THRESHOLD = 25;
const MINIMUM_DISTANCE = 8;

const triggerHaptic = NativeHandlers.triggerHaptic;

const swipeGesture = useMemo(
() =>
Gesture.Pan()
.hitSlop(messageSwipeToReplyHitSlop)
.onBegin((event) => {
touchStart.value = { x: event.x, y: event.y };
})
.onTouchesMove((event, state) => {
if (!touchStart.value || !event.changedTouches.length) {
state.fail();
return;
}

const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x);
const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y);
const isHorizontalPanning = xDiff > yDiff;
const hasMinimumDistance = xDiff > MINIMUM_DISTANCE || yDiff > MINIMUM_DISTANCE;

// Only activate if there's significant horizontal movement
if (isHorizontalPanning && hasMinimumDistance) {
state.activate();
if (!isSwiping.value) {
runOnJS(setShouldRenderAnimatedWrapper)(true);
}
})
.onEnd(() => {
if (translateX.value >= SWIPABLE_THRESHOLD) {
runOnJS(onSwipe)();
if (triggerHaptic) {
runOnJS(triggerHaptic)('impactMedium');
}
isSwiping.value = true;
} else if (hasMinimumDistance) {
// If there's significant movement but not horizontal, fail the gesture
state.fail();
}
})
.onStart(() => {
translateX.value = 0;
})
.onChange(({ translationX }) => {
if (translationX > 0) {
translateX.value = translationX;
}
})
.onEnd(() => {
if (translateX.value >= SWIPABLE_THRESHOLD) {
runOnJS(onSwipe)();
if (triggerHaptic) {
runOnJS(triggerHaptic)('impactMedium');
}
isSwiping.value = false;
translateX.value = withSpring(
0,
{
dampingRatio: 1,
duration: 500,
overshootClamping: true,
stiffness: 1,
},
() => {
runOnJS(setShouldRenderAnimatedWrapper)(false);
},
);
}),
[messageSwipeToReplyHitSlop, touchStart, isSwiping, translateX, onSwipe, triggerHaptic],
);

const swipeContentAnimatedStyle = useAnimatedStyle(
() => ({
opacity: interpolate(translateX.value, [0, SWIPABLE_THRESHOLD], [0, 1]),
width: translateX.value,
}),
[],
);

return (
<GestureDetector gesture={swipeGesture}>
<View
hitSlop={messageSwipeToReplyHitSlop}
style={[
styles.contentWrapper,
props.messageContentWidth > 0 && shouldRenderAnimatedWrapper
? { width: props.messageContentWidth }
: {},
]}
>
{shouldRenderAnimatedWrapper ? (
<AnimatedWrapper style={[styles.swipeContentContainer, swipeContentAnimatedStyle]}>
{MessageSwipeContent ? <MessageSwipeContent /> : null}
</AnimatedWrapper>
) : null}
<MessageBubble {...messageBubbleProps} setMessageContentWidth={setMessageContentWidth} />
</View>
</GestureDetector>
);
},
);
}
isSwiping.value = false;
translateX.value = withSpring(
0,
{
dampingRatio: 1,
duration: 500,
overshootClamping: true,
stiffness: 1,
},
() => {
runOnJS(setShouldRenderAnimatedWrapper)(false);
},
);
}),
[messageSwipeToReplyHitSlop, touchStart, isSwiping, translateX, onSwipe, triggerHaptic],
);

const swipeContentAnimatedStyle = useAnimatedStyle(
() => ({
opacity: interpolate(translateX.value, [0, SWIPABLE_THRESHOLD], [0, 1]),
width: translateX.value,
}),
[],
);

return (
<GestureDetector gesture={swipeGesture}>
<View hitSlop={messageSwipeToReplyHitSlop} style={styles.contentWrapper}>
{shouldRenderAnimatedWrapper ? (
<AnimatedWrapper style={[styles.swipeContentContainer, swipeContentAnimatedStyle]}>
{MessageSwipeContent ? <MessageSwipeContent /> : null}
</AnimatedWrapper>
) : null}
{children}
</View>
</GestureDetector>
);
});

const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => {
const {
Expand Down
26 changes: 3 additions & 23 deletions package/src/components/Message/MessageItemView/MessageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import React, { useMemo } from 'react';
import {
AnimatableNumericValue,
ColorValue,
LayoutChangeEvent,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { AnimatableNumericValue, ColorValue, Pressable, StyleSheet, View } from 'react-native';

import { MessageTextContainer } from './MessageTextContainer';

Expand Down Expand Up @@ -89,7 +82,6 @@ export type MessageContentPropsWithContext = Pick<
| 'StreamingMessageView'
> &
Pick<TranslationContextValue, 't'> & {
setMessageContentWidth: React.Dispatch<React.SetStateAction<number>>;
/**
* Background color for the message content
*/
Expand Down Expand Up @@ -147,7 +139,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
otherAttachments,
preventPress,
Reply,
setMessageContentWidth,
StreamingMessageView,
hidePaddingTop,
hidePaddingHorizontal,
Expand Down Expand Up @@ -181,14 +172,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
},
} = useTheme();

const onLayout: (event: LayoutChangeEvent) => void = ({
nativeEvent: {
layout: { width },
},
}) => {
setMessageContentWidth(width);
};

const isAIGenerated = useMemo(
() => isMessageAIGenerated(message),
[message, isMessageAIGenerated],
Expand Down Expand Up @@ -352,7 +335,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
}
}}
>
<View onLayout={onLayout} style={wrapper}>
<View style={wrapper}>
<View
style={[
styles.containerInner,
Expand Down Expand Up @@ -551,10 +534,7 @@ const MemoizedMessageContent = React.memo(
areEqual,
) as typeof MessageContentWithContext;

export type MessageContentProps = Partial<
Omit<MessageContentPropsWithContext, 'setMessageContentWidth'>
> &
Pick<MessageContentPropsWithContext, 'setMessageContentWidth'>;
export type MessageContentProps = Partial<MessageContentPropsWithContext>;

/**
* Child of MessageItemView that displays a message's content
Expand Down
Loading
Loading