Skip to content

Commit 6cf66dd

Browse files
authored
feat: move swipability to full row (#3503)
## 🎯 Goal This PR moves swipe-to-reply from the inner MessageBubble to the full MessageItemView, so the entire rendered message row now participates in the swipe interaction instead of only the bubble. The underlying swipe implementation is intentionally unchanged: it keeps the same gesture activation rules, thresholding, haptics, and swipe content behavior. As part of that change, the old `messageContentWidth` / `setMessageContentWidth` measurement path was removed from `MessageItemView`, `MessageBubble` and `MessageContent`, because that state was no longer used by the UI after the swipe boundary moved. This simplifies the message rendering stack and removes an unnecessary layout driven state update/rerender path. ## 🛠 Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## 🧪 Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## ☑️ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent bcf6866 commit 6cf66dd

File tree

7 files changed

+550
-634
lines changed

7 files changed

+550
-634
lines changed

package/src/components/Message/MessageItemView/MessageBubble.tsx

Lines changed: 103 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { SetStateAction, useMemo, useState } from 'react';
1+
import React, { ReactNode, useMemo, useState } from 'react';
22
import { StyleSheet, View } from 'react-native';
33
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
44

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

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

18-
import { useStableCallback } from '../../../hooks';
1918
import { NativeHandlers } from '../../../native';
2019
import { MessageStatusTypes } from '../../../utils/utils';
2120

@@ -34,18 +33,14 @@ export type MessageBubbleProps = Pick<
3433
| 'messageGroupedSingleOrBottom'
3534
| 'noBorder'
3635
| 'message'
37-
| 'setMessageContentWidth'
3836
> &
39-
Pick<MessageItemViewPropsWithContext, 'alignment'> & {
40-
messageContentWidth: number;
41-
};
37+
Pick<MessageItemViewPropsWithContext, 'alignment'>;
4238

4339
export const MessageBubble = React.memo(
4440
({
4541
alignment,
4642
reactionListPosition,
4743
reactionListType,
48-
setMessageContentWidth,
4944
MessageContent,
5045
ReactionListTop,
5146
backgroundColor,
@@ -72,7 +67,6 @@ export const MessageBubble = React.memo(
7267
isVeryLastMessage={isVeryLastMessage}
7368
messageGroupedSingleOrBottom={messageGroupedSingleOrBottom}
7469
noBorder={noBorder}
75-
setMessageContentWidth={setMessageContentWidth}
7670
/>
7771

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

8983
const AnimatedWrapper = Animated.createAnimatedComponent(View);
9084

91-
export const SwipableMessageBubble = React.memo(
92-
(
93-
props: MessageBubbleProps &
94-
Pick<MessagesContextValue, 'MessageSwipeContent'> &
95-
Pick<
96-
MessageItemViewPropsWithContext,
97-
'shouldRenderSwipeableWrapper' | 'messageSwipeToReplyHitSlop'
98-
> & { onSwipe: () => void },
99-
) => {
100-
const { MessageSwipeContent, messageSwipeToReplyHitSlop, onSwipe, ...messageBubbleProps } =
101-
props;
102-
103-
const styles = useStyles({ alignment: props.alignment });
104-
105-
const translateX = useSharedValue(0);
106-
const touchStart = useSharedValue<{ x: number; y: number } | null>(null);
107-
const isSwiping = useSharedValue<boolean>(false);
108-
const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState<boolean>(false);
109-
110-
const SWIPABLE_THRESHOLD = 25;
111-
const MINIMUM_DISTANCE = 8;
112-
113-
const triggerHaptic = NativeHandlers.triggerHaptic;
114-
115-
const setMessageContentWidth = useStableCallback((valueOrCallback: SetStateAction<number>) => {
116-
if (typeof valueOrCallback === 'number') {
117-
props.setMessageContentWidth(Math.ceil(valueOrCallback));
118-
return;
119-
}
120-
props.setMessageContentWidth(valueOrCallback);
121-
});
122-
123-
const swipeGesture = useMemo(
124-
() =>
125-
Gesture.Pan()
126-
.hitSlop(messageSwipeToReplyHitSlop)
127-
.onBegin((event) => {
128-
touchStart.value = { x: event.x, y: event.y };
129-
})
130-
.onTouchesMove((event, state) => {
131-
if (!touchStart.value || !event.changedTouches.length) {
132-
state.fail();
133-
return;
134-
}
135-
136-
const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x);
137-
const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y);
138-
const isHorizontalPanning = xDiff > yDiff;
139-
const hasMinimumDistance = xDiff > MINIMUM_DISTANCE || yDiff > MINIMUM_DISTANCE;
85+
type SwipableMessageWrapperProps = Pick<MessagesContextValue, 'MessageSwipeContent'> &
86+
Pick<MessageItemViewPropsWithContext, 'alignment' | 'messageSwipeToReplyHitSlop'> & {
87+
children: ReactNode;
88+
onSwipe: () => void;
89+
};
14090

141-
// Only activate if there's significant horizontal movement
142-
if (isHorizontalPanning && hasMinimumDistance) {
143-
state.activate();
144-
if (!isSwiping.value) {
145-
runOnJS(setShouldRenderAnimatedWrapper)(true);
146-
}
147-
isSwiping.value = true;
148-
} else if (hasMinimumDistance) {
149-
// If there's significant movement but not horizontal, fail the gesture
150-
state.fail();
151-
}
152-
})
153-
.onStart(() => {
154-
translateX.value = 0;
155-
})
156-
.onChange(({ translationX }) => {
157-
if (translationX > 0) {
158-
translateX.value = translationX;
91+
export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => {
92+
const { MessageSwipeContent, children, messageSwipeToReplyHitSlop, onSwipe } = props;
93+
94+
const styles = useStyles({ alignment: props.alignment });
95+
96+
const translateX = useSharedValue(0);
97+
const touchStart = useSharedValue<{ x: number; y: number } | null>(null);
98+
const isSwiping = useSharedValue<boolean>(false);
99+
const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState<boolean>(false);
100+
101+
const SWIPABLE_THRESHOLD = 25;
102+
const MINIMUM_DISTANCE = 8;
103+
104+
const triggerHaptic = NativeHandlers.triggerHaptic;
105+
106+
const swipeGesture = useMemo(
107+
() =>
108+
Gesture.Pan()
109+
.hitSlop(messageSwipeToReplyHitSlop)
110+
.onBegin((event) => {
111+
touchStart.value = { x: event.x, y: event.y };
112+
})
113+
.onTouchesMove((event, state) => {
114+
if (!touchStart.value || !event.changedTouches.length) {
115+
state.fail();
116+
return;
117+
}
118+
119+
const xDiff = Math.abs(event.changedTouches[0].x - touchStart.value.x);
120+
const yDiff = Math.abs(event.changedTouches[0].y - touchStart.value.y);
121+
const isHorizontalPanning = xDiff > yDiff;
122+
const hasMinimumDistance = xDiff > MINIMUM_DISTANCE || yDiff > MINIMUM_DISTANCE;
123+
124+
// Only activate if there's significant horizontal movement
125+
if (isHorizontalPanning && hasMinimumDistance) {
126+
state.activate();
127+
if (!isSwiping.value) {
128+
runOnJS(setShouldRenderAnimatedWrapper)(true);
159129
}
160-
})
161-
.onEnd(() => {
162-
if (translateX.value >= SWIPABLE_THRESHOLD) {
163-
runOnJS(onSwipe)();
164-
if (triggerHaptic) {
165-
runOnJS(triggerHaptic)('impactMedium');
166-
}
130+
isSwiping.value = true;
131+
} else if (hasMinimumDistance) {
132+
// If there's significant movement but not horizontal, fail the gesture
133+
state.fail();
134+
}
135+
})
136+
.onStart(() => {
137+
translateX.value = 0;
138+
})
139+
.onChange(({ translationX }) => {
140+
if (translationX > 0) {
141+
translateX.value = translationX;
142+
}
143+
})
144+
.onEnd(() => {
145+
if (translateX.value >= SWIPABLE_THRESHOLD) {
146+
runOnJS(onSwipe)();
147+
if (triggerHaptic) {
148+
runOnJS(triggerHaptic)('impactMedium');
167149
}
168-
isSwiping.value = false;
169-
translateX.value = withSpring(
170-
0,
171-
{
172-
dampingRatio: 1,
173-
duration: 500,
174-
overshootClamping: true,
175-
stiffness: 1,
176-
},
177-
() => {
178-
runOnJS(setShouldRenderAnimatedWrapper)(false);
179-
},
180-
);
181-
}),
182-
[messageSwipeToReplyHitSlop, touchStart, isSwiping, translateX, onSwipe, triggerHaptic],
183-
);
184-
185-
const swipeContentAnimatedStyle = useAnimatedStyle(
186-
() => ({
187-
opacity: interpolate(translateX.value, [0, SWIPABLE_THRESHOLD], [0, 1]),
188-
width: translateX.value,
189-
}),
190-
[],
191-
);
192-
193-
return (
194-
<GestureDetector gesture={swipeGesture}>
195-
<View
196-
hitSlop={messageSwipeToReplyHitSlop}
197-
style={[
198-
styles.contentWrapper,
199-
props.messageContentWidth > 0 && shouldRenderAnimatedWrapper
200-
? { width: props.messageContentWidth }
201-
: {},
202-
]}
203-
>
204-
{shouldRenderAnimatedWrapper ? (
205-
<AnimatedWrapper style={[styles.swipeContentContainer, swipeContentAnimatedStyle]}>
206-
{MessageSwipeContent ? <MessageSwipeContent /> : null}
207-
</AnimatedWrapper>
208-
) : null}
209-
<MessageBubble {...messageBubbleProps} setMessageContentWidth={setMessageContentWidth} />
210-
</View>
211-
</GestureDetector>
212-
);
213-
},
214-
);
150+
}
151+
isSwiping.value = false;
152+
translateX.value = withSpring(
153+
0,
154+
{
155+
dampingRatio: 1,
156+
duration: 500,
157+
overshootClamping: true,
158+
stiffness: 1,
159+
},
160+
() => {
161+
runOnJS(setShouldRenderAnimatedWrapper)(false);
162+
},
163+
);
164+
}),
165+
[messageSwipeToReplyHitSlop, touchStart, isSwiping, translateX, onSwipe, triggerHaptic],
166+
);
167+
168+
const swipeContentAnimatedStyle = useAnimatedStyle(
169+
() => ({
170+
opacity: interpolate(translateX.value, [0, SWIPABLE_THRESHOLD], [0, 1]),
171+
width: translateX.value,
172+
}),
173+
[],
174+
);
175+
176+
return (
177+
<GestureDetector gesture={swipeGesture}>
178+
<View hitSlop={messageSwipeToReplyHitSlop} style={styles.contentWrapper}>
179+
{shouldRenderAnimatedWrapper ? (
180+
<AnimatedWrapper style={[styles.swipeContentContainer, swipeContentAnimatedStyle]}>
181+
{MessageSwipeContent ? <MessageSwipeContent /> : null}
182+
</AnimatedWrapper>
183+
) : null}
184+
{children}
185+
</View>
186+
</GestureDetector>
187+
);
188+
});
215189

216190
const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => {
217191
const {

package/src/components/Message/MessageItemView/MessageContent.tsx

Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import React, { useMemo } from 'react';
2-
import {
3-
AnimatableNumericValue,
4-
ColorValue,
5-
LayoutChangeEvent,
6-
Pressable,
7-
StyleSheet,
8-
View,
9-
} from 'react-native';
2+
import { AnimatableNumericValue, ColorValue, Pressable, StyleSheet, View } from 'react-native';
103

114
import { MessageTextContainer } from './MessageTextContainer';
125

@@ -89,7 +82,6 @@ export type MessageContentPropsWithContext = Pick<
8982
| 'StreamingMessageView'
9083
> &
9184
Pick<TranslationContextValue, 't'> & {
92-
setMessageContentWidth: React.Dispatch<React.SetStateAction<number>>;
9385
/**
9486
* Background color for the message content
9587
*/
@@ -147,7 +139,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
147139
otherAttachments,
148140
preventPress,
149141
Reply,
150-
setMessageContentWidth,
151142
StreamingMessageView,
152143
hidePaddingTop,
153144
hidePaddingHorizontal,
@@ -181,14 +172,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
181172
},
182173
} = useTheme();
183174

184-
const onLayout: (event: LayoutChangeEvent) => void = ({
185-
nativeEvent: {
186-
layout: { width },
187-
},
188-
}) => {
189-
setMessageContentWidth(width);
190-
};
191-
192175
const isAIGenerated = useMemo(
193176
() => isMessageAIGenerated(message),
194177
[message, isMessageAIGenerated],
@@ -352,7 +335,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => {
352335
}
353336
}}
354337
>
355-
<View onLayout={onLayout} style={wrapper}>
338+
<View style={wrapper}>
356339
<View
357340
style={[
358341
styles.containerInner,
@@ -551,10 +534,7 @@ const MemoizedMessageContent = React.memo(
551534
areEqual,
552535
) as typeof MessageContentWithContext;
553536

554-
export type MessageContentProps = Partial<
555-
Omit<MessageContentPropsWithContext, 'setMessageContentWidth'>
556-
> &
557-
Pick<MessageContentPropsWithContext, 'setMessageContentWidth'>;
537+
export type MessageContentProps = Partial<MessageContentPropsWithContext>;
558538

559539
/**
560540
* Child of MessageItemView that displays a message's content

0 commit comments

Comments
 (0)