diff --git a/web/chat/composed-message.react.js b/web/chat/composed-message.react.js index 08bd448fc..5878a07bd 100644 --- a/web/chat/composed-message.react.js +++ b/web/chat/composed-message.react.js @@ -1,190 +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 { 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 { tooltipPositions, useMessageTooltip } from '../utils/tooltip-utils'; import css from './chat-message-list.css'; import FailedSend from './failed-send.react'; import InlineSidebar from './inline-sidebar.react'; -import { tooltipPositions, useMessageTooltip } from './tooltip-utils'; 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, +sendFailed: boolean, +children: React.Node, +fixedWidth?: boolean, +borderRadius: number, }; type BaseConfig = React.Config; type Props = { ...BaseProps, // 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 inlineSidebar = null; if (this.props.containsInlineSidebar && item.threadCreatedFromMessage) { const positioning = isViewer ? 'right' : 'left'; inlineSidebar = (
); } return ( {authorName}
{this.props.children}
{deliveryIcon}
{failedSendInfo} {inlineSidebar}
); } } 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 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-tooltip.react.js b/web/chat/message-tooltip.react.js index 42ab6cc5c..44ceb8172 100644 --- a/web/chat/message-tooltip.react.js +++ b/web/chat/message-tooltip.react.js @@ -1,115 +1,115 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; +import { type MessageTooltipAction } from '../utils/tooltip-utils'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from './chat-constants'; import css from './message-tooltip.css'; -import { type MessageTooltipAction } from './tooltip-utils'; type MessageTooltipProps = { +actions: $ReadOnlyArray, +messageTimestamp: string, +alignment?: 'left' | 'center' | 'right', }; function MessageTooltip(props: MessageTooltipProps): React.Node { const { actions, messageTimestamp, alignment = 'left' } = props; const [activeTooltipLabel, setActiveTooltipLabel] = React.useState(); const messageActionButtonsContainerClassName = classNames( css.messageActionContainer, css.messageActionButtons, ); const messageTooltipButtonStyle = React.useMemo(() => tooltipButtonStyle, []); const tooltipButtons = React.useMemo(() => { if (!actions || actions.length === 0) { return null; } const buttons = actions.map(({ label, onClick, actionButtonContent }) => { const onMouseEnter = () => { setActiveTooltipLabel(label); }; const onMouseLeave = () => setActiveTooltipLabel(oldLabel => label === oldLabel ? null : oldLabel, ); return (
{actionButtonContent}
); }); return (
{buttons}
); }, [ actions, messageActionButtonsContainerClassName, messageTooltipButtonStyle, ]); const messageTooltipLabelStyle = React.useMemo(() => tooltipLabelStyle, []); const messageTooltipTopLabelStyle = React.useMemo( () => ({ height: `${tooltipLabelStyle.height + 2 * tooltipLabelStyle.padding}px`, }), [], ); const tooltipLabel = React.useMemo(() => { if (!activeTooltipLabel) { return null; } return (
{activeTooltipLabel}
); }, [activeTooltipLabel, messageTooltipLabelStyle]); const tooltipTimestamp = React.useMemo(() => { if (!messageTimestamp) { return null; } return (
{messageTimestamp}
); }, [messageTimestamp, messageTooltipLabelStyle]); const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []); const containerClassNames = React.useMemo( () => classNames(css.messageTooltipContainer, { [css.leftTooltipAlign]: alignment === 'left', [css.centerTooltipAlign]: alignment === 'center', [css.rightTooltipAlign]: alignment === 'right', }), [alignment], ); return (
{tooltipLabel}
{tooltipButtons} {tooltipTimestamp}
); } export default MessageTooltip; diff --git a/web/chat/robotext-message.react.js b/web/chat/robotext-message.react.js index 35a4b2ca2..ffa428d6d 100644 --- a/web/chat/robotext-message.react.js +++ b/web/chat/robotext-message.react.js @@ -1,166 +1,166 @@ // @flow import * as React from 'react'; import { useDispatch } from 'react-redux'; import { type RobotextChatMessageInfoItem } from 'lib/selectors/chat-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { splitRobotext, parseRobotextEntity } from 'lib/shared/message-utils'; import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo } from 'lib/types/thread-types'; import Markdown from '../markdown/markdown.react'; import { linkRules } from '../markdown/rules.react'; import { updateNavInfoActionType } from '../redux/action-types'; import { useSelector } from '../redux/redux-utils'; +import { tooltipPositions, useMessageTooltip } from '../utils/tooltip-utils'; import InlineSidebar from './inline-sidebar.react'; import css from './robotext-message.css'; -import { tooltipPositions, useMessageTooltip } from './tooltip-utils'; const availableTooltipPositionsForRobotext = [ tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.LEFT_BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.RIGHT_BOTTOM, ]; type BaseProps = { +item: RobotextChatMessageInfoItem, +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +onMouseLeave: ?() => mixed, +onMouseEnter: (event: SyntheticEvent) => mixed, }; class RobotextMessage extends React.PureComponent { render() { let inlineSidebar; if (this.props.item.threadCreatedFromMessage) { inlineSidebar = (
); } return (
{this.linkedRobotext()}
{inlineSidebar}
); } linkedRobotext() { const { item } = this.props; const { robotext } = item; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (const splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push(); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } return textParts; } } type BaseInnerThreadEntityProps = { +id: string, +name: string, }; type InnerThreadEntityProps = { ...BaseInnerThreadEntityProps, +threadInfo: ThreadInfo, +dispatch: Dispatch, }; class InnerThreadEntity extends React.PureComponent { render() { return {this.props.name}; } onClickThread = (event: SyntheticEvent) => { event.preventDefault(); const id = this.props.id; this.props.dispatch({ type: updateNavInfoActionType, payload: { activeChatThreadID: id, }, }); }; } const ThreadEntity = React.memo( function ConnectedInnerThreadEntity(props: BaseInnerThreadEntityProps) { const { id } = props; const threadInfo = useSelector(state => threadInfoSelector(state)[id]); const dispatch = useDispatch(); return ( ); }, ); function ColorEntity(props: { color: string }) { const colorStyle = { color: props.color }; return {props.color}; } const ConnectedRobotextMessage: React.ComponentType = React.memo( function ConnectedRobotextMessage(props) { const { item, threadInfo } = props; const { onMouseLeave, onMouseEnter } = useMessageTooltip({ item, threadInfo, availablePositions: availableTooltipPositionsForRobotext, }); return ( ); }, ); export default ConnectedRobotextMessage; diff --git a/web/chat/tooltip-provider.js b/web/chat/tooltip-provider.js index 41f17626c..7ffbdd24a 100644 --- a/web/chat/tooltip-provider.js +++ b/web/chat/tooltip-provider.js @@ -1,172 +1,172 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; -import type { TooltipPositionStyle } from './tooltip-utils'; +import type { TooltipPositionStyle } from '../utils/tooltip-utils'; import css from './tooltip.css'; const onMouseLeaveSourceDisappearTimeoutMs = 200; const onMouseLeaveTooltipDisappearTimeoutMs = 100; export type RenderTooltipParams = { +newNode: React.Node, +tooltipPositionStyle: TooltipPositionStyle, }; export type RenderTooltipResult = { +onMouseLeaveCallback: () => mixed, +clearTooltip: () => mixed, }; type TooltipContextType = { +renderTooltip: (params: RenderTooltipParams) => RenderTooltipResult, +clearTooltip: () => mixed, }; const TooltipContext: React.Context = React.createContext( { renderTooltip: () => ({ onMouseLeaveCallback: () => {}, clearTooltip: () => {}, }), clearTooltip: () => {}, }, ); type Props = { +children: React.Node, }; function TooltipProvider(props: Props): React.Node { const { children } = props; // eslint-disable-next-line no-unused-vars const tooltipSymbol = React.useRef(null); // eslint-disable-next-line no-unused-vars const tooltipCancelTimer = React.useRef(null); // eslint-disable-next-line no-unused-vars const [tooltipNode, setTooltipNode] = React.useState(null); const [ // eslint-disable-next-line no-unused-vars tooltipPosition, // eslint-disable-next-line no-unused-vars setTooltipPosition, ] = React.useState(null); const clearTooltip = React.useCallback((tooltipToClose: symbol) => { if (tooltipSymbol.current !== tooltipToClose) { return; } tooltipCancelTimer.current = null; setTooltipNode(null); setTooltipPosition(null); tooltipSymbol.current = null; }, []); const clearCurrentTooltip = React.useCallback(() => { if (tooltipSymbol.current) { clearTooltip(tooltipSymbol.current); } }, [clearTooltip]); const renderTooltip = React.useCallback( ({ newNode, tooltipPositionStyle: newTooltipPosition, }: RenderTooltipParams): RenderTooltipResult => { setTooltipNode(newNode); setTooltipPosition(newTooltipPosition); const newNodeSymbol = Symbol(); tooltipSymbol.current = newNodeSymbol; if (tooltipCancelTimer.current) { clearTimeout(tooltipCancelTimer.current); } return { onMouseLeaveCallback: () => { const newTimer = setTimeout( () => clearTooltip(newNodeSymbol), onMouseLeaveSourceDisappearTimeoutMs, ); tooltipCancelTimer.current = newTimer; }, clearTooltip: () => clearTooltip(newNodeSymbol), }; }, [clearTooltip], ); // eslint-disable-next-line no-unused-vars const onMouseEnterTooltip = React.useCallback(() => { if (tooltipSymbol.current) { clearTimeout(tooltipCancelTimer.current); } }, []); // eslint-disable-next-line no-unused-vars const onMouseLeaveTooltip = React.useCallback(() => { const timer = setTimeout( clearCurrentTooltip, onMouseLeaveTooltipDisappearTimeoutMs, ); tooltipCancelTimer.current = timer; }, [clearCurrentTooltip]); const tooltip = React.useMemo(() => { if (!tooltipNode || !tooltipPosition) { return null; } const tooltipContainerStyle = { position: 'absolute', top: tooltipPosition.yCoord, left: tooltipPosition.xCoord, }; const { verticalPosition, horizontalPosition } = tooltipPosition; const tooltipClassName = classNames(css.tooltipAbsolute, { [css.tooltipAbsoluteLeft]: horizontalPosition === 'right', [css.tooltipAbsoluteRight]: horizontalPosition === 'left', [css.tooltipAbsoluteTop]: verticalPosition === 'bottom', [css.tooltipAbsoluteBottom]: verticalPosition === 'top', }); return (
{tooltipNode}
); }, [onMouseEnterTooltip, onMouseLeaveTooltip, tooltipNode, tooltipPosition]); const value = React.useMemo( () => ({ renderTooltip, clearTooltip: clearCurrentTooltip, }), [renderTooltip, clearCurrentTooltip], ); return ( {children} {tooltip} ); } function useTooltipContext(): TooltipContextType { const context = React.useContext(TooltipContext); invariant(context, 'TooltipContext not found'); return context; } export { TooltipProvider, useTooltipContext }; diff --git a/web/chat/tooltip-utils.js b/web/utils/tooltip-utils.js similarity index 98% rename from web/chat/tooltip-utils.js rename to web/utils/tooltip-utils.js index dc6b1eaf8..c0a9d51d7 100644 --- a/web/chat/tooltip-utils.js +++ b/web/utils/tooltip-utils.js @@ -1,520 +1,520 @@ // @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 { + tooltipButtonStyle, + tooltipLabelStyle, + tooltipStyle, +} from '../chat/chat-constants'; +import MessageTooltip from '../chat/message-tooltip.react'; +import type { PositionInfo } from '../chat/position-types'; +import { useTooltipContext } from '../chat/tooltip-provider'; 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 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, }; diff --git a/web/utils/tooltip-utils.test.js b/web/utils/tooltip-utils.test.js index 153b14ae8..21a1273c6 100644 --- a/web/utils/tooltip-utils.test.js +++ b/web/utils/tooltip-utils.test.js @@ -1,204 +1,204 @@ // @flow import type { PositionInfo } from '../chat/position-types'; -import { findTooltipPosition, tooltipPositions } from '../chat/tooltip-utils'; +import { findTooltipPosition, tooltipPositions } from './tooltip-utils'; const QHDWindow = { width: 2560, height: 1440, }; const tooltipSourcePositionCenter: PositionInfo = { width: 200, height: 300, left: QHDWindow.width / 2 - 100, top: QHDWindow.height / 2 - 150, right: QHDWindow.width / 2 + 100, bottom: QHDWindow.height / 2 + 150, }; const tooltipSourcePositionTopRight: PositionInfo = { width: 200, height: 300, left: QHDWindow.width - 200, top: 65, // app top bar height right: QHDWindow.width, bottom: 300 + 65, // tooltip height + app top bar height }; const tooltipSourcePositionBottomLeft: PositionInfo = { width: 200, height: 300, left: 0, top: QHDWindow.height - 300, right: 200, bottom: QHDWindow.height, }; const tooltipSizeSmall = { width: 100, height: 200, }; const tooltipSizeBig = { width: 300, height: 500, }; const allTooltipPositions = [ tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.LEFT_BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.RIGHT_BOTTOM, tooltipPositions.TOP, tooltipPositions.BOTTOM, ]; const sidewaysTooltipPositions = [ tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.LEFT_BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.RIGHT_BOTTOM, ]; const topAndBottomTooltipPositions = [ tooltipPositions.TOP, tooltipPositions.BOTTOM, ]; const onlyLeftTooltipPositions = [ tooltipPositions.LEFT, tooltipPositions.LEFT_BOTTOM, tooltipPositions.LEFT_TOP, ]; beforeAll(() => { window.innerWidth = QHDWindow.width; window.innerHeight = QHDWindow.height; }); afterAll(() => { window.innerWidth = 1024; window.innerHeight = 768; }); describe('findTooltipPosition', () => { it( 'should return first position if there is enough space ' + 'in every direction', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionCenter, tooltipSize: tooltipSizeSmall, availablePositions: allTooltipPositions, defaultPosition: allTooltipPositions[0], }), ).toMatch(allTooltipPositions[0]), ); it( 'should return first non-left position ' + 'if there is no space on the left', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionBottomLeft, tooltipSize: tooltipSizeSmall, availablePositions: sidewaysTooltipPositions, defaultPosition: sidewaysTooltipPositions[0], }), ).toMatch(tooltipPositions.RIGHT), ); it('should return bottom position if there is no space on the top ', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionTopRight, tooltipSize: tooltipSizeSmall, availablePositions: topAndBottomTooltipPositions, defaultPosition: topAndBottomTooltipPositions[0], }), ).toMatch(tooltipPositions.BOTTOM)); it( 'should return top left position if the tooltip is higher than the ' + 'source object and there is no enough space on the top', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionTopRight, tooltipSize: tooltipSizeBig, availablePositions: onlyLeftTooltipPositions, defaultPosition: onlyLeftTooltipPositions[0], }), ).toMatch(tooltipPositions.LEFT_TOP), ); it( 'should return bottom position on left ' + 'to prevent covering element below source', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionCenter, tooltipSize: tooltipSizeBig, availablePositions: onlyLeftTooltipPositions, defaultPosition: onlyLeftTooltipPositions[0], preventDisplayingBelowSource: true, }), ).toMatch(tooltipPositions.LEFT_BOTTOM), ); it( 'should return first position ' + 'that does not cover element below source ', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionCenter, tooltipSize: tooltipSizeBig, availablePositions: [ tooltipPositions.BOTTOM, tooltipPositions.RIGHT, tooltipPositions.RIGHT_TOP, tooltipPositions.LEFT, tooltipPositions.LEFT_TOP, tooltipPositions.TOP, tooltipPositions.LEFT_BOTTOM, ], defaultPosition: tooltipPositions.BOTTOM, preventDisplayingBelowSource: true, }), ).toMatch(tooltipPositions.TOP), ); it( 'should return default position ' + 'if an empty array of available is provided', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionCenter, tooltipSize: tooltipSizeSmall, availablePositions: [], defaultPosition: tooltipPositions.LEFT_BOTTOM, }), ).toMatch(tooltipPositions.LEFT_BOTTOM), ); it('should return default position if an no position is available', () => expect( findTooltipPosition({ sourcePositionInfo: tooltipSourcePositionTopRight, tooltipSize: tooltipSizeBig, availablePositions: allTooltipPositions, defaultPosition: tooltipPositions.BOTTOM, preventDisplayingBelowSource: true, }), ).toMatch(tooltipPositions.BOTTOM)); });