diff --git a/web/chat/message-tooltip.css b/web/chat/message-tooltip.css index f0a9b77af..fea6c9aa7 100644 --- a/web/chat/message-tooltip.css +++ b/web/chat/message-tooltip.css @@ -1,73 +1,68 @@ -div.container { - display: flex; - flex-direction: row; -} - -div.containerLeftAlign, -div.containerCenterAlign { - flex-direction: row-reverse; -} - div.messageTooltipContainer { display: flex; flex-direction: column; align-items: center; font-size: var(--s-font-14); + padding: 8px 0; } div.messageActionContainer { display: flex; flex-direction: row; align-items: center; justify-content: center; background-color: var(--message-action-tooltip-bg); border-radius: 8px; width: fit-content; box-shadow: var(--message-action-tooltip-bg) 0 2px 8px; } div.messageActionButtons { display: flex; font-size: 16px; } div.messageActionButtons svg { padding: 10px 6px 6px; color: var(--color-disabled); } div.messageActionButtons svg:hover, div.messageActionButtons span:hover { cursor: pointer; color: var(--fg); } div.messageTooltipButton { display: flex; align-items: center; justify-content: center; } div.messageTooltipLabel { display: flex; flex-direction: row; align-items: center; justify-content: center; background-color: var(--message-action-tooltip-bg); color: var(--tool-tip-color); border-radius: 8px; overflow: auto; white-space: nowrap; box-shadow: var(--message-action-tooltip-bg) 0 2px 8px; } div.leftTooltipAlign { align-items: flex-start; } div.centerTooltipAlign { align-items: center; } div.rightTooltipAlign { align-items: flex-end; } + +div.emojiKeyboard { + position: absolute; +} diff --git a/web/chat/message-tooltip.react.js b/web/chat/message-tooltip.react.js index fd57bb78c..ba94a22bf 100644 --- a/web/chat/message-tooltip.react.js +++ b/web/chat/message-tooltip.react.js @@ -1,172 +1,229 @@ // @flow import data from '@emoji-mart/data'; import Picker from '@emoji-mart/react'; import classNames from 'classnames'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { localIDPrefix } from 'lib/shared/message-utils.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from './chat-constants.js'; import css from './message-tooltip.css'; -import { useSendReaction } from './reaction-message-utils.js'; +import { + useSendReaction, + getEmojiKeyboardPosition, +} from './reaction-message-utils.js'; import { useTooltipContext } from './tooltip-provider.js'; import { useSelector } from '../redux/redux-utils.js'; -import { type MessageTooltipAction } from '../utils/tooltip-utils.js'; +import type { + MessageTooltipAction, + TooltipSize, + TooltipPositionStyle, +} from '../utils/tooltip-utils.js'; type MessageTooltipProps = { +actions: $ReadOnlyArray, +messageTimestamp: string, - +alignment?: 'left' | 'center' | 'right', + +tooltipPositionStyle: TooltipPositionStyle, + +tooltipSize: TooltipSize, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function MessageTooltip(props: MessageTooltipProps): React.Node { const { actions, messageTimestamp, - alignment = 'left', + tooltipPositionStyle, + tooltipSize, item, threadInfo, } = props; const { messageInfo, reactions } = item; + const { alignment = 'left' } = tooltipPositionStyle; + const [activeTooltipLabel, setActiveTooltipLabel] = React.useState(); - const { renderEmojiKeyboard } = useTooltipContext(); + const { shouldRenderEmojiKeyboard } = useTooltipContext(); + + // emoji-mart actually doesn't render its contents until a useEffect runs: + // https://github.com/missive/emoji-mart/blob/d29728f7b4e295e46f9b64aa80335aa4a3c15b8e/packages/emoji-mart-react/react.tsx#L13-L19 + // We need to measure the width/height of the picker, but because of this we + // need to do the measurement in our own useEffect, in order to guarantee it + // runs after emoji-mart's useEffect. To do this, we have to define two pieces + // of React state: + // - emojiKeyboardNode, which will get set by the emoji keyboard's ref and + // will trigger our useEffect + // - emojiKeyboardRenderedNode, which will get set in that useEffect and will + // trigger the rerendering of this component with the correct height/width + + const [emojiKeyboardNode, setEmojiKeyboardNode] = React.useState(null); + const [emojiKeyboardRenderedNode, setEmojiKeyboardRenderedNode] = + React.useState(null); + + React.useEffect(() => { + if (emojiKeyboardNode) { + // It would be more simple to just call getEmojiKeyboardPosition + // immediately here, but some quirk of emoji-mart causes the width of the + // node to be 0 here. If instead we wait until the next render of this + // component to check the width, it ends up being set correctly. + setEmojiKeyboardRenderedNode(emojiKeyboardNode); + } + }, [emojiKeyboardNode]); 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 emojiKeyboardPosition = React.useMemo( + () => + getEmojiKeyboardPosition( + emojiKeyboardRenderedNode, + tooltipPositionStyle, + tooltipSize, + ), + [emojiKeyboardRenderedNode, tooltipPositionStyle, tooltipSize], + ); + + const emojiKeyboardPositionStyle = React.useMemo(() => { + if (!emojiKeyboardPosition) { + return null; + } + + return { + bottom: emojiKeyboardPosition.bottom, + left: emojiKeyboardPosition.left, + }; + }, [emojiKeyboardPosition]); + const nextLocalID = useSelector(state => state.nextLocalID); const localID = `${localIDPrefix}${nextLocalID}`; const sendReaction = useSendReaction(messageInfo.id, localID, threadInfo.id); const onEmojiSelect = React.useCallback( emoji => { const reactionInput = emoji.native; const viewerReacted = reactions[reactionInput] ? reactions[reactionInput].viewerReacted : false; const action = viewerReacted ? 'remove_reaction' : 'add_reaction'; sendReaction(reactionInput, action); }, [sendReaction, reactions], ); const emojiKeyboard = React.useMemo(() => { - if (!renderEmojiKeyboard) { + if (!shouldRenderEmojiKeyboard) { return null; } - return ; - }, [onEmojiSelect, renderEmojiKeyboard]); + + return ( +
+ +
+ ); + }, [emojiKeyboardPositionStyle, onEmojiSelect, shouldRenderEmojiKeyboard]); const messageTooltipContainerStyle = React.useMemo(() => tooltipStyle, []); const containerClassName = classNames({ - [css.container]: true, - [css.containerLeftAlign]: alignment === 'left', - [css.containerCenterAlign]: alignment === 'center', - }); - - const messageTooltipContainerClassNames = classNames({ [css.messageTooltipContainer]: true, [css.leftTooltipAlign]: alignment === 'left', [css.centerTooltipAlign]: alignment === 'center', [css.rightTooltipAlign]: alignment === 'right', }); return ( -
+ <> {emojiKeyboard} -
+
{tooltipLabel}
{tooltipButtons} {tooltipTimestamp}
-
+ ); } export default MessageTooltip; diff --git a/web/chat/tooltip-provider.js b/web/chat/tooltip-provider.js index 1bc5f7588..b56540c27 100644 --- a/web/chat/tooltip-provider.js +++ b/web/chat/tooltip-provider.js @@ -1,180 +1,180 @@ // @flow import classNames from 'classnames'; import invariant from 'invariant'; import * as React from 'react'; import type { SetState } from 'lib/types/hook-types.js'; import css from './tooltip.css'; import type { TooltipPositionStyle } from '../utils/tooltip-utils.js'; const onMouseLeaveSourceDisappearTimeoutMs = 200; const onMouseLeaveTooltipDisappearTimeoutMs = 100; export type RenderTooltipParams = { +newNode: React.Node, +tooltipPositionStyle: TooltipPositionStyle, }; export type RenderTooltipResult = { +onMouseLeaveCallback: () => mixed, +clearTooltip: () => mixed, +updateTooltip: React.Node => mixed, }; type TooltipContextType = { +renderTooltip: (params: RenderTooltipParams) => RenderTooltipResult, +clearTooltip: () => mixed, - +renderEmojiKeyboard: boolean, - +setRenderEmojiKeyboard: SetState, + +shouldRenderEmojiKeyboard: boolean, + +setShouldRenderEmojiKeyboard: SetState, }; const TooltipContext: React.Context = React.createContext({ renderTooltip: () => ({ onMouseLeaveCallback: () => {}, clearTooltip: () => {}, updateTooltip: () => {}, }), clearTooltip: () => {}, - renderEmojiKeyboard: false, - setRenderEmojiKeyboard: () => {}, + shouldRenderEmojiKeyboard: false, + setShouldRenderEmojiKeyboard: () => {}, }); type Props = { +children: React.Node, }; function TooltipProvider(props: Props): React.Node { const { children } = props; const tooltipSymbol = React.useRef(null); const tooltipCancelTimer = React.useRef(null); const [tooltipNode, setTooltipNode] = React.useState(null); const [tooltipPosition, setTooltipPosition] = React.useState(null); - const [renderEmojiKeyboard, setRenderEmojiKeyboard] = + const [shouldRenderEmojiKeyboard, setShouldRenderEmojiKeyboard] = React.useState(false); const clearTooltip = React.useCallback((tooltipToClose: symbol) => { if (tooltipSymbol.current !== tooltipToClose) { return; } tooltipCancelTimer.current = null; setTooltipNode(null); setTooltipPosition(null); - setRenderEmojiKeyboard(false); + setShouldRenderEmojiKeyboard(false); 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), updateTooltip: (node: React.Node) => { if (newNodeSymbol === tooltipSymbol.current) { setTooltipNode(node); } }, }; }, [clearTooltip], ); const onMouseEnterTooltip = React.useCallback(() => { if (tooltipSymbol.current) { clearTimeout(tooltipCancelTimer.current); } }, []); 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.anchorPoint.y, left: tooltipPosition.anchorPoint.x, }; 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, - renderEmojiKeyboard, - setRenderEmojiKeyboard, + shouldRenderEmojiKeyboard, + setShouldRenderEmojiKeyboard, }), - [renderTooltip, clearCurrentTooltip, renderEmojiKeyboard], + [renderTooltip, clearCurrentTooltip, shouldRenderEmojiKeyboard], ); 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/utils/tooltip-utils.js b/web/utils/tooltip-utils.js index 6c216e648..e3abeb60c 100644 --- a/web/utils/tooltip-utils.js +++ b/web/utils/tooltip-utils.js @@ -1,693 +1,692 @@ // @flow import invariant from 'invariant'; import _debounce from 'lodash/debounce.js'; import * as React from 'react'; import type { ChatMessageInfoItem } from 'lib/selectors/chat-selectors.js'; import { createMessageReply } from 'lib/shared/message-utils.js'; import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js'; import { threadHasPermission, useSidebarExistsOrCanBeCreated, } from 'lib/shared/thread-utils.js'; import { isComposableMessageType, messageTypes, } from 'lib/types/message-types.js'; import type { ThreadInfo } from 'lib/types/thread-types.js'; import { threadPermissions } from 'lib/types/thread-types.js'; import { longAbsoluteDate } from 'lib/utils/date-utils.js'; import { getAppContainerPositionInfo } from './window-utils.js'; import { tooltipButtonStyle, tooltipLabelStyle, tooltipStyle, } from '../chat/chat-constants.js'; import MessageTooltip from '../chat/message-tooltip.react.js'; import type { PositionInfo } from '../chat/position-types.js'; import { useTooltipContext } from '../chat/tooltip-provider.js'; import CommIcon from '../CommIcon.react.js'; import { InputStateContext } from '../input/input-state.js'; import { useOnClickPendingSidebar, useOnClickThread, } from '../selectors/thread-selectors.js'; import { calculateMaxTextWidth } from '../utils/text-utils.js'; 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', }); export type TooltipSize = { +height: number, +width: number, }; export type TooltipPositionStyle = { +anchorPoint: { +x: number, +y: 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 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 { const appContainerPositionInfo = getAppContainerPositionInfo(); if (!appContainerPositionInfo) { return defaultPosition; } 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 { anchorPoint: { x: sourcePositionInfo.right, y: sourcePositionInfo.top, }, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.LEFT_TOP) { return { anchorPoint: { x: sourcePositionInfo.left, y: sourcePositionInfo.top, }, horizontalPosition: 'left', verticalPosition: 'bottom', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.RIGHT_BOTTOM) { return { anchorPoint: { x: sourcePositionInfo.right, y: sourcePositionInfo.bottom, }, horizontalPosition: 'right', verticalPosition: 'top', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.LEFT_BOTTOM) { return { anchorPoint: { x: sourcePositionInfo.left, y: sourcePositionInfo.bottom, }, horizontalPosition: 'left', verticalPosition: 'top', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.LEFT) { return { anchorPoint: { x: sourcePositionInfo.left, y: sourcePositionInfo.top + sourcePositionInfo.height / 2 - tooltipSize.height / 2, }, horizontalPosition: 'left', verticalPosition: 'bottom', alignment: 'right', }; } else if (tooltipPosition === tooltipPositions.RIGHT) { return { anchorPoint: { x: sourcePositionInfo.right, y: sourcePositionInfo.top + sourcePositionInfo.height / 2 - tooltipSize.height / 2, }, horizontalPosition: 'right', verticalPosition: 'bottom', alignment: 'left', }; } else if (tooltipPosition === tooltipPositions.TOP) { return { anchorPoint: { x: sourcePositionInfo.left + sourcePositionInfo.width / 2 - tooltipSize.width / 2, y: sourcePositionInfo.top, }, horizontalPosition: 'right', verticalPosition: 'top', alignment: 'center', }; } else if (tooltipPosition === tooltipPositions.BOTTOM) { return { anchorPoint: { x: sourcePositionInfo.left + sourcePositionInfo.width / 2 - tooltipSize.width / 2, y: 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]); } const copiedMessageDurationMs = 2000; function useMessageCopyAction( item: ChatMessageInfoItem, ): ?MessageTooltipAction { const { messageInfo } = item; const [successful, setSuccessful] = React.useState(false); const resetStatusAfterTimeout = React.useRef( _debounce(() => setSuccessful(false), copiedMessageDurationMs), ); const onSuccess = React.useCallback(() => { setSuccessful(true); resetStatusAfterTimeout.current(); }, []); React.useEffect(() => resetStatusAfterTimeout.current.cancel, []); return React.useMemo(() => { if (messageInfo.type !== messageTypes.TEXT) { return null; } const buttonContent = ; const onClick = async () => { try { await navigator.clipboard.writeText(messageInfo.text); onSuccess(); } catch (e) { setSuccessful(false); } }; return { actionButtonContent: buttonContent, onClick, label: successful ? 'Copied!' : 'Copy', }; }, [messageInfo.text, messageInfo.type, onSuccess, successful]); } function useMessageReactAction( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): ?MessageTooltipAction { const { messageInfo } = item; - const { setRenderEmojiKeyboard } = useTooltipContext(); + const { setShouldRenderEmojiKeyboard } = useTooltipContext(); const canCreateReactionFromMessage = useCanCreateReactionFromMessage( threadInfo, messageInfo, ); return React.useMemo(() => { if (!canCreateReactionFromMessage) { return null; } const buttonContent = ; const onClickReact = () => { - if (!setRenderEmojiKeyboard) { + if (!setShouldRenderEmojiKeyboard) { return; } - setRenderEmojiKeyboard(true); + setShouldRenderEmojiKeyboard(true); }; return { actionButtonContent: buttonContent, onClick: onClickReact, label: 'React', }; - }, [canCreateReactionFromMessage, setRenderEmojiKeyboard]); + }, [canCreateReactionFromMessage, setShouldRenderEmojiKeyboard]); } function useMessageTooltipActions( item: ChatMessageInfoItem, threadInfo: ThreadInfo, ): $ReadOnlyArray { const sidebarAction = useMessageTooltipSidebarAction(item, threadInfo); const replyAction = useMessageTooltipReplyAction(item, threadInfo); const copyAction = useMessageCopyAction(item); const reactAction = useMessageReactAction(item, threadInfo); return React.useMemo( () => [replyAction, sidebarAction, copyAction, reactAction].filter(Boolean), [replyAction, sidebarAction, copyAction, reactAction], ); } type UseMessageTooltipArgs = { +availablePositions: $ReadOnlyArray, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; type UseMessageTooltipResult = { onMouseEnter: (event: SyntheticEvent) => void, onMouseLeave: ?() => mixed, }; type CreateTooltipParams = { +tooltipMessagePosition: ?PositionInfo, +tooltipSize: TooltipSize, +availablePositions: $ReadOnlyArray, +containsInlineEngagement: boolean, +tooltipActions: $ReadOnlyArray, +messageTimestamp: string, +item: ChatMessageInfoItem, +threadInfo: ThreadInfo, }; function createTooltip(params: CreateTooltipParams) { const { tooltipMessagePosition, tooltipSize, availablePositions, containsInlineEngagement, tooltipActions, messageTimestamp, item, threadInfo, } = params; if (!tooltipMessagePosition) { return; } const tooltipPosition = findTooltipPosition({ sourcePositionInfo: tooltipMessagePosition, tooltipSize, availablePositions, defaultPosition: availablePositions[0], preventDisplayingBelowSource: containsInlineEngagement, }); if (!tooltipPosition) { return; } const tooltipPositionStyle = getMessageActionTooltipStyle({ tooltipPosition, sourcePositionInfo: tooltipMessagePosition, tooltipSize, }); - const { alignment } = tooltipPositionStyle; - const tooltip = ( ); return { tooltip, tooltipPositionStyle }; } function useMessageTooltip({ availablePositions, item, threadInfo, }: UseMessageTooltipArgs): UseMessageTooltipResult { const [onMouseLeave, setOnMouseLeave] = React.useState mixed>(null); const { renderTooltip } = useTooltipContext(); const tooltipActions = useMessageTooltipActions(item, threadInfo); const containsInlineEngagement = !!item.threadCreatedFromMessage; const messageTimestamp = React.useMemo(() => { const time = item.messageInfo.time; return longAbsoluteDate(time); }, [item.messageInfo.time]); 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 updateTooltip = React.useRef(); const [tooltipMessagePosition, setTooltipMessagePosition] = React.useState(); 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 }; setTooltipMessagePosition(messagePosition); const tooltipResult = createTooltip({ tooltipMessagePosition, tooltipSize, availablePositions, containsInlineEngagement, tooltipActions, messageTimestamp, item, threadInfo, }); if (!tooltipResult) { return; } const { tooltip, tooltipPositionStyle } = tooltipResult; const renderTooltipResult = renderTooltip({ newNode: tooltip, tooltipPositionStyle, }); if (renderTooltipResult) { const { onMouseLeaveCallback: callback } = renderTooltipResult; setOnMouseLeave((() => callback: () => () => mixed)); updateTooltip.current = renderTooltipResult.updateTooltip; } }, [ availablePositions, containsInlineEngagement, item, messageTimestamp, renderTooltip, threadInfo, tooltipActions, tooltipMessagePosition, tooltipSize, ], ); React.useEffect(() => { if (!updateTooltip.current) { return; } const tooltipResult = createTooltip({ tooltipMessagePosition, tooltipSize, availablePositions, containsInlineEngagement, tooltipActions, messageTimestamp, item, threadInfo, }); if (!tooltipResult) { return; } updateTooltip.current?.(tooltipResult.tooltip); }, [ availablePositions, containsInlineEngagement, item, messageTimestamp, threadInfo, tooltipActions, tooltipMessagePosition, tooltipSize, ]); return { onMouseEnter, onMouseLeave, }; } export { findTooltipPosition, calculateTooltipSize, getMessageActionTooltipStyle, useMessageTooltipSidebarAction, useMessageTooltipReplyAction, useMessageReactAction, useMessageTooltipActions, useMessageTooltip, };