diff --git a/lib/shared/chat-message-item-utils.js b/lib/shared/chat-message-item-utils.js new file mode 100644 index 000000000..9f034d7d4 --- /dev/null +++ b/lib/shared/chat-message-item-utils.js @@ -0,0 +1,25 @@ +// @flow + +import type { ReactionInfo } from '../selectors/chat-selectors.js'; +import { getMessageLabel } from '../shared/edit-messages-utils.js'; +import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; + +type BaseChatMessageItemForEngagementCheck = { + +threadCreatedFromMessage: ?ThreadInfo, + +reactions: ReactionInfo, + +hasBeenEdited?: ?boolean, + ... +}; +function chatMessageItemHasEngagement( + item: BaseChatMessageItemForEngagementCheck, + threadID: string, +): boolean { + const label = getMessageLabel(item.hasBeenEdited, threadID); + return ( + !!label || + !!item.threadCreatedFromMessage || + Object.keys(item.reactions).length > 0 + ); +} + +export { chatMessageItemHasEngagement }; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 21d8437c3..f97c89bbf 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,444 +1,434 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View, TouchableOpacity } from 'react-native'; import { useDerivedValue, withTiming, interpolateColor, useAnimatedStyle, } from 'react-native-reanimated'; +import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import { clusterEndHeight, composedMessageStyle, avatarOffset, } from './chat-constants.js'; import { useComposedMessageMaxWidth } from './composed-message-width.js'; import { FailedSend } from './failed-send.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { MessageEditingContext } from './message-editing-context.react.js'; import { MessageHeader } from './message-header.react.js'; import { useNavigateToSidebar } from './sidebar-navigation.js'; import SwipeableMessage from './swipeable-message.react.js'; import { useContentAndHeaderOpacity, useDeliveryIconOpacity } from './utils.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import CommIcon from '../components/comm-icon.react.js'; import { InputStateContext } from '../input/input-state.js'; import { useColors } from '../themes/colors.js'; import type { ChatMessageInfoItemWithHeight } from '../types/chat-types.js'; import { type AnimatedStyleObj, type ViewStyle, AnimatedView, } from '../types/styles.js'; import { useNavigateToUserProfileBottomSheet } from '../user-profile/user-profile-utils.js'; type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; type Props = { ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, +shouldDisplayPinIndicator: boolean, +children: React.Node, }; const ConnectedComposedMessage: React.ComponentType = React.memo( function ConnectedComposedMessage(props: Props) { const composedMessageMaxWidth = useComposedMessageMaxWidth(); const colors = useColors(); const inputState = React.useContext(InputStateContext); const navigateToSidebar = useNavigateToSidebar(props.item); const contentAndHeaderOpacity = useContentAndHeaderOpacity(props.item); const deliveryIconOpacity = useDeliveryIconOpacity(props.item); const messageEditingContext = React.useContext(MessageEditingContext); const progress = useDerivedValue(() => { const isThisThread = messageEditingContext?.editState.editedMessage?.threadID === props.item.threadInfo.id; const isHighlighted = messageEditingContext?.editState.editedMessage?.id === props.item.messageInfo.id && isThisThread; return withTiming(isHighlighted ? 1 : 0); }); const editedMessageStyle = useAnimatedStyle(() => { const backgroundColor = interpolateColor( progress.value, [0, 1], ['transparent', `#${props.item.threadInfo.color}40`], ); return { backgroundColor, }; }); assertComposableMessageType(props.item.messageInfo.type); const { item, sendFailed, swipeOptions, shouldDisplayPinIndicator, children, focused, ...viewProps } = props; const { hasBeenEdited, isPinned } = item; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; const containerStyle = React.useMemo(() => { let containerMarginBottom = 5; if (item.endsCluster) { containerMarginBottom += clusterEndHeight; } return { marginBottom: containerMarginBottom }; }, [item.endsCluster]); const messageBoxContainerStyle = React.useMemo( () => [ styles.messageBoxContainer, isViewer ? styles.rightChatContainer : styles.leftChatContainer, ], [isViewer], ); const deliveryIcon = React.useMemo(() => { if (!isViewer) { return undefined; } let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; const notDeliveredP2PMessages = item?.localMessageInfo?.outboundP2PMessageIDs ?? []; if ( id !== null && id !== undefined && notDeliveredP2PMessages.length === 0 ) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; return ( ); }, [ colors.redText, deliveryIconOpacity, id, isViewer, item?.localMessageInfo?.outboundP2PMessageIDs, item.threadInfo.color, sendFailed, ]); const editInputMessage = inputState?.editInputMessage; const reply = React.useCallback(() => { invariant(editInputMessage, 'editInputMessage should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); editInputMessage({ message: createMessageReply(item.messageInfo.text), mode: 'prepend', }); }, [editInputMessage, item.messageInfo.text]); const triggerReply = swipeOptions === 'reply' || swipeOptions === 'both' ? reply : undefined; const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; const navigateToUserProfileBottomSheet = useNavigateToUserProfileBottomSheet(); const onPressAvatar = React.useCallback( () => navigateToUserProfileBottomSheet(item.messageInfo.creator.id), [item.messageInfo.creator.id, navigateToUserProfileBottomSheet], ); const avatar = React.useMemo(() => { if (!isViewer && item.endsCluster) { return ( ); } else if (!isViewer) { return ; } else { return undefined; } }, [ isViewer, item.endsCluster, item.messageInfo.creator.id, onPressAvatar, ]); const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const messageBoxTopLevelContainerStyle = pinIconPositioning === 'left' ? styles.rightMessageBoxTopLevelContainerStyle : styles.leftMessageBoxTopLevelContainerStyle; const pinIcon = React.useMemo(() => { if (!isPinned || !shouldDisplayPinIndicator) { return undefined; } return ( ); }, [ isPinned, item.threadInfo.color, pinIconName, shouldDisplayPinIndicator, ]); const messageBoxStyle = React.useMemo( () => ({ opacity: contentAndHeaderOpacity, maxWidth: composedMessageMaxWidth, }), [composedMessageMaxWidth, contentAndHeaderOpacity], ); const messageBox = React.useMemo( () => ( {pinIcon} {avatar} {children} ), [ avatar, children, isViewer, item.threadInfo.color, messageBoxContainerStyle, messageBoxStyle, messageBoxTopLevelContainerStyle, pinIcon, triggerReply, triggerSidebar, ], ); + const label = getMessageLabel(hasBeenEdited, item.threadInfo.id); const inlineEngagement = React.useMemo(() => { - const label = getMessageLabel(hasBeenEdited, item.threadInfo.id); - if ( - !item.threadCreatedFromMessage && - Object.keys(item.reactions).length <= 0 && - !label - ) { + if (!chatMessageItemHasEngagement(item, item.threadInfo.id)) { return undefined; } const positioning = isViewer ? 'right' : 'left'; return ( ); - }, [ - hasBeenEdited, - isViewer, - item.messageInfo, - item.reactions, - item.threadCreatedFromMessage, - item.threadInfo, - ]); + }, [label, isViewer, item]); const viewStyle = React.useMemo(() => { const baseStyle: Array = [styles.alignment]; if (__DEV__) { return baseStyle; } if (item.messageShapeType === 'text') { baseStyle.push({ height: item.contentHeight }); } else if (item.messageShapeType === 'multimedia') { const height = item.inlineEngagementHeight ? item.contentHeight + item.inlineEngagementHeight : item.contentHeight; baseStyle.push({ height }); } return baseStyle; }, [ item.contentHeight, item.inlineEngagementHeight, item.messageShapeType, ]); const messageHeaderStyle = React.useMemo( () => ({ opacity: contentAndHeaderOpacity, }), [contentAndHeaderOpacity], ); const animatedContainerStyle = React.useMemo( () => [containerStyle, editedMessageStyle], [containerStyle, editedMessageStyle], ); const contentStyle = React.useMemo( () => [styles.content, alignStyle], [alignStyle], ); const failedSend = React.useMemo( () => (sendFailed ? : undefined), [item, sendFailed], ); const composedMessage = React.useMemo(() => { return ( {deliveryIcon} {messageBox} {inlineEngagement} {failedSend} ); }, [ animatedContainerStyle, contentStyle, deliveryIcon, failedSend, focused, inlineEngagement, item, messageBox, messageHeaderStyle, viewProps, viewStyle, ]); return composedMessage; }, ); const styles = StyleSheet.create({ alignment: { marginLeft: composedMessageStyle.marginLeft, marginRight: composedMessageStyle.marginRight, }, avatarContainer: { paddingRight: 8, paddingTop: 4, }, avatarOffset: { width: avatarOffset, }, content: { alignItems: 'center', flexDirection: 'row-reverse', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, leftChatBubble: { justifyContent: 'flex-end', }, leftChatContainer: { alignItems: 'flex-start', }, leftMessageBoxTopLevelContainerStyle: { flexDirection: 'row-reverse', }, messageBoxContainer: { marginRight: 5, }, pinIconContainer: { marginRight: 4, marginTop: 4, }, rightChatBubble: { justifyContent: 'flex-start', }, rightChatContainer: { alignItems: 'flex-end', }, rightMessageBoxTopLevelContainerStyle: { flexDirection: 'row', }, swipeableContainer: { alignItems: 'flex-end', flexDirection: 'row', }, }); export default ConnectedComposedMessage; diff --git a/native/chat/multimedia-message-tooltip-button.react.js b/native/chat/multimedia-message-tooltip-button.react.js index 7a01afc96..29bc37403 100644 --- a/native/chat/multimedia-message-tooltip-button.react.js +++ b/native/chat/multimedia-message-tooltip-button.react.js @@ -1,178 +1,179 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; +import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { useViewerAlreadySelectedMessageReactions, useCanCreateReactionFromMessage, } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerMultimediaMessage } from './inner-multimedia-message.react.js'; import { MessageHeader } from './message-header.react.js'; import MessageTooltipButtonAvatar from './message-tooltip-button-avatar.react.js'; import { useSendReaction } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import EmojiKeyboard from '../components/emoji-keyboard.react.js'; import type { EmojiSelection } from '../components/emoji-keyboard.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; const { Node, Extrapolate, interpolateNode } = Animated; function noop() {} type Props = { +navigation: AppNavigationProp<'MultimediaMessageTooltipModal'>, +route: TooltipRoute<'MultimediaMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function MultimediaMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [sidebarInputBarHeight, setSidebarInputBarHeight] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates } = route.params; const { style: messageContainerStyle } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const inlineEngagement = React.useMemo(() => { - if (!item.threadCreatedFromMessage) { + if (!chatMessageItemHasEngagement(item, item.threadInfo.id)) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const innerMultimediaMessage = React.useMemo( () => ( ), [item, navigation.goBackOnce, verticalBounds], ); const { messageInfo, threadInfo, reactions } = item; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction(messageInfo.id, threadInfo, reactions); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( (emoji: EmojiSelection) => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); const alreadySelectedEmojis = useViewerAlreadySelectedMessageReactions(reactions); return ( <> {reactionSelectionPopover} {innerMultimediaMessage} {inlineEngagement} ); } export default MultimediaMessageTooltipButton; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 2d845f803..39af2ff0d 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,240 +1,241 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { View } from 'react-native'; +import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { messageKey } from 'lib/shared/message-utils.js'; import { useCanCreateSidebarFromMessage } from 'lib/shared/sidebar-utils.js'; import { inlineEngagementCenterStyle } from './chat-constants.js'; import type { ChatNavigationProp } from './chat.react.js'; import { InlineEngagement } from './inline-engagement.react.js'; import { InnerRobotextMessage } from './inner-robotext-message.react.js'; import { Timestamp } from './timestamp.react.js'; import { getMessageTooltipKey, useContentAndHeaderOpacity } from './utils.js'; import { ChatContext } from '../chat/chat-context.js'; import { KeyboardContext } from '../keyboard/keyboard-state.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext } from '../navigation/overlay-context.js'; import { RobotextMessageTooltipModalRouteName } from '../navigation/route-names.js'; import type { NavigationRoute } from '../navigation/route-names.js'; import { useStyles } from '../themes/colors.js'; import { fixedTooltipHeight } from '../tooltip/tooltip.react.js'; import type { ChatRobotextMessageInfoItemWithHeight } from '../types/chat-types.js'; import type { VerticalBounds } from '../types/layout-types.js'; import { AnimatedView } from '../types/styles.js'; type Props = { ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: | ChatNavigationProp<'MessageList'> | AppNavigationProp<'TogglePinModal'> | ChatNavigationProp<'PinnedMessagesScreen'> | ChatNavigationProp<'MessageSearch'>, +route: | NavigationRoute<'MessageList'> | NavigationRoute<'TogglePinModal'> | NavigationRoute<'PinnedMessagesScreen'> | NavigationRoute<'MessageSearch'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, }; function RobotextMessage(props: Props): React.Node { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = props; let timestamp = null; if (focused || item.startsConversation) { timestamp = ( ); } const styles = useStyles(unboundStyles); let inlineEngagement = null; - if (item.threadCreatedFromMessage || Object.keys(item.reactions).length > 0) { + if (chatMessageItemHasEngagement(item, item.threadInfo.id)) { inlineEngagement = ( ); } const chatContext = React.useContext(ChatContext); const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); const overlayContext = React.useContext(OverlayContext); const viewRef = React.useRef>(); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( item.threadInfo, item.messageInfo, ); const visibleEntryIDs = React.useMemo(() => { const result = []; if (item.threadCreatedFromMessage || canCreateSidebarFromMessage) { result.push('sidebar'); } return result; }, [item.threadCreatedFromMessage, canCreateSidebarFromMessage]); const openRobotextTooltipModal = React.useCallback( ( x: number, y: number, width: number, height: number, pageX: number, pageY: number, ) => { invariant( verticalBounds, 'verticalBounds should be present in openRobotextTooltipModal', ); const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = fixedTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = fixedTooltipHeight + aboveMargin; let margin = 0; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { margin = aboveMargin; } const currentInputBarHeight = chatContext?.chatInputBarHeights.get(item.threadInfo.id) ?? 0; props.navigation.navigate<'RobotextMessageTooltipModal'>({ name: RobotextMessageTooltipModalRouteName, params: { presentedFrom: props.route.key, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs, tooltipLocation: 'fixed', margin, item, chatInputBarHeight: currentInputBarHeight, }, key: getMessageTooltipKey(item), }); }, [ item, props.navigation, props.route.key, verticalBounds, visibleEntryIDs, chatContext, ], ); const onLongPress = React.useCallback(() => { if (keyboardState && keyboardState.dismissKeyboardIfShowing()) { return; } if (visibleEntryIDs.length === 0) { return; } if (!viewRef.current || !verticalBounds) { return; } if (!focused) { toggleFocus(messageKey(item.messageInfo)); } invariant(overlayContext, 'RobotextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); viewRef.current?.measure(openRobotextTooltipModal); }, [ focused, item, keyboardState, overlayContext, toggleFocus, verticalBounds, viewRef, visibleEntryIDs, openRobotextTooltipModal, ]); const onLayout = React.useCallback(() => {}, []); const contentAndHeaderOpacity = useContentAndHeaderOpacity(item); const viewStyle: { height?: number } = {}; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.height = item.contentHeight; } return ( {timestamp} {inlineEngagement} ); } const unboundStyles = { sidebar: { marginTop: inlineEngagementCenterStyle.topOffset, marginBottom: -inlineEngagementCenterStyle.topOffset, alignSelf: 'center', }, }; export { RobotextMessage }; diff --git a/native/chat/text-message-tooltip-button.react.js b/native/chat/text-message-tooltip-button.react.js index 302c2babd..1c30acea9 100644 --- a/native/chat/text-message-tooltip-button.react.js +++ b/native/chat/text-message-tooltip-button.react.js @@ -1,184 +1,185 @@ // @flow import * as React from 'react'; import Animated from 'react-native-reanimated'; +import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { useViewerAlreadySelectedMessageReactions, useCanCreateReactionFromMessage, } from 'lib/shared/reaction-utils.js'; import { TooltipInlineEngagement } from './inline-engagement.react.js'; import { InnerTextMessage } from './inner-text-message.react.js'; import { MessageHeader } from './message-header.react.js'; import { MessageListContextProvider } from './message-list-types.js'; import { MessagePressResponderContext } from './message-press-responder-context.js'; import MessageTooltipButtonAvatar from './message-tooltip-button-avatar.react.js'; import { useSendReaction } from './reaction-message-utils.js'; import ReactionSelectionPopover from './reaction-selection-popover.react.js'; import SidebarInputBarHeightMeasurer from './sidebar-input-bar-height-measurer.react.js'; import { useAnimatedMessageTooltipButton } from './utils.js'; import EmojiKeyboard from '../components/emoji-keyboard.react.js'; import type { EmojiSelection } from '../components/emoji-keyboard.react.js'; import type { AppNavigationProp } from '../navigation/app-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { useTooltipActions } from '../tooltip/tooltip-hooks.js'; import type { TooltipRoute } from '../tooltip/tooltip.react.js'; const { Node, interpolateNode, Extrapolate } = Animated; type Props = { +navigation: AppNavigationProp<'TextMessageTooltipModal'>, +route: TooltipRoute<'TextMessageTooltipModal'>, +progress: Node, +isOpeningSidebar: boolean, }; function TextMessageTooltipButton(props: Props): React.Node { const { navigation, route, progress, isOpeningSidebar } = props; const windowWidth = useSelector(state => state.dimensions.width); const [sidebarInputBarHeight, setSidebarInputBarHeight] = React.useState(null); const onInputBarMeasured = React.useCallback((height: number) => { setSidebarInputBarHeight(height); }, []); const { item, verticalBounds, initialCoordinates } = route.params; const { style: messageContainerStyle, threadColorOverride, isThreadColorDarkOverride, } = useAnimatedMessageTooltipButton({ sourceMessage: item, initialCoordinates, messageListVerticalBounds: verticalBounds, progress, targetInputBarHeight: sidebarInputBarHeight, }); const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; const opacity = interpolateNode(progress, { inputRange: [0, 0.05], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); return { opacity, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [initialCoordinates.height, initialCoordinates.x, progress, windowWidth]); const messagePressResponderContext = React.useMemo( () => ({ onPressMessage: navigation.goBackOnce, }), [navigation.goBackOnce], ); const inlineEngagement = React.useMemo(() => { - if (!item.threadCreatedFromMessage) { + if (!chatMessageItemHasEngagement(item, item.threadInfo.id)) { return null; } return ( ); }, [initialCoordinates, isOpeningSidebar, item, progress, windowWidth]); const { messageInfo, threadInfo, reactions } = item; const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); const sendReaction = useSendReaction(messageInfo.id, threadInfo, reactions); const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false); const openEmojiPicker = React.useCallback(() => { setEmojiPickerOpen(true); }, []); const reactionSelectionPopover = React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } return ( ); }, [ navigation, route, openEmojiPicker, canCreateReactionFromMessage, sendReaction, ]); const tooltipRouteKey = route.key; const { dismissTooltip } = useTooltipActions(navigation, tooltipRouteKey); const onEmojiSelected = React.useCallback( (emoji: EmojiSelection) => { sendReaction(emoji.emoji); dismissTooltip(); }, [sendReaction, dismissTooltip], ); const alreadySelectedEmojis = useViewerAlreadySelectedMessageReactions(reactions); return ( {reactionSelectionPopover} {inlineEngagement} ); } export default TextMessageTooltipButton; diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 3afa5c581..9c4032882 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,292 +1,282 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { CheckCircle as CheckCircleIcon, Circle as CircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { useStringForUser } from 'lib/hooks/ens-cache.js'; import { type ComposableChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; +import { chatMessageItemHasEngagement } from 'lib/shared/chat-message-item-utils.js'; import { getMessageLabel } from 'lib/shared/edit-messages-utils.js'; import { assertComposableMessageType } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { getComposedMessageID } from './chat-constants.js'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react.js'; import InlineEngagement from './inline-engagement.react.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import CommIcon from '../comm-icon.react.js'; import { usePushUserProfileModal } from '../modals/user-profile/user-profile-utils.js'; import { useMessageTooltip } from '../tooltips/tooltip-action-utils.js'; import { tooltipPositions } from '../tooltips/tooltip-utils.js'; export type ComposedMessageID = string; const availableTooltipPositionsForViewerMessage = [ tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; const availableTooltipPositionsForNonViewerMessage = [ tooltipPositions.RIGHT, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, tooltipPositions.BOTTOM, tooltipPositions.TOP, ]; type Props = { +item: ComposableChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius?: number, }; const ComposedMessage: React.ComponentType = React.memo( function ComposedMessage(props) { const { item, threadInfo } = props; const { messageInfo } = item; const { id, creator } = messageInfo; const { isViewer } = creator; assertComposableMessageType(messageInfo.type); const shouldShowUsername = !isViewer && item.startsCluster; const stringForUser = useStringForUser(shouldShowUsername ? creator : null); const pushUserProfileModal = usePushUserProfileModal(creator.id); const authorName = React.useMemo(() => { if (!stringForUser) { return null; } return ( {stringForUser} ); }, [stringForUser, pushUserProfileModal]); const threadColor = threadInfo.color; const notDeliveredP2PMessages = item?.localMessageInfo?.outboundP2PMessageIDs; const { deliveryIcon, failedSendInfo } = React.useMemo(() => { if (!isViewer) { return { deliveryIcon: null, failedSendInfo: null }; } let returnedFailedSendInfo, deliveryIconSpan; let deliveryIconColor = threadColor; if ( id !== null && id !== undefined && (!notDeliveredP2PMessages || notDeliveredP2PMessages.length === 0) ) { deliveryIconSpan = ; } else if (props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; returnedFailedSendInfo = ( ); } else { deliveryIconSpan = ; } const returnedDeliveryIcon = (
{deliveryIconSpan}
); return { deliveryIcon: returnedDeliveryIcon, failedSendInfo: returnedFailedSendInfo, }; }, [ isViewer, threadColor, id, notDeliveredP2PMessages, props.sendFailed, item, threadInfo, ]); const label = getMessageLabel(item.hasBeenEdited, threadInfo.id); const inlineEngagement = React.useMemo(() => { - if ( - !item.threadCreatedFromMessage && - Object.keys(item.reactions).length === 0 && - !label - ) { + if (!chatMessageItemHasEngagement(item, threadInfo.id)) { return null; } const positioning = isViewer ? 'right' : 'left'; return (
); - }, [ - item.threadCreatedFromMessage, - item.reactions, - label, - isViewer, - item.messageInfo, - threadInfo, - ]); + }, [item, label, isViewer, threadInfo]); const avatar = React.useMemo(() => { if (!isViewer && item.endsCluster) { return (
); } else if (!isViewer) { return
; } return undefined; }, [isViewer, item.endsCluster, pushUserProfileModal, creator.id]); const shouldShowPinIcon = item.isPinned && props.shouldDisplayPinIndicator; const pinIcon = React.useMemo(() => { if (!shouldShowPinIcon) { return null; } const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const pinIconContainerClassName = classNames({ [css.pinIconContainer]: true, [css.pinIconLeft]: pinIconPositioning === 'left', [css.pinIconRight]: pinIconPositioning === 'right', }); return (
); }, [shouldShowPinIcon, isViewer, threadColor]); const availablePositions = isViewer ? availableTooltipPositionsForViewerMessage : availableTooltipPositionsForNonViewerMessage; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions, }); const contentClassName = React.useMemo( () => classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }), [isViewer], ); const messageBoxContainerClassName = React.useMemo( () => classNames({ [css.messageBoxContainer]: true, [css.fixedWidthMessageBoxContainer]: props.fixedWidth, }), [props.fixedWidth], ); const messageBoxClassName = React.useMemo( () => classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: props.fixedWidth, }), [props.fixedWidth], ); const borderRadius = props.borderRadius ?? 8; const messageBoxStyle = React.useMemo( () => ({ borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }), [isViewer, item.startsCluster, item.endsCluster, borderRadius], ); const composedMessageID = getComposedMessageID(item.messageInfo); return React.useMemo( () => ( <> {authorName}
{avatar}
{pinIcon}
{props.children}
{deliveryIcon}
{failedSendInfo} {inlineEngagement} ), [ authorName, contentClassName, avatar, messageBoxContainerClassName, onMouseEnter, onMouseLeave, pinIcon, messageBoxClassName, messageBoxStyle, composedMessageID, props.children, deliveryIcon, failedSendInfo, inlineEngagement, ], ); }, ); export default ComposedMessage;