From be6ece49855a5f6b5627d357ac23c0d98972c6e8 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 22 Mar 2026 01:36:22 +0100 Subject: [PATCH 1/3] feat: move swipe-to-reply feature to full row --- .../Message/MessageItemView/MessageBubble.tsx | 241 +++++++++--------- .../MessageItemView/MessageContent.tsx | 18 +- .../MessageItemView/MessageItemView.tsx | 143 +++++------ .../__tests__/MessageItemView.test.js | 14 + 4 files changed, 208 insertions(+), 208 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageBubble.tsx b/package/src/components/Message/MessageItemView/MessageBubble.tsx index 78f5514437..f63e966ca2 100644 --- a/package/src/components/Message/MessageItemView/MessageBubble.tsx +++ b/package/src/components/Message/MessageItemView/MessageBubble.tsx @@ -1,5 +1,5 @@ -import React, { SetStateAction, useMemo, useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import React, { ReactNode, SetStateAction, useMemo, useState } from 'react'; +import { LayoutChangeEvent, StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -36,9 +36,7 @@ export type MessageBubbleProps = Pick< | 'message' | 'setMessageContentWidth' > & - Pick & { - messageContentWidth: number; - }; + Pick; export const MessageBubble = React.memo( ({ @@ -88,130 +86,139 @@ export const MessageBubble = React.memo( const AnimatedWrapper = Animated.createAnimatedComponent(View); -export const SwipableMessageBubble = React.memo( - ( - props: MessageBubbleProps & - Pick & - Pick< - MessageItemViewPropsWithContext, - 'shouldRenderSwipeableWrapper' | 'messageSwipeToReplyHitSlop' - > & { onSwipe: () => void }, - ) => { - const { MessageSwipeContent, messageSwipeToReplyHitSlop, onSwipe, ...messageBubbleProps } = - props; +type SwipableMessageWrapperProps = Pick & + Pick & { + children: ReactNode; + messageContentWidth: number; + onSwipe: () => void; + setMessageContentWidth: React.Dispatch>; + }; - const styles = useStyles({ alignment: props.alignment }); +export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => { + const { MessageSwipeContent, children, messageSwipeToReplyHitSlop, onSwipe } = props; - const translateX = useSharedValue(0); - const touchStart = useSharedValue<{ x: number; y: number } | null>(null); - const isSwiping = useSharedValue(false); - const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState(false); + const styles = useStyles({ alignment: props.alignment }); - const SWIPABLE_THRESHOLD = 25; - const MINIMUM_DISTANCE = 8; + const translateX = useSharedValue(0); + const touchStart = useSharedValue<{ x: number; y: number } | null>(null); + const isSwiping = useSharedValue(false); + const [shouldRenderAnimatedWrapper, setShouldRenderAnimatedWrapper] = useState(false); - const triggerHaptic = NativeHandlers.triggerHaptic; + const SWIPABLE_THRESHOLD = 25; + const MINIMUM_DISTANCE = 8; - const setMessageContentWidth = useStableCallback((valueOrCallback: SetStateAction) => { - if (typeof valueOrCallback === 'number') { - props.setMessageContentWidth(Math.ceil(valueOrCallback)); - return; - } - props.setMessageContentWidth(valueOrCallback); - }); + 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 setMessageContentWidth = useStableCallback((valueOrCallback: SetStateAction) => { + if (typeof valueOrCallback === 'number') { + props.setMessageContentWidth(Math.ceil(valueOrCallback)); + return; + } + props.setMessageContentWidth(valueOrCallback); + }); - 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; + const onLayout = useStableCallback( + ({ + nativeEvent: { + layout: { width }, + }, + }: LayoutChangeEvent) => { + setMessageContentWidth(width); + }, + ); - // 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; + 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], - ); + } + 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, - }), - [], - ); + const swipeContentAnimatedStyle = useAnimatedStyle( + () => ({ + opacity: interpolate(translateX.value, [0, SWIPABLE_THRESHOLD], [0, 1]), + width: translateX.value, + }), + [], + ); - return ( - - 0 && shouldRenderAnimatedWrapper - ? { width: props.messageContentWidth } - : {}, - ]} - > - {shouldRenderAnimatedWrapper ? ( - - {MessageSwipeContent ? : null} - - ) : null} - - - - ); - }, -); + return ( + + 0 && shouldRenderAnimatedWrapper + ? { width: props.messageContentWidth } + : {}, + ]} + > + {shouldRenderAnimatedWrapper ? ( + + {MessageSwipeContent ? : null} + + ) : null} + {children} + + + ); +}); const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => { const { diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index 73af2ae56a..dc3e506f50 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -89,7 +89,7 @@ export type MessageContentPropsWithContext = Pick< | 'StreamingMessageView' > & Pick & { - setMessageContentWidth: React.Dispatch>; + setMessageContentWidth?: React.Dispatch>; /** * Background color for the message content */ @@ -181,13 +181,15 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }, } = useTheme(); - const onLayout: (event: LayoutChangeEvent) => void = ({ - nativeEvent: { - layout: { width }, - }, - }) => { - setMessageContentWidth(width); - }; + const onLayout: ((event: LayoutChangeEvent) => void) | undefined = setMessageContentWidth + ? ({ + nativeEvent: { + layout: { width }, + }, + }) => { + setMessageContentWidth(width); + } + : undefined; const isAIGenerated = useMemo( () => isMessageAIGenerated(message), diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index 5f778db1ba..4e80e10bc7 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, useMemo, useState } from 'react'; import { Dimensions, StyleSheet, View, ViewStyle } from 'react-native'; -import { MessageBubble, SwipableMessageBubble } from './MessageBubble'; +import { MessageBubble, SwipableMessageWrapper } from './MessageBubble'; import { Alignment, @@ -71,6 +71,7 @@ const useStyles = ({ alignItems: 'flex-end', gap: primitives.spacingXs, flexDirection: alignment === 'left' ? 'row' : 'row-reverse', + width: '100%', ...container, }, contentContainer: { @@ -190,16 +191,7 @@ export type MessageItemViewPropsWithContext = Pick< | 'reactionListPosition' | 'reactionListType' | 'ReactionListTop' - > & { - /** - * Will determine whether the swipeable wrapper is always rendered for each - * message. If set to false, the animated wrapper will be rendered only when - * a swiping gesture is active and not otherwise. - * Since stateful components would lose their state if we remount them while - * an animation is happening, this should always be set to true in those instances. - */ - shouldRenderSwipeableWrapper: boolean; - }; + >; const MessageItemViewWithContext = forwardRef( (props, ref) => { @@ -230,7 +222,6 @@ const MessageItemViewWithContext = forwardRef + {alignment === 'left' ? : null} + {isMessageTypeDeleted ? ( + + ) : ( + + + + + + + + + {reactionListPosition === 'bottom' && ReactionListBottom ? ( + + ) : null} + + + )} + {MessageSpacer ? : null} + + ); + return ( - - {alignment === 'left' ? : null} - {isMessageTypeDeleted ? ( - - ) : ( - - - {enableSwipeToReply ? ( - - ) : ( - - )} - - - - - - {reactionListPosition === 'bottom' && ReactionListBottom ? ( - - ) : null} - - - )} - {MessageSpacer ? : null} - + {enableSwipeToReply && !isMessageTypeDeleted ? ( + + {itemViewContent} + + ) : ( + itemViewContent + )} ); }, @@ -504,7 +488,6 @@ export const MessageItemView = forwardRef((props, re message, onlyEmojis, otherAttachments, - isMessageAIGenerated, setQuotedMessage, lastGroupMessage, members, @@ -530,11 +513,6 @@ export const MessageItemView = forwardRef((props, re reactionListType, ReactionListTop, } = useMessagesContext(); - const isAIGenerated = useMemo( - () => isMessageAIGenerated(message), - [message, isMessageAIGenerated], - ); - const shouldRenderSwipeableWrapper = (message?.attachments || []).length > 0 || isAIGenerated; return ( ((props, re reactionListType, ReactionListTop, setQuotedMessage, - shouldRenderSwipeableWrapper, lastGroupMessage, members, }} diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js index 360844d634..d855bc4aca 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js @@ -1,6 +1,7 @@ import React from 'react'; import { Text } from 'react-native'; +import { GestureDetector } from 'react-native-gesture-handler'; import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; @@ -107,6 +108,19 @@ describe('MessageItemView', () => { }); }); + it('wraps the full MessageItemView with swipe-to-reply', async () => { + const user = generateUser(); + const message = generateMessage({ user }); + + renderMessage({ message }); + + await waitFor(() => { + const gestureDetector = screen.UNSAFE_getByType(GestureDetector); + + expect(gestureDetector.findByProps({ testID: 'message-item-view-wrapper' })).toBeTruthy(); + }); + }); + it('renders MessageSpacer component if defined', async () => { const user = generateUser(); const message = generateMessage({ user }); From 858070b1535c753225acaa782cdd83f7027f032a Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 22 Mar 2026 02:11:56 +0100 Subject: [PATCH 2/3] chore: remove messageContentWidth entirely --- .../Message/MessageItemView/MessageBubble.tsx | 39 ++----------------- .../MessageItemView/MessageContent.tsx | 28 ++----------- .../MessageItemView/MessageItemView.tsx | 5 +-- .../__tests__/MessageContent.test.js | 15 +------ .../__tests__/ReactionListTop.test.js | 3 -- 5 files changed, 9 insertions(+), 81 deletions(-) diff --git a/package/src/components/Message/MessageItemView/MessageBubble.tsx b/package/src/components/Message/MessageItemView/MessageBubble.tsx index f63e966ca2..f142c620fc 100644 --- a/package/src/components/Message/MessageItemView/MessageBubble.tsx +++ b/package/src/components/Message/MessageItemView/MessageBubble.tsx @@ -1,5 +1,5 @@ -import React, { ReactNode, SetStateAction, useMemo, useState } from 'react'; -import { LayoutChangeEvent, StyleSheet, View } from 'react-native'; +import React, { ReactNode, useMemo, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { @@ -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'; @@ -34,7 +33,6 @@ export type MessageBubbleProps = Pick< | 'messageGroupedSingleOrBottom' | 'noBorder' | 'message' - | 'setMessageContentWidth' > & Pick; @@ -43,7 +41,6 @@ export const MessageBubble = React.memo( alignment, reactionListPosition, reactionListType, - setMessageContentWidth, MessageContent, ReactionListTop, backgroundColor, @@ -70,7 +67,6 @@ export const MessageBubble = React.memo( isVeryLastMessage={isVeryLastMessage} messageGroupedSingleOrBottom={messageGroupedSingleOrBottom} noBorder={noBorder} - setMessageContentWidth={setMessageContentWidth} /> {isMessageErrorType ? ( @@ -89,9 +85,7 @@ const AnimatedWrapper = Animated.createAnimatedComponent(View); type SwipableMessageWrapperProps = Pick & Pick & { children: ReactNode; - messageContentWidth: number; onSwipe: () => void; - setMessageContentWidth: React.Dispatch>; }; export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => { @@ -109,24 +103,6 @@ export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperP const triggerHaptic = NativeHandlers.triggerHaptic; - const setMessageContentWidth = useStableCallback((valueOrCallback: SetStateAction) => { - if (typeof valueOrCallback === 'number') { - props.setMessageContentWidth(Math.ceil(valueOrCallback)); - return; - } - props.setMessageContentWidth(valueOrCallback); - }); - - const onLayout = useStableCallback( - ({ - nativeEvent: { - layout: { width }, - }, - }: LayoutChangeEvent) => { - setMessageContentWidth(width); - }, - ); - const swipeGesture = useMemo( () => Gesture.Pan() @@ -199,16 +175,7 @@ export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperP return ( - 0 && shouldRenderAnimatedWrapper - ? { width: props.messageContentWidth } - : {}, - ]} - > + {shouldRenderAnimatedWrapper ? ( {MessageSwipeContent ? : null} diff --git a/package/src/components/Message/MessageItemView/MessageContent.tsx b/package/src/components/Message/MessageItemView/MessageContent.tsx index dc3e506f50..cb65156006 100644 --- a/package/src/components/Message/MessageItemView/MessageContent.tsx +++ b/package/src/components/Message/MessageItemView/MessageContent.tsx @@ -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'; @@ -89,7 +82,6 @@ export type MessageContentPropsWithContext = Pick< | 'StreamingMessageView' > & Pick & { - setMessageContentWidth?: React.Dispatch>; /** * Background color for the message content */ @@ -147,7 +139,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { otherAttachments, preventPress, Reply, - setMessageContentWidth, StreamingMessageView, hidePaddingTop, hidePaddingHorizontal, @@ -181,16 +172,6 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { }, } = useTheme(); - const onLayout: ((event: LayoutChangeEvent) => void) | undefined = setMessageContentWidth - ? ({ - nativeEvent: { - layout: { width }, - }, - }) => { - setMessageContentWidth(width); - } - : undefined; - const isAIGenerated = useMemo( () => isMessageAIGenerated(message), [message, isMessageAIGenerated], @@ -354,7 +335,7 @@ const MessageContentWithContext = (props: MessageContentPropsWithContext) => { } }} > - + -> & - Pick; +export type MessageContentProps = Partial; /** * Child of MessageItemView that displays a message's content diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index 4e80e10bc7..58fb0f2dc1 100644 --- a/package/src/components/Message/MessageItemView/MessageItemView.tsx +++ b/package/src/components/Message/MessageItemView/MessageItemView.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useMemo, useState } from 'react'; +import React, { forwardRef, useMemo } from 'react'; import { Dimensions, StyleSheet, View, ViewStyle } from 'react-native'; import { MessageBubble, SwipableMessageWrapper } from './MessageBubble'; @@ -195,7 +195,6 @@ export type MessageItemViewPropsWithContext = Pick< const MessageItemViewWithContext = forwardRef( (props, ref) => { - const [messageContentWidth, setMessageContentWidth] = useState(0); const { width } = Dimensions.get('screen'); const { alignment, @@ -334,11 +333,9 @@ const MessageItemViewWithContext = forwardRef {itemViewContent} diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js b/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js index f6a9f8bf76..21fbbeee46 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/MessageContent.test.js @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { StyleSheet, View } from 'react-native'; import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; @@ -17,8 +17,6 @@ import { getTestClientWithUser } from '../../../../mock-builders/mock'; import { Channel } from '../../../Channel/Channel'; import { Chat } from '../../../Chat/Chat'; import { Message } from '../../Message'; -import { MessageContent } from '../MessageContent'; - describe('MessageContent', () => { let channel; let chatClient; @@ -359,19 +357,10 @@ describe('MessageContent', () => { user, }); - // This needs to be mocked like that cause native onLayout on MessageContent would never - // trigger. - const MessageContentWithMockedMessageContentWidth = (props) => { - useEffect(() => { - props.setMessageContentWidth(100); - }, [props]); - return ; - }; - render( - + diff --git a/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.js b/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.js index 03e60c7320..e6007a780a 100644 --- a/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/ReactionListTop.test.js @@ -51,7 +51,6 @@ describe('ReactionListTop', () => { it('renders the ReactionListTop component', async () => { renderMessage({ hasReactions: true, - messageContentWidth: 100, reactions: [{ count: 1, own: true, type: 'love' }], }); @@ -63,7 +62,6 @@ describe('ReactionListTop', () => { it('return null in ReactionListTop component when hasReactions false', async () => { renderMessage({ hasReactions: false, - messageContentWidth: 100, reactions: [{ count: 1, own: true, type: 'love' }], }); @@ -76,7 +74,6 @@ describe('ReactionListTop', () => { renderMessage( { hasReactions: false, - messageContentWidth: 100, reactions: [{ count: 1, own: true, type: 'love' }], }, { supportedReactions: [] }, From 195876fb661604f2415815789de4344b7210e240 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Sun, 22 Mar 2026 02:23:35 +0100 Subject: [PATCH 3/3] fix: tests --- .../__snapshots__/Thread.test.js.snap | 750 +++++++++--------- 1 file changed, 369 insertions(+), 381 deletions(-) diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 107301f3ca..b55a003ebc 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -370,101 +370,99 @@ exports[`Thread should match thread snapshot 1`] = ` - + testID="avatar-image" + > + + - - - - - - + - Edited - + + Edited + + @@ -697,101 +694,99 @@ exports[`Thread should match thread snapshot 1`] = ` - + testID="avatar-image" + > + + - - - - - - + - Edited - + + Edited + + @@ -1057,101 +1051,99 @@ exports[`Thread should match thread snapshot 1`] = ` - + testID="avatar-image" + > + + - - - - - - + - Edited - + + Edited + + @@ -1374,102 +1365,100 @@ exports[`Thread should match thread snapshot 1`] = ` - + testID="avatar-image" + > + + - - - - - - + - Edited - + + Edited + +