diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 1688ef147..21d8437c3 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,437 +1,444 @@ // @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 { 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}`; - if (id !== null && id !== undefined) { + 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 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'; return ( ); }, [ hasBeenEdited, isViewer, item.messageInfo, item.reactions, item.threadCreatedFromMessage, item.threadInfo, ]); 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/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 81e1042e7..ed7b10c69 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,257 +1,264 @@ // @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 ChatMessageInfoItem } from 'lib/selectors/chat-selectors.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 { type InputState, InputStateContext } from '../input/input-state.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 BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +shouldDisplayPinIndicator: boolean, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, }; type DefaultProps = { +borderRadius: number }; type BaseConfig = React.Config; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, +onMouseLeave: ?() => mixed, +onMouseEnter: (event: SyntheticEvent) => mixed, +containsInlineEngagement: boolean, +stringForUser: ?string, +onClickUser: () => mixed, }; class ComposedMessage extends React.PureComponent { static defaultProps: DefaultProps = { borderRadius: 8 }; render(): React.Node { assertComposableMessageType(this.props.item.messageInfo.type); const { borderRadius, item, threadInfo, shouldDisplayPinIndicator } = this.props; const { hasBeenEdited, isPinned } = item; const { id, creator } = item.messageInfo; const threadColor = threadInfo.color; const { isViewer } = creator; const contentClassName = classNames({ [css.content]: true, [css.viewerContent]: isViewer, [css.nonViewerContent]: !isViewer, }); const messageBoxContainerClassName = classNames({ [css.messageBoxContainer]: true, [css.fixedWidthMessageBoxContainer]: this.props.fixedWidth, }); const messageBoxClassName = classNames({ [css.messageBox]: true, [css.fixedWidthMessageBox]: this.props.fixedWidth, }); const messageBoxStyle = { borderTopRightRadius: isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomRightRadius: isViewer && !item.endsCluster ? 0 : borderRadius, borderTopLeftRadius: !isViewer && !item.startsCluster ? 0 : borderRadius, borderBottomLeftRadius: !isViewer && !item.endsCluster ? 0 : borderRadius, }; let authorName = null; const { stringForUser } = this.props; if (stringForUser) { authorName = ( {stringForUser} ); } let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconSpan; let deliveryIconColor = threadColor; - if (id !== null && id !== undefined) { + + const notDeliveredP2PMessages = + item?.localMessageInfo?.outboundP2PMessageIDs ?? []; + if ( + id !== null && + id !== undefined && + notDeliveredP2PMessages.length === 0 + ) { deliveryIconSpan = ; } else if (this.props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; failedSendInfo = ; } else { deliveryIconSpan = ; } deliveryIcon = (
{deliveryIconSpan}
); } let inlineEngagement = null; const label = getMessageLabel(hasBeenEdited, threadInfo.id); if ( (this.props.containsInlineEngagement && item.threadCreatedFromMessage) || Object.keys(item.reactions).length > 0 || label ) { const positioning = isViewer ? 'right' : 'left'; inlineEngagement = (
); } let avatar; if (!isViewer && item.endsCluster) { avatar = (
); } else if (!isViewer) { avatar =
; } 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', }); let pinIcon; if (isPinned && shouldDisplayPinIndicator) { pinIcon = (
); } return ( {authorName}
{avatar}
{pinIcon}
{this.props.children}
{deliveryIcon}
{failedSendInfo} {inlineEngagement}
); } } type ConnectedConfig = React.Config< BaseProps, typeof ComposedMessage.defaultProps, >; const ConnectedComposedMessage: React.ComponentType = React.memo(function ConnectedComposedMessage(props) { const { item, threadInfo } = props; const inputState = React.useContext(InputStateContext); const { creator } = props.item.messageInfo; const { isViewer } = creator; const availablePositions = isViewer ? availableTooltipPositionsForViewerMessage : availableTooltipPositionsForNonViewerMessage; const containsInlineEngagement = !!item.threadCreatedFromMessage; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions, }); const shouldShowUsername = !isViewer && item.startsCluster; const stringForUser = useStringForUser(shouldShowUsername ? creator : null); const pushUserProfileModal = usePushUserProfileModal(creator.id); return ( ); }); export default ConnectedComposedMessage;