Skip to content

Commit 5feeccb

Browse files
authored
feat: allow message overlay anchoring (#3504)
## 🎯 Goal This PR updates the message row and overlay interaction model. The message context menu now anchors its top and bottom portal items to the message bubble area instead of the full message row, and custom MessageItemView implementations can control that behavior via `useMessageContext().contextMenuAnchorRef`. Swipe-to-reply was also moved from the inner bubble to the full `MessageItemView`, while keeping the existing swipe logic intact. As part of that cleanup, the old `messageContentWidth` plumbing was removed, the internal `MessageBubble` component was inlined into `MessageItemView`, and the nested `messageItemView.bubble.*` theme keys were flattened into top level `messageItemView` style keys. ## 🛠 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 72bd39f commit 5feeccb

7 files changed

Lines changed: 253 additions & 278 deletions

File tree

package/src/components/Message/Message.tsx

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import React, { useEffect, useMemo, useRef, useState } from 'react';
2-
import {
3-
GestureResponderEvent,
4-
StyleProp,
5-
StyleSheet,
6-
useWindowDimensions,
7-
View,
8-
ViewStyle,
9-
} from 'react-native';
2+
import { GestureResponderEvent, StyleProp, StyleSheet, View, ViewStyle } from 'react-native';
103

114
import { useSafeAreaInsets } from 'react-native-safe-area-context';
125
import { Portal } from 'react-native-teleport';
@@ -54,6 +47,7 @@ import { isVideoPlayerAvailable, NativeHandlers } from '../../native';
5447
import {
5548
closeOverlay,
5649
openOverlay,
50+
Rect,
5751
setOverlayBottomH,
5852
setOverlayMessageH,
5953
setOverlayTopH,
@@ -332,16 +326,18 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
332326
const isMessageTypeDeleted = message.type === 'deleted';
333327
const { client } = chatContext;
334328

335-
const [rect, setRect] = useState<{ w: number; h: number; x: number; y: number } | undefined>(
336-
undefined,
337-
);
338-
const { width: screenW } = useWindowDimensions();
329+
const rectRef = useRef<Rect>(undefined);
330+
const bubbleRect = useRef<Rect>(undefined);
331+
const contextMenuAnchorRef = useRef<View>(null);
339332

340333
const showMessageOverlay = useStableCallback(async () => {
341334
dismissKeyboard();
342335
try {
343336
const layout = await measureInWindow(messageWrapperRef, insets);
344-
setRect(layout);
337+
const bubbleLayout = await measureInWindow(contextMenuAnchorRef, insets).catch(() => layout);
338+
339+
rectRef.current = layout;
340+
bubbleRect.current = bubbleLayout;
345341
setOverlayMessageH(layout);
346342
openOverlay({ id: messageOverlayId, messageId: message.id });
347343
} catch (e) {
@@ -698,6 +694,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
698694
actionsEnabled,
699695
alignment,
700696
channel,
697+
contextMenuAnchorRef,
701698
deliveredToCount,
702699
dismissOverlay,
703700
files: attachments.files,
@@ -815,6 +812,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
815812
const styles = useStyles({
816813
highlightedMessage: (isTargetedMessage || message.pinned) && !isMessageTypeDeleted,
817814
});
815+
const rect = rectRef.current;
816+
const overlayItemsAnchorRect = bubbleRect.current ?? rect;
818817

819818
if (!(isMessageTypeDeleted || messageContentOrder.length)) {
820819
return null;
@@ -841,15 +840,18 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
841840
) : null}
842841
{/*TODO: V9: Find a way to separate these in a dedicated file*/}
843842
<Portal hostName={overlayActive && rect ? 'top-item' : undefined}>
844-
{overlayActive && rect ? (
843+
{overlayActive && rect && overlayItemsAnchorRect ? (
845844
<View
846845
onLayout={(e) => {
847846
const { width: w, height: h } = e.nativeEvent.layout;
848847

849848
setOverlayTopH({
850849
h,
851850
w,
852-
x: isMyMessage ? screenW - rect.x - w : rect.x,
851+
x:
852+
alignment === 'right'
853+
? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w
854+
: overlayItemsAnchorRect.x,
853855
y: rect.y - h,
854856
});
855857
}}
@@ -865,7 +867,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
865867
hostName={overlayActive ? 'message-overlay' : undefined}
866868
style={overlayActive && rect ? { width: rect.w } : undefined}
867869
>
868-
<MessageItemView ref={messageWrapperRef} />
870+
<View ref={messageWrapperRef}>
871+
<MessageItemView />
872+
</View>
869873
</Portal>
870874
{showMessageReactions ? (
871875
<BottomSheetModal
@@ -883,14 +887,17 @@ const MessageWithContext = (props: MessagePropsWithContext) => {
883887
</BottomSheetModal>
884888
) : null}
885889
<Portal hostName={overlayActive && rect ? 'bottom-item' : undefined}>
886-
{overlayActive && rect ? (
890+
{overlayActive && rect && overlayItemsAnchorRect ? (
887891
<View
888892
onLayout={(e) => {
889893
const { width: w, height: h } = e.nativeEvent.layout;
890894
setOverlayBottomH({
891895
h,
892896
w,
893-
x: isMyMessage ? screenW - rect.x - w : rect.x,
897+
x:
898+
alignment === 'right'
899+
? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w
900+
: overlayItemsAnchorRect.x,
894901
y: rect.y + rect.h,
895902
});
896903
}}

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

Lines changed: 5 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -10,88 +10,24 @@ import Animated, {
1010
withSpring,
1111
} from 'react-native-reanimated';
1212

13-
import { MessageContentProps } from './MessageContent';
1413
import { MessageItemViewPropsWithContext } from './MessageItemView';
1514

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

1817
import { NativeHandlers } from '../../../native';
19-
import { MessageStatusTypes } from '../../../utils/utils';
20-
21-
export type MessageBubbleProps = Pick<
22-
MessagesContextValue,
23-
| 'reactionListPosition'
24-
| 'MessageContent'
25-
| 'ReactionListTop'
26-
| 'MessageError'
27-
| 'reactionListType'
28-
> &
29-
Pick<
30-
MessageContentProps,
31-
| 'isVeryLastMessage'
32-
| 'backgroundColor'
33-
| 'messageGroupedSingleOrBottom'
34-
| 'noBorder'
35-
| 'message'
36-
> &
37-
Pick<MessageItemViewPropsWithContext, 'alignment'>;
38-
39-
export const MessageBubble = React.memo(
40-
({
41-
alignment,
42-
reactionListPosition,
43-
reactionListType,
44-
MessageContent,
45-
ReactionListTop,
46-
backgroundColor,
47-
isVeryLastMessage,
48-
messageGroupedSingleOrBottom,
49-
noBorder,
50-
MessageError,
51-
message,
52-
}: MessageBubbleProps) => {
53-
const styles = useStyles({ alignment });
54-
const isMessageErrorType =
55-
message?.type === 'error' || message?.status === MessageStatusTypes.FAILED;
56-
57-
return (
58-
<View style={styles.wrapper}>
59-
{reactionListPosition === 'top' && ReactionListTop ? (
60-
<View style={styles.reactionListTopContainer}>
61-
<ReactionListTop type={reactionListType} />
62-
</View>
63-
) : null}
64-
<View style={styles.contentContainer}>
65-
<MessageContent
66-
backgroundColor={backgroundColor}
67-
isVeryLastMessage={isVeryLastMessage}
68-
messageGroupedSingleOrBottom={messageGroupedSingleOrBottom}
69-
noBorder={noBorder}
70-
/>
71-
72-
{isMessageErrorType ? (
73-
<View style={styles.errorContainer}>
74-
<MessageError />
75-
</View>
76-
) : null}
77-
</View>
78-
</View>
79-
);
80-
},
81-
);
8218

8319
const AnimatedWrapper = Animated.createAnimatedComponent(View);
8420

8521
type SwipableMessageWrapperProps = Pick<MessagesContextValue, 'MessageSwipeContent'> &
86-
Pick<MessageItemViewPropsWithContext, 'alignment' | 'messageSwipeToReplyHitSlop'> & {
22+
Pick<MessageItemViewPropsWithContext, 'messageSwipeToReplyHitSlop'> & {
8723
children: ReactNode;
8824
onSwipe: () => void;
8925
};
9026

9127
export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperProps) => {
9228
const { MessageSwipeContent, children, messageSwipeToReplyHitSlop, onSwipe } = props;
9329

94-
const styles = useStyles({ alignment: props.alignment });
30+
const styles = useStyles();
9531

9632
const translateX = useSharedValue(0);
9733
const touchStart = useSharedValue<{ x: number; y: number } | null>(null);
@@ -187,14 +123,10 @@ export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperP
187123
);
188124
});
189125

190-
const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => {
126+
const useStyles = () => {
191127
const {
192128
theme: {
193-
messageItemView: {
194-
bubble: { contentContainer, errorContainer, reactionListTopContainer, wrapper },
195-
contentWrapper,
196-
swipeContentContainer,
197-
},
129+
messageItemView: { contentWrapper, swipeContentContainer },
198130
},
199131
} = useTheme();
200132
return useMemo(() => {
@@ -205,34 +137,9 @@ const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => {
205137
zIndex: 1, // To hide the stick inside the message content
206138
...contentWrapper,
207139
},
208-
contentContainer: {
209-
alignSelf: alignment === 'left' ? 'flex-start' : 'flex-end',
210-
...contentContainer,
211-
},
212140
swipeContentContainer: {
213141
...swipeContentContainer,
214142
},
215-
errorContainer: {
216-
position: 'absolute',
217-
top: 8,
218-
right: -12,
219-
...errorContainer,
220-
},
221-
reactionListTopContainer: {
222-
alignSelf: alignment === 'left' ? 'flex-end' : 'flex-start',
223-
...reactionListTopContainer,
224-
},
225-
wrapper: {
226-
...wrapper,
227-
},
228143
});
229-
}, [
230-
alignment,
231-
contentContainer,
232-
contentWrapper,
233-
errorContainer,
234-
reactionListTopContainer,
235-
swipeContentContainer,
236-
wrapper,
237-
]);
144+
}, [contentWrapper, swipeContentContainer]);
238145
};

0 commit comments

Comments
 (0)