diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index fa82ed8079..c00762a69b 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -1,12 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { - GestureResponderEvent, - StyleProp, - StyleSheet, - useWindowDimensions, - View, - ViewStyle, -} from 'react-native'; +import { GestureResponderEvent, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Portal } from 'react-native-teleport'; @@ -54,6 +47,7 @@ import { isVideoPlayerAvailable, NativeHandlers } from '../../native'; import { closeOverlay, openOverlay, + Rect, setOverlayBottomH, setOverlayMessageH, setOverlayTopH, @@ -332,16 +326,18 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const isMessageTypeDeleted = message.type === 'deleted'; const { client } = chatContext; - const [rect, setRect] = useState<{ w: number; h: number; x: number; y: number } | undefined>( - undefined, - ); - const { width: screenW } = useWindowDimensions(); + const rectRef = useRef(undefined); + const bubbleRect = useRef(undefined); + const contextMenuAnchorRef = useRef(null); const showMessageOverlay = useStableCallback(async () => { dismissKeyboard(); try { const layout = await measureInWindow(messageWrapperRef, insets); - setRect(layout); + const bubbleLayout = await measureInWindow(contextMenuAnchorRef, insets).catch(() => layout); + + rectRef.current = layout; + bubbleRect.current = bubbleLayout; setOverlayMessageH(layout); openOverlay({ id: messageOverlayId, messageId: message.id }); } catch (e) { @@ -698,6 +694,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { actionsEnabled, alignment, channel, + contextMenuAnchorRef, deliveredToCount, dismissOverlay, files: attachments.files, @@ -815,6 +812,8 @@ const MessageWithContext = (props: MessagePropsWithContext) => { const styles = useStyles({ highlightedMessage: (isTargetedMessage || message.pinned) && !isMessageTypeDeleted, }); + const rect = rectRef.current; + const overlayItemsAnchorRect = bubbleRect.current ?? rect; if (!(isMessageTypeDeleted || messageContentOrder.length)) { return null; @@ -841,7 +840,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { ) : null} {/*TODO: V9: Find a way to separate these in a dedicated file*/} - {overlayActive && rect ? ( + {overlayActive && rect && overlayItemsAnchorRect ? ( { const { width: w, height: h } = e.nativeEvent.layout; @@ -849,7 +848,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => { setOverlayTopH({ h, w, - x: isMyMessage ? screenW - rect.x - w : rect.x, + x: + alignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, y: rect.y - h, }); }} @@ -865,7 +867,9 @@ const MessageWithContext = (props: MessagePropsWithContext) => { hostName={overlayActive ? 'message-overlay' : undefined} style={overlayActive && rect ? { width: rect.w } : undefined} > - + + + {showMessageReactions ? ( { ) : null} - {overlayActive && rect ? ( + {overlayActive && rect && overlayItemsAnchorRect ? ( { const { width: w, height: h } = e.nativeEvent.layout; setOverlayBottomH({ h, w, - x: isMyMessage ? screenW - rect.x - w : rect.x, + x: + alignment === 'right' + ? overlayItemsAnchorRect.x + overlayItemsAnchorRect.w - w + : overlayItemsAnchorRect.x, y: rect.y + rect.h, }); }} diff --git a/package/src/components/Message/MessageItemView/MessageBubble.tsx b/package/src/components/Message/MessageItemView/MessageBubble.tsx index f142c620fc..15ab0e8544 100644 --- a/package/src/components/Message/MessageItemView/MessageBubble.tsx +++ b/package/src/components/Message/MessageItemView/MessageBubble.tsx @@ -10,80 +10,16 @@ import Animated, { withSpring, } from 'react-native-reanimated'; -import { MessageContentProps } from './MessageContent'; import { MessageItemViewPropsWithContext } from './MessageItemView'; import { MessagesContextValue, useTheme } from '../../../contexts'; import { NativeHandlers } from '../../../native'; -import { MessageStatusTypes } from '../../../utils/utils'; - -export type MessageBubbleProps = Pick< - MessagesContextValue, - | 'reactionListPosition' - | 'MessageContent' - | 'ReactionListTop' - | 'MessageError' - | 'reactionListType' -> & - Pick< - MessageContentProps, - | 'isVeryLastMessage' - | 'backgroundColor' - | 'messageGroupedSingleOrBottom' - | 'noBorder' - | 'message' - > & - Pick; - -export const MessageBubble = React.memo( - ({ - alignment, - reactionListPosition, - reactionListType, - MessageContent, - ReactionListTop, - backgroundColor, - isVeryLastMessage, - messageGroupedSingleOrBottom, - noBorder, - MessageError, - message, - }: MessageBubbleProps) => { - const styles = useStyles({ alignment }); - const isMessageErrorType = - message?.type === 'error' || message?.status === MessageStatusTypes.FAILED; - - return ( - - {reactionListPosition === 'top' && ReactionListTop ? ( - - - - ) : null} - - - - {isMessageErrorType ? ( - - - - ) : null} - - - ); - }, -); const AnimatedWrapper = Animated.createAnimatedComponent(View); type SwipableMessageWrapperProps = Pick & - Pick & { + Pick & { children: ReactNode; onSwipe: () => void; }; @@ -91,7 +27,7 @@ type SwipableMessageWrapperProps = Pick { const { MessageSwipeContent, children, messageSwipeToReplyHitSlop, onSwipe } = props; - const styles = useStyles({ alignment: props.alignment }); + const styles = useStyles(); const translateX = useSharedValue(0); const touchStart = useSharedValue<{ x: number; y: number } | null>(null); @@ -187,14 +123,10 @@ export const SwipableMessageWrapper = React.memo((props: SwipableMessageWrapperP ); }); -const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => { +const useStyles = () => { const { theme: { - messageItemView: { - bubble: { contentContainer, errorContainer, reactionListTopContainer, wrapper }, - contentWrapper, - swipeContentContainer, - }, + messageItemView: { contentWrapper, swipeContentContainer }, }, } = useTheme(); return useMemo(() => { @@ -205,34 +137,9 @@ const useStyles = ({ alignment }: { alignment?: 'left' | 'right' }) => { zIndex: 1, // To hide the stick inside the message content ...contentWrapper, }, - contentContainer: { - alignSelf: alignment === 'left' ? 'flex-start' : 'flex-end', - ...contentContainer, - }, swipeContentContainer: { ...swipeContentContainer, }, - errorContainer: { - position: 'absolute', - top: 8, - right: -12, - ...errorContainer, - }, - reactionListTopContainer: { - alignSelf: alignment === 'left' ? 'flex-end' : 'flex-start', - ...reactionListTopContainer, - }, - wrapper: { - ...wrapper, - }, }); - }, [ - alignment, - contentContainer, - contentWrapper, - errorContainer, - reactionListTopContainer, - swipeContentContainer, - wrapper, - ]); + }, [contentWrapper, swipeContentContainer]); }; diff --git a/package/src/components/Message/MessageItemView/MessageItemView.tsx b/package/src/components/Message/MessageItemView/MessageItemView.tsx index 58fb0f2dc1..b21f5a477f 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 } from 'react'; +import React, { useMemo } from 'react'; import { Dimensions, StyleSheet, View, ViewStyle } from 'react-native'; -import { MessageBubble, SwipableMessageWrapper } from './MessageBubble'; +import { SwipableMessageWrapper } from './MessageBubble'; import { Alignment, @@ -43,6 +43,10 @@ const useStyles = ({ theme: { messageItemView: { container, + bubbleContentContainer, + bubbleErrorContainer, + bubbleReactionListTopContainer, + bubbleWrapper, contentContainer, repliesContainer, leftAlignItems, @@ -78,6 +82,23 @@ const useStyles = ({ gap: primitives.spacingXxs, ...contentContainer, }, + bubbleContentContainer: { + alignSelf: alignment === 'left' ? 'flex-start' : 'flex-end', + ...bubbleContentContainer, + }, + bubbleErrorContainer: { + position: 'absolute', + top: 8, + right: -12, + ...bubbleErrorContainer, + }, + bubbleReactionListTopContainer: { + alignSelf: alignment === 'left' ? 'flex-end' : 'flex-start', + ...bubbleReactionListTopContainer, + }, + bubbleWrapper: { + ...bubbleWrapper, + }, repliesContainer: { marginTop: -primitives.spacingXxs, // Reducing the margin to account the gap added in the content container ...repliesContainer, @@ -91,7 +112,18 @@ const useStyles = ({ ...rightAlignItems, }, }), - [alignment, container, contentContainer, leftAlignItems, repliesContainer, rightAlignItems], + [ + alignment, + bubbleContentContainer, + bubbleErrorContainer, + bubbleReactionListTopContainer, + bubbleWrapper, + container, + contentContainer, + leftAlignItems, + repliesContainer, + rightAlignItems, + ], ); const groupStylesMap = useMemo(() => { @@ -151,6 +183,10 @@ const useStyles = ({ return { container: containerStyle, + bubbleContentContainer: styles.bubbleContentContainer, + bubbleErrorContainer: styles.bubbleErrorContainer, + bubbleReactionListTopContainer: styles.bubbleReactionListTopContainer, + bubbleWrapper: styles.bubbleWrapper, contentContainer: styles.contentContainer, repliesContainer: styles.repliesContainer, leftAlignItems: styles.leftAlignItems, @@ -169,6 +205,7 @@ export type MessageItemViewPropsWithContext = Pick< | 'otherAttachments' | 'setQuotedMessage' | 'lastGroupMessage' + | 'contextMenuAnchorRef' | 'members' > & Pick< @@ -193,159 +230,160 @@ export type MessageItemViewPropsWithContext = Pick< | 'ReactionListTop' >; -const MessageItemViewWithContext = forwardRef( - (props, ref) => { - const { width } = Dimensions.get('screen'); - const { - alignment, - channel, - customMessageSwipeAction, - enableMessageGroupingByUser, - enableSwipeToReply, - groupStyles, - isMyMessage, - message, - MessageAuthor, - MessageContent, - MessageDeleted, - MessageError, - MessageFooter, - MessageHeader, - MessageReplies, - MessageSpacer, - MessageSwipeContent, - messageSwipeToReplyHitSlop = { left: width, right: width }, - onlyEmojis, - otherAttachments, - ReactionListBottom, - reactionListPosition, - reactionListType, - ReactionListTop, - setQuotedMessage, - } = props; - - const { - theme: { - semantics, - messageItemView: { - content: { errorContainer }, - }, +const MessageItemViewWithContext = (props: MessageItemViewPropsWithContext) => { + const { width } = Dimensions.get('screen'); + const { + alignment, + channel, + contextMenuAnchorRef, + customMessageSwipeAction, + enableMessageGroupingByUser, + enableSwipeToReply, + groupStyles, + isMyMessage, + message, + MessageAuthor, + MessageContent, + MessageDeleted, + MessageError, + MessageFooter, + MessageHeader, + MessageReplies, + MessageSpacer, + MessageSwipeContent, + messageSwipeToReplyHitSlop = { left: width, right: width }, + onlyEmojis, + otherAttachments, + ReactionListBottom, + reactionListPosition, + reactionListType, + ReactionListTop, + setQuotedMessage, + } = props; + + const { + theme: { + semantics, + messageItemView: { + content: { errorContainer }, }, - } = useTheme(); - - const { - isMessageErrorType, - isMessageReceivedOrErrorType, - isMessageTypeDeleted, - isVeryLastMessage, - messageGroupedSingle, - messageGroupedBottom, - messageGroupedTop, - messageGroupedSingleOrBottom, - messageGroupedMiddle, - } = useMessageData({}); - - const styles = useStyles({ - alignment, - isVeryLastMessage, - messageGroupedSingle, - messageGroupedBottom, - messageGroupedTop, - messageGroupedMiddle, - enableMessageGroupingByUser, - }); - - const groupStyle = `${alignment}_${groupStyles?.[0]?.toLowerCase?.()}`; - - let noBorder = onlyEmojis && !message.quoted_message; - if (otherAttachments.length) { - if (otherAttachments[0].type === 'giphy' && !isMyMessage) { - noBorder = false; - } else { - noBorder = true; - } + }, + } = useTheme(); + + const { + isMessageErrorType, + isMessageReceivedOrErrorType, + isMessageTypeDeleted, + isVeryLastMessage, + messageGroupedSingle, + messageGroupedBottom, + messageGroupedTop, + messageGroupedSingleOrBottom, + messageGroupedMiddle, + } = useMessageData({}); + + const styles = useStyles({ + alignment, + isVeryLastMessage, + messageGroupedSingle, + messageGroupedBottom, + messageGroupedTop, + messageGroupedMiddle, + enableMessageGroupingByUser, + }); + + const groupStyle = `${alignment}_${groupStyles?.[0]?.toLowerCase?.()}`; + + let noBorder = onlyEmojis && !message.quoted_message; + if (otherAttachments.length) { + if (otherAttachments[0].type === 'giphy' && !isMyMessage) { + noBorder = false; + } else { + noBorder = true; } + } - let backgroundColor = semantics.chatBgOutgoing; - if (onlyEmojis && !message.quoted_message) { + let backgroundColor = semantics.chatBgOutgoing; + if (onlyEmojis && !message.quoted_message) { + backgroundColor = 'transparent'; + } else if (otherAttachments.length) { + if (otherAttachments[0].type === 'giphy') { backgroundColor = 'transparent'; - } else if (otherAttachments.length) { - if (otherAttachments[0].type === 'giphy') { - backgroundColor = 'transparent'; - } - } else if (isMessageReceivedOrErrorType) { - backgroundColor = semantics.chatBgIncoming; } + } else if (isMessageReceivedOrErrorType) { + backgroundColor = semantics.chatBgIncoming; + } - const onSwipeActionHandler = useStableCallback(() => { - if (customMessageSwipeAction) { - customMessageSwipeAction({ channel, message }); - return; - } - setQuotedMessage(message); - }); - - const itemViewContent = ( - - {alignment === 'left' ? : null} - {isMessageTypeDeleted ? ( - - ) : ( - - - - - - + const onSwipeActionHandler = useStableCallback(() => { + if (customMessageSwipeAction) { + customMessageSwipeAction({ channel, message }); + return; + } + setQuotedMessage(message); + }); + + const itemViewContent = ( + + {alignment === 'left' ? : null} + {isMessageTypeDeleted ? ( + + ) : ( + + + + {reactionListPosition === 'top' && ReactionListTop ? ( + + + + ) : null} + + + {isMessageErrorType ? ( + + + + ) : null} + - {reactionListPosition === 'bottom' && ReactionListBottom ? ( - - ) : null} - + + - )} - {MessageSpacer ? : null} - - ); - - return ( - - {enableSwipeToReply && !isMessageTypeDeleted ? ( - - {itemViewContent} - - ) : ( - itemViewContent - )} - - ); - }, -); + + {reactionListPosition === 'bottom' && ReactionListBottom ? ( + + ) : null} + + + )} + {MessageSpacer ? : null} + + ); + + return enableSwipeToReply && !isMessageTypeDeleted ? ( + + {itemViewContent} + + ) : ( + itemViewContent + ); +}; const areEqual = ( prevProps: MessageItemViewPropsWithContext, @@ -476,13 +514,14 @@ export type MessageItemViewProps = Partial; * * Message UI component */ -export const MessageItemView = forwardRef((props, ref) => { +export const MessageItemView = (props: MessageItemViewProps) => { const { alignment, channel, groupStyles, isMyMessage, message, + contextMenuAnchorRef, onlyEmojis, otherAttachments, setQuotedMessage, @@ -516,6 +555,7 @@ export const MessageItemView = forwardRef((props, re {...{ alignment, channel, + contextMenuAnchorRef, customMessageSwipeAction, enableMessageGroupingByUser, enableSwipeToReply, @@ -543,10 +583,9 @@ export const MessageItemView = forwardRef((props, re lastGroupMessage, members, }} - ref={ref} {...props} /> ); -}); +}; MessageItemView.displayName = 'MessageItemView{messageItemView{container}}'; diff --git a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js index d855bc4aca..14d7e5a28a 100644 --- a/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js +++ b/package/src/components/Message/MessageItemView/__tests__/MessageItemView.test.js @@ -6,6 +6,7 @@ import { GestureDetector } from 'react-native-gesture-handler'; import { cleanup, render, screen, waitFor } from '@testing-library/react-native'; import { ChannelsStateProvider } from '../../../../contexts/channelsStateContext/ChannelsStateContext'; +import { useMessageContext } from '../../../../contexts/messageContext/MessageContext'; import { getOrCreateChannelApi } from '../../../../mock-builders/api/getOrCreateChannel'; import { useMockedApis } from '../../../../mock-builders/api/useMockedApis'; @@ -121,6 +122,23 @@ describe('MessageItemView', () => { }); }); + it('exposes contextMenuAnchorRef through MessageContext for custom renderers', async () => { + const user = generateUser(); + const message = generateMessage({ user }); + + const CustomMessageItemView = () => { + const { contextMenuAnchorRef } = useMessageContext(); + + return Custom Message Item; + }; + + renderMessage({ message }, { MessageItemView: CustomMessageItemView }); + + await waitFor(() => { + expect(screen.queryByText('Custom Message Item')).not.toBeNull(); + }); + }); + it('renders MessageSpacer component if defined', async () => { const user = generateUser(); const message = generateMessage({ user }); diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index 7940da517c..b109827be6 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -18,6 +18,7 @@ export const useCreateMessageContext = ({ actionsEnabled, alignment, channel, + contextMenuAnchorRef, deliveredToCount, dismissOverlay, files, @@ -71,6 +72,7 @@ export const useCreateMessageContext = ({ actionsEnabled, alignment, channel, + contextMenuAnchorRef, deliveredToCount, dismissOverlay, files, diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index edb3fc9d63..58ac45eb66 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -1,4 +1,5 @@ import React, { PropsWithChildren, useContext } from 'react'; +import type { View } from 'react-native'; import type { Attachment, LocalMessage } from 'stream-chat'; @@ -56,6 +57,11 @@ export type MessageContextValue = { lastGroupMessage: boolean; /** Current [message object](https://getstream.io/chat/docs/#message_format) */ message: LocalMessage; + /** + * Ref to the view that the message context menu should align with. + * Custom message renderers can attach this to a different subview if needed. + */ + contextMenuAnchorRef: React.RefObject; /** * Stable UI-instance identifier for the rendered message. * Used for overlay state so two rendered instances of the same message do not collide. diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 69640e1ea1..b59fc80a99 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -583,6 +583,10 @@ export type Theme = { }; messageItemView: { blockedMessageContainer: ViewStyle; + bubbleContentContainer: ViewStyle; + bubbleErrorContainer: ViewStyle; + bubbleReactionListTopContainer: ViewStyle; + bubbleWrapper: ViewStyle; actions: { button: ViewStyle & { defaultBackgroundColor?: ViewStyle['backgroundColor']; @@ -728,12 +732,6 @@ export type Theme = { container: ViewStyle; text: TextStyle; }; - bubble: { - reactionListTopContainer: ViewStyle; - contentContainer: ViewStyle; - wrapper: ViewStyle; - errorContainer: ViewStyle; - }; pinnedHeader: { container: ViewStyle; label: TextStyle; @@ -1491,6 +1489,10 @@ export const defaultTheme: Theme = { }, messageItemView: { blockedMessageContainer: {}, + bubbleContentContainer: {}, + bubbleErrorContainer: {}, + bubbleReactionListTopContainer: {}, + bubbleWrapper: {}, actions: { button: {}, buttonText: {}, @@ -1635,12 +1637,6 @@ export const defaultTheme: Theme = { container: {}, text: {}, }, - bubble: { - reactionListTopContainer: {}, - contentContainer: {}, - wrapper: {}, - errorContainer: {}, - }, messageGroupedSingleOrBottomContainer: {}, messageGroupedTopContainer: {}, pinnedHeader: {