diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index edbd30807..849ae2216 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,226 +1,190 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { Circle as CircleIcon, CheckCircle as CheckCircleIcon, XCircle as XCircleIcon, } from 'react-feather'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; -import { useSidebarExistsOrCanBeCreated } from 'lib/shared/thread-utils'; import { stringForUser } from 'lib/shared/user-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type InputState, InputStateContext } from '../input/input-state'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react'; import { InlineSidebar } from './inline-sidebar.react'; -import MessageTooltip from './message-tooltip.react'; -import { - type OnMessagePositionWithContainerInfo, - type MessagePositionInfo, -} from './position-types'; -import { tooltipPositions } from './tooltip-utils'; +import { tooltipPositions, useMessageTooltip } from './tooltip-utils'; const availableTooltipPositionsForViewerMessage = [ - tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, + tooltipPositions.LEFT_BOTTOM, + tooltipPositions.LEFT_TOP, + tooltipPositions.RIGHT, + tooltipPositions.RIGHT_BOTTOM, + tooltipPositions.RIGHT_TOP, + tooltipPositions.BOTTOM, + tooltipPositions.TOP, ]; const availableTooltipPositionsForNonViewerMessage = [ - tooltipPositions.LEFT_TOP, 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, +sendFailed: boolean, - +setMouseOverMessagePosition: ( - messagePositionInfo: MessagePositionInfo, - ) => void, - +mouseOverMessagePosition?: ?OnMessagePositionWithContainerInfo, - +canReply: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, }; type BaseConfig = React.Config; type Props = { ...BaseProps, - // Redux state - +sidebarExistsOrCanBeCreated: boolean, // withInputState +inputState: ?InputState, + +onMouseLeave: ?() => mixed, + +onMouseEnter: (event: SyntheticEvent) => mixed, + +containsInlineSidebar: boolean, }; class ComposedMessage extends React.PureComponent { static defaultProps: { +borderRadius: number } = { borderRadius: 8, }; render(): React.Node { assertComposableMessageType(this.props.item.messageInfo.type); const { borderRadius, item, threadInfo } = this.props; 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; if (!isViewer && item.startsCluster) { authorName = ( {stringForUser(creator)} ); } let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconSpan; let deliveryIconColor = threadColor; if (id !== null && id !== undefined) { deliveryIconSpan = ; } else if (this.props.sendFailed) { deliveryIconSpan = ; deliveryIconColor = 'FF0000'; failedSendInfo = ; } else { deliveryIconSpan = ; } deliveryIcon = (
{deliveryIconSpan}
); } - let messageTooltip; - if ( - this.props.mouseOverMessagePosition && - this.props.mouseOverMessagePosition.item.messageInfo.id === id && - (this.props.sidebarExistsOrCanBeCreated || this.props.canReply) - ) { - // eslint-disable-next-line no-unused-vars - const availableTooltipPositions = isViewer - ? availableTooltipPositionsForViewerMessage - : availableTooltipPositionsForNonViewerMessage; - - messageTooltip = ; - } - - let messageTooltipLinks; - if (messageTooltip) { - const tooltipLinksClassName = classNames({ - [css.messageTooltipActiveArea]: true, - [css.viewerMessageTooltipActiveArea]: isViewer, - [css.nonViewerMessageActiveArea]: !isViewer, - }); - - messageTooltipLinks = ( -
{messageTooltip}
- ); - } - - const viewerTooltipLinks = isViewer ? messageTooltipLinks : null; - const nonViewerTooltipLinks = !isViewer ? messageTooltipLinks : null; - let inlineSidebar = null; - if (item.threadCreatedFromMessage) { + if (this.props.containsInlineSidebar && item.threadCreatedFromMessage) { const positioning = isViewer ? 'right' : 'left'; inlineSidebar = (
); } return ( {authorName}
- {viewerTooltipLinks}
{this.props.children}
- {nonViewerTooltipLinks}
{deliveryIcon}
{failedSendInfo} {inlineSidebar}
); } - - onMouseEnter: (event: SyntheticEvent) => void = event => { - const { item } = this.props; - const rect = event.currentTarget.getBoundingClientRect(); - const { top, bottom, left, right, height, width } = rect; - const messagePosition = { top, bottom, left, right, height, width }; - this.props.setMouseOverMessagePosition({ - type: 'on', - item, - messagePosition, - }); - }; - - onMouseLeave: () => void = () => { - const { item } = this.props; - this.props.setMouseOverMessagePosition({ type: 'off', item }); - }; } type ConnectedConfig = React.Config< BaseProps, typeof ComposedMessage.defaultProps, >; const ConnectedComposedMessage: React.ComponentType = React.memo( function ConnectedComposedMessage(props) { - const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( - props.threadInfo, - props.item, - ); + const { item, threadInfo } = props; const inputState = React.useContext(InputStateContext); + const isViewer = props.item.messageInfo.creator.isViewer; + const availablePositions = isViewer + ? availableTooltipPositionsForViewerMessage + : availableTooltipPositionsForNonViewerMessage; + const containsInlineSidebar = !!item.threadCreatedFromMessage; + + const { onMouseLeave, onMouseEnter } = useMessageTooltip({ + item, + threadInfo, + availablePositions, + }); + return ( ); }, ); export default ConnectedComposedMessage; diff --git a/web/chat/message.react.js b/web/chat/message.react.js index d637222c4..680f88161 100644 --- a/web/chat/message.react.js +++ b/web/chat/message.react.js @@ -1,81 +1,67 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { longAbsoluteDate } from 'lib/utils/date-utils'; import css from './chat-message-list.css'; import MultimediaMessage from './multimedia-message.react'; import { type OnMessagePositionWithContainerInfo, type MessagePositionInfo, } from './position-types'; import RobotextMessage from './robotext-message.react'; import TextMessage from './text-message.react'; type Props = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, +setMouseOverMessagePosition: ( messagePositionInfo: MessagePositionInfo, ) => void, +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, +timeZone: ?string, }; function Message(props: Props): React.Node { const { item, timeZone } = props; let conversationHeader = null; if (item.startsConversation) { conversationHeader = (
{longAbsoluteDate(item.messageInfo.time, timeZone)}
); } let message; if (item.messageInfo.type === messageTypes.TEXT) { - message = ( - - ); + message = ; } else if ( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA ) { - message = ( - - ); + message = ; } else { invariant(item.robotext, "Flow can't handle our fancy types :("); message = ( ); } return (
{conversationHeader} {message}
); } export default Message; diff --git a/web/chat/multimedia-message.react.js b/web/chat/multimedia-message.react.js index 4c2e82020..282149510 100644 --- a/web/chat/multimedia-message.react.js +++ b/web/chat/multimedia-message.react.js @@ -1,93 +1,81 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import { type ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { messageTypes } from 'lib/types/message-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import { type InputState, InputStateContext } from '../input/input-state'; import Multimedia from '../media/multimedia.react'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react'; import sendFailed from './multimedia-message-send-failed'; -import type { - MessagePositionInfo, - OnMessagePositionWithContainerInfo, -} from './position-types'; - type BaseProps = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, - +setMouseOverMessagePosition: ( - messagePositionInfo: MessagePositionInfo, - ) => void, - +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, }; type Props = { ...BaseProps, // withInputState +inputState: ?InputState, }; class MultimediaMessage extends React.PureComponent { render() { const { item, inputState } = this.props; invariant( item.messageInfo.type === messageTypes.IMAGES || item.messageInfo.type === messageTypes.MULTIMEDIA, 'MultimediaMessage should only be used for multimedia messages', ); const { localID, media } = item.messageInfo; invariant(inputState, 'inputState should be set in MultimediaMessage'); const pendingUploads = localID ? inputState.assignedUploads[localID] : null; const multimedia = []; for (const singleMedia of media) { const pendingUpload = pendingUploads ? pendingUploads.find(upload => upload.localID === singleMedia.id) : null; multimedia.push( , ); } invariant(multimedia.length > 0, 'should be at least one multimedia...'); const content = multimedia.length > 1 ? (
{multimedia}
) : ( multimedia ); return ( 1} borderRadius={16} > {content} ); } } const ConnectedMultimediaMessage: React.ComponentType = React.memo( function ConnectedMultimediaMessage(props) { const inputState = React.useContext(InputStateContext); return ; }, ); export default ConnectedMultimediaMessage; diff --git a/web/chat/text-message.react.js b/web/chat/text-message.react.js index bfd974716..8d08c35e1 100644 --- a/web/chat/text-message.react.js +++ b/web/chat/text-message.react.js @@ -1,82 +1,67 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { onlyEmojiRegex } from 'lib/shared/emojis'; -import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; +import { colorIsDark } from 'lib/shared/thread-utils'; import { messageTypes } from 'lib/types/message-types'; -import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; +import { type ThreadInfo } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import css from './chat-message-list.css'; import ComposedMessage from './composed-message.react'; import { MessageListContext } from './message-list-types'; -import type { - MessagePositionInfo, - OnMessagePositionWithContainerInfo, -} from './position-types'; import textMessageSendFailed from './text-message-send-failed'; type Props = { +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, - +setMouseOverMessagePosition: ( - messagePositionInfo: MessagePositionInfo, - ) => void, - +mouseOverMessagePosition: ?OnMessagePositionWithContainerInfo, }; function TextMessage(props: Props): React.Node { invariant( props.item.messageInfo.type === messageTypes.TEXT, 'TextMessage should only be used for messageTypes.TEXT', ); const { text, creator: { isViewer }, } = props.item.messageInfo; const messageStyle = {}; let darkColor = true; if (isViewer) { const threadColor = props.threadInfo.color; darkColor = colorIsDark(threadColor); messageStyle.backgroundColor = `#${threadColor}`; } const onlyEmoji = onlyEmojiRegex.test(text); const messageClassName = classNames({ [css.textMessage]: true, [css.textMessageDefaultBackground]: !isViewer, [css.normalTextMessage]: !onlyEmoji, [css.emojiOnlyTextMessage]: onlyEmoji, [css.darkTextMessage]: darkColor, [css.lightTextMessage]: !darkColor, }); const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); const rules = messageListContext.getTextMessageMarkdownRules(darkColor); - const canReply = threadHasPermission( - props.threadInfo, - threadPermissions.VOICED, - ); return (
{text}
); } export default TextMessage; diff --git a/web/chat/tooltip-utils.js b/web/chat/tooltip-utils.js index 56a6d1344..f8adf8bcb 100644 --- a/web/chat/tooltip-utils.js +++ b/web/chat/tooltip-utils.js @@ -1,412 +1,522 @@ // @flow import invariant from 'invariant'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { createMessageReply } from 'lib/shared/message-utils'; import { threadHasPermission, useSidebarExistsOrCanBeCreated, } from 'lib/shared/thread-utils'; import { isComposableMessageType } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadPermissions } from 'lib/types/thread-types'; +import { longAbsoluteDate } from 'lib/utils/date-utils'; import CommIcon from '../CommIcon.react'; import { InputStateContext } from '../input/input-state'; +import { useSelector } from '../redux/redux-utils'; import { useOnClickPendingSidebar, useOnClickThread, } from '../selectors/nav-selectors'; import { calculateMaxTextWidth } from '../utils/text-utils'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from './chat-constants'; +import MessageTooltip from './message-tooltip.react'; import type { PositionInfo } from './position-types'; +import { useTooltipContext } from './tooltip-provider'; export const tooltipPositions = Object.freeze({ LEFT: 'left', RIGHT: 'right', LEFT_BOTTOM: 'left-bottom', RIGHT_BOTTOM: 'right-bottom', LEFT_TOP: 'left-top', RIGHT_TOP: 'right-top', TOP: 'top', BOTTOM: 'bottom', }); type TooltipSize = { +height: number, +width: number, }; export type TooltipPositionStyle = { +xCoord: number, +yCoord: number, +verticalPosition: 'top' | 'bottom', +horizontalPosition: 'left' | 'right', +alignment: 'left' | 'center' | 'right', }; export type TooltipPosition = $Values; export type MessageTooltipAction = { +label: string, +onClick: (SyntheticEvent) => mixed, +actionButtonContent: React.Node, }; const sizeOfTooltipArrow = 10; // 7px arrow + 3px extra const appTopBarHeight = 65; const font = '14px "Inter", -apple-system, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", ' + '"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", ui-sans-serif'; type FindTooltipPositionArgs = { +sourcePositionInfo: PositionInfo, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, +defaultPosition: TooltipPosition, +preventDisplayingBelowSource?: boolean, }; function findTooltipPosition({ sourcePositionInfo, tooltipSize, availablePositions, defaultPosition, preventDisplayingBelowSource, }: FindTooltipPositionArgs): TooltipPosition { if (!window) { return defaultPosition; } const appContainerPositionInfo: PositionInfo = { height: window.innerHeight - appTopBarHeight, width: window.innerWidth, top: appTopBarHeight, bottom: window.innerHeight, left: 0, right: window.innerWidth, }; const pointingTo = sourcePositionInfo; const { top: containerTop, left: containerLeft, right: containerRight, bottom: containerBottom, } = appContainerPositionInfo; const tooltipWidth = tooltipSize.width; const tooltipHeight = tooltipSize.height; const canBeDisplayedOnLeft = containerLeft + tooltipWidth <= pointingTo.left; const canBeDisplayedOnRight = tooltipWidth + pointingTo.right <= containerRight; const willCoverSidebarOnTopSideways = preventDisplayingBelowSource && pointingTo.top + tooltipHeight > pointingTo.bottom; const canBeDisplayedOnTopSideways = pointingTo.top >= containerTop && pointingTo.top + tooltipHeight <= containerBottom && !willCoverSidebarOnTopSideways; const canBeDisplayedOnBottomSideways = pointingTo.bottom <= containerBottom && pointingTo.bottom - tooltipHeight >= containerTop; const verticalCenterOfPointingTo = pointingTo.top + pointingTo.height / 2; const horizontalCenterOfPointingTo = pointingTo.left + pointingTo.width / 2; const willCoverSidebarInTheMiddleSideways = preventDisplayingBelowSource && verticalCenterOfPointingTo + tooltipHeight / 2 > pointingTo.bottom; const canBeDisplayedInTheMiddleSideways = verticalCenterOfPointingTo - tooltipHeight / 2 >= containerTop && verticalCenterOfPointingTo + tooltipHeight / 2 <= containerBottom && !willCoverSidebarInTheMiddleSideways; const canBeDisplayedOnTop = pointingTo.top - tooltipHeight >= containerTop && horizontalCenterOfPointingTo - tooltipWidth / 2 >= containerLeft && horizontalCenterOfPointingTo + tooltipWidth / 2 <= containerRight; const canBeDisplayedOnBottom = pointingTo.bottom + tooltipHeight <= containerBottom && horizontalCenterOfPointingTo - tooltipWidth / 2 >= containerLeft && horizontalCenterOfPointingTo + tooltipWidth / 2 <= containerRight && !preventDisplayingBelowSource; for (const tooltipPosition of availablePositions) { if ( tooltipPosition === tooltipPositions.RIGHT && canBeDisplayedOnRight && canBeDisplayedInTheMiddleSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.RIGHT_BOTTOM && canBeDisplayedOnRight && canBeDisplayedOnBottomSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.LEFT && canBeDisplayedOnLeft && canBeDisplayedInTheMiddleSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.LEFT_BOTTOM && canBeDisplayedOnLeft && canBeDisplayedOnBottomSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.LEFT_TOP && canBeDisplayedOnLeft && canBeDisplayedOnTopSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.RIGHT_TOP && canBeDisplayedOnRight && canBeDisplayedOnTopSideways ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.TOP && canBeDisplayedOnTop ) { return tooltipPosition; } else if ( tooltipPosition === tooltipPositions.BOTTOM && canBeDisplayedOnBottom ) { return tooltipPosition; } } return defaultPosition; } type GetMessageActionTooltipStyleParams = { +sourcePositionInfo: PositionInfo, +tooltipSize: TooltipSize, +tooltipPosition: TooltipPosition, }; function getMessageActionTooltipStyle({ sourcePositionInfo, tooltipSize, tooltipPosition, }: GetMessageActionTooltipStyleParams): TooltipPositionStyle { if (tooltipPosition === tooltipPositions.RIGHT_TOP) { return { xCoord: sourcePositionInfo.right, yCoord: sourcePositionInfo.top, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.LEFT_TOP) { return { xCoord: sourcePositionInfo.left, yCoord: sourcePositionInfo.top, horizontalPosition: 'left', verticalPosition: 'bottom', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.RIGHT_BOTTOM) { return { xCoord: sourcePositionInfo.right, yCoord: sourcePositionInfo.bottom, horizontalPosition: 'right', verticalPosition: 'top', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.LEFT_BOTTOM) { return { xCoord: sourcePositionInfo.left, yCoord: sourcePositionInfo.bottom, horizontalPosition: 'left', verticalPosition: 'top', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.LEFT) { return { xCoord: sourcePositionInfo.left, yCoord: sourcePositionInfo.top + sourcePositionInfo.height / 2 - tooltipSize.height / 2, horizontalPosition: 'left', verticalPosition: 'bottom', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.RIGHT) { return { xCoord: sourcePositionInfo.right, yCoord: sourcePositionInfo.top + sourcePositionInfo.height / 2 - tooltipSize.height / 2, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.TOP) { return { xCoord: sourcePositionInfo.left + sourcePositionInfo.width / 2 - tooltipSize.width / 2, yCoord: sourcePositionInfo.top, horizontalPosition: 'right', verticalPosition: 'top', alignment: 'center', }; } else if (tooltipPosition === tooltipPositions.BOTTOM) { return { xCoord: sourcePositionInfo.left + sourcePositionInfo.width / 2 - tooltipSize.width / 2, yCoord: sourcePositionInfo.bottom, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'center', }; } invariant(false, `Unexpected tooltip position value: ${tooltipPosition}`); } type CalculateTooltipSizeArgs = { +tooltipLabels: $ReadOnlyArray, +timestamp: string, }; function calculateTooltipSize({ tooltipLabels, timestamp, }: CalculateTooltipSizeArgs): { +width: number, +height: number, } { const textWidth = calculateMaxTextWidth([...tooltipLabels, timestamp], font) + 2 * tooltipLabelStyle.padding; const buttonsWidth = tooltipLabels.length * (tooltipButtonStyle.width + tooltipButtonStyle.paddingLeft + tooltipButtonStyle.paddingRight); const width = Math.max(textWidth, buttonsWidth) + tooltipStyle.paddingLeft + tooltipStyle.paddingRight; const height = (tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding) * 2 + tooltipStyle.rowGap * 2 + tooltipButtonStyle.height; return { width, height, }; } function useMessageTooltipSidebarAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { threadCreatedFromMessage, messageInfo } = item; const sidebarExists = !!threadCreatedFromMessage; const sidebarExistsOrCanBeCreated = useSidebarExistsOrCanBeCreated( threadInfo, item, ); const openThread = useOnClickThread(threadCreatedFromMessage); const openPendingSidebar = useOnClickPendingSidebar(messageInfo, threadInfo); return React.useMemo(() => { if (!sidebarExistsOrCanBeCreated) { return null; } const buttonContent = ; const onClick = (event: SyntheticEvent) => { if (threadCreatedFromMessage) { openThread(event); } else { openPendingSidebar(event); } }; return { actionButtonContent: buttonContent, onClick, label: sidebarExists ? 'Go to thread' : 'Create thread', }; }, [ openPendingSidebar, openThread, sidebarExists, sidebarExistsOrCanBeCreated, threadCreatedFromMessage, ]); } function useMessageTooltipReplyAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; const inputState = React.useContext(InputStateContext); invariant(inputState, 'inputState is required'); const { addReply } = inputState; return React.useMemo(() => { if ( !isComposableMessageType(item.messageInfo.type) || !threadHasPermission(threadInfo, threadPermissions.VOICED) ) { return null; } const buttonContent = ; const onClick = () => { if (!messageInfo.text) { return; } addReply(createMessageReply(messageInfo.text)); }; return { actionButtonContent: buttonContent, onClick, label: 'Reply', }; }, [addReply, item.messageInfo.type, messageInfo, threadInfo]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); return React.useMemo(() => [replyAction, sidebarAction].filter(Boolean), [ replyAction, sidebarAction, ]); } +type UseMessageTooltipArgs = { + +availablePositions: $ReadOnlyArray, + +item: ChatMessageInfoItem, + +threadInfo: ThreadInfo, +}; + +type UseMessageTooltipResult = { + onMouseEnter: (event: SyntheticEvent) => void, + onMouseLeave: ?() => mixed, +}; + +function useMessageTooltip({ + availablePositions, + item, + threadInfo, +}: UseMessageTooltipArgs): UseMessageTooltipResult { + const [onMouseLeave, setOnMouseLeave] = React.useState mixed>(null); + + const { renderTooltip } = useTooltipContext(); + const tooltipActions = useMessageTooltipActions(item, threadInfo); + + const containsInlineSidebar = !!item.threadCreatedFromMessage; + + const timeZone = useSelector(state => state.timeZone); + + const messageTimestamp = React.useMemo(() => { + const time = item.messageInfo.time; + return longAbsoluteDate(time, timeZone); + }, [item.messageInfo.time, timeZone]); + + const tooltipSize = React.useMemo(() => { + if (typeof document === 'undefined') { + return { + width: 0, + height: 0, + }; + } + const tooltipLabels = tooltipActions.map(action => action.label); + return calculateTooltipSize({ + tooltipLabels, + timestamp: messageTimestamp, + }); + }, [messageTimestamp, tooltipActions]); + + const onMouseEnter = React.useCallback( + (event: SyntheticEvent) => { + if (!renderTooltip) { + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + const { top, bottom, left, right, height, width } = rect; + const messagePosition = { top, bottom, left, right, height, width }; + + const tooltipPosition = findTooltipPosition({ + sourcePositionInfo: messagePosition, + tooltipSize, + availablePositions, + defaultPosition: availablePositions[0], + preventDisplayingBelowSource: containsInlineSidebar, + }); + if (!tooltipPosition) { + return; + } + + const tooltipPositionStyle = getMessageActionTooltipStyle({ + tooltipPosition, + sourcePositionInfo: messagePosition, + tooltipSize: tooltipSize, + }); + + const { alignment } = tooltipPositionStyle; + + const tooltip = ( + + ); + + const renderTooltipResult = renderTooltip({ + newNode: tooltip, + tooltipPositionStyle, + }); + if (renderTooltipResult) { + const { onMouseLeaveCallback: callback } = renderTooltipResult; + setOnMouseLeave((() => callback: () => () => mixed)); + } + }, + [ + availablePositions, + containsInlineSidebar, + messageTimestamp, + renderTooltip, + tooltipActions, + tooltipSize, + ], + ); + + return { + onMouseEnter, + onMouseLeave, + }; +} + export { findTooltipPosition, calculateTooltipSize, getMessageActionTooltipStyle, useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageTooltipActions, + useMessageTooltip, sizeOfTooltipArrow, };