diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index cdb30cf2d..eb67653f5 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,355 +1,415 @@ // @flow import Icon from '@expo/vector-icons/Feather.js'; import invariant from 'invariant'; import * as React from 'react'; import { StyleSheet, View } from 'react-native'; -import Animated, { +import { useDerivedValue, withTiming, interpolateColor, useAnimatedStyle, } from 'react-native-reanimated'; 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 { type InputState, InputStateContext } from '../input/input-state.js'; -import { type Colors, useColors } from '../themes/colors.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, AnimatedView } from '../types/styles.js'; -/* eslint-disable import/no-named-as-default-member */ -const { Node } = Animated; -/* eslint-enable import/no-named-as-default-member */ - type SwipeOptions = 'reply' | 'sidebar' | 'both' | 'none'; -type BaseProps = { +type Props = { ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +swipeOptions: SwipeOptions, +shouldDisplayPinIndicator: boolean, +children: React.Node, }; -type Props = { - ...BaseProps, - // Redux state - +composedMessageMaxWidth: number, - +colors: Colors, - +contentAndHeaderOpacity: number | Node, - +deliveryIconOpacity: number | Node, - // withInputState - +inputState: ?InputState, - +navigateToSidebar: () => mixed, - +editedMessageStyle: AnimatedStyleObj, -}; -class ComposedMessage extends React.PureComponent { - render() { - assertComposableMessageType(this.props.item.messageInfo.type); + +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, - focused, swipeOptions, shouldDisplayPinIndicator, children, - composedMessageMaxWidth, - colors, - inputState, - navigateToSidebar, - contentAndHeaderOpacity, - deliveryIconOpacity, - editedMessageStyle, + focused, ...viewProps - } = this.props; - const { id, creator } = item.messageInfo; + } = props; + const { hasBeenEdited, isPinned } = item; + const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; - let containerMarginBottom = 5; - if (item.endsCluster) { - containerMarginBottom += clusterEndHeight; - } - const containerStyle = { marginBottom: containerMarginBottom }; - - const messageBoxContainerStyle = [styles.messageBoxContainer]; - const positioningStyle = isViewer - ? styles.rightChatContainer - : styles.leftChatContainer; - messageBoxContainerStyle.push(positioningStyle); - - let deliveryIcon = null; - let failedSendInfo = null; - if (isViewer) { + 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}`; + if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; - failedSendInfo = ; } else { deliveryIconName = 'circle'; } const animatedStyle: AnimatedStyleObj = { opacity: deliveryIconOpacity }; - deliveryIcon = ( + + return ( ); - } + }, [ + colors.redText, + deliveryIconOpacity, + id, + isViewer, + 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' - ? this.reply - : undefined; + swipeOptions === 'reply' || swipeOptions === 'both' ? reply : undefined; + const triggerSidebar = swipeOptions === 'sidebar' || swipeOptions === 'both' ? navigateToSidebar : undefined; - let avatar; - if (!isViewer && item.endsCluster) { - avatar = ( - - - - ); - } else if (!isViewer) { - avatar = ; - } + const avatar = React.useMemo(() => { + if (!isViewer && item.endsCluster) { + return ( + + + + ); + } else if (!isViewer) { + return ; + } else { + return undefined; + } + }, [isViewer, item.endsCluster, item.messageInfo.creator.id]); const pinIconPositioning = isViewer ? 'left' : 'right'; const pinIconName = pinIconPositioning === 'left' ? 'pin-mirror' : 'pin'; const messageBoxTopLevelContainerStyle = pinIconPositioning === 'left' ? styles.rightMessageBoxTopLevelContainerStyle : styles.leftMessageBoxTopLevelContainerStyle; - let pinIcon; - if (isPinned && shouldDisplayPinIndicator) { - pinIcon = ( + const pinIcon = React.useMemo(() => { + if (!isPinned || !shouldDisplayPinIndicator) { + return undefined; + } + return ( ); - } - - const messageBoxStyle = { - opacity: contentAndHeaderOpacity, - maxWidth: composedMessageMaxWidth, - }; - - const messageBox = ( - - {pinIcon} - - - {avatar} - {children} - + }, [ + 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, + ], ); - let inlineEngagement = null; - const label = getMessageLabel(hasBeenEdited, item.threadInfo.id); - if ( - item.threadCreatedFromMessage || - Object.keys(item.reactions).length > 0 || - label - ) { + const inlineEngagement = React.useMemo(() => { + const label = getMessageLabel(hasBeenEdited, item.threadInfo.id); + if ( + !item.threadCreatedFromMessage && + Object.keys(item.reactions).length <= 0 && + !label + ) { + return undefined; + } const positioning = isViewer ? 'right' : 'left'; - inlineEngagement = ( + return ( ); - } + }, [ + hasBeenEdited, + isViewer, + item.messageInfo, + item.reactions, + item.threadCreatedFromMessage, + item.threadInfo, + ]); - const viewStyle = [styles.alignment]; - 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 + const viewStyle = React.useMemo(() => { + const baseStyle = [styles.alignment]; + if (__DEV__) { + return baseStyle; + } if (item.messageShapeType === 'text') { - viewStyle.push({ height: item.contentHeight }); + baseStyle.push({ height: item.contentHeight }); } else if (item.messageShapeType === 'multimedia') { const height = item.inlineEngagementHeight ? item.contentHeight + item.inlineEngagementHeight : item.contentHeight; - viewStyle.push({ height }); + baseStyle.push({ height }); } - } + return baseStyle; + }, [ + item.contentHeight, + item.inlineEngagementHeight, + item.messageShapeType, + ]); - return ( - - - - - - - - {deliveryIcon} - {messageBox} - - {inlineEngagement} - - {failedSendInfo} - - + const messageHeaderStyle = React.useMemo( + () => ({ + opacity: contentAndHeaderOpacity, + }), + [contentAndHeaderOpacity], + ); + + const animatedContainerStyle = React.useMemo( + () => [containerStyle, editedMessageStyle], + [containerStyle, editedMessageStyle], ); - } - - reply = () => { - const { inputState, item } = this.props; - invariant(inputState, 'inputState should be set in reply'); - invariant(item.messageInfo.text, 'text should be set in reply'); - inputState.editInputMessage({ - message: createMessageReply(item.messageInfo.text), - mode: 'prepend', - }); - }; -} + + 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: { marginRight: 8, }, 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', }, }); -const ConnectedComposedMessage: React.ComponentType = - React.memo(function ConnectedComposedMessage(props: BaseProps) { - 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, - }; - }); - - return ( - - ); - }); - export default ConnectedComposedMessage;