// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; import { noop } from 'lodash'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import type { Ref } from 'react'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import ReactDOM, { createPortal } from 'react-dom'; import { Manager, Popper, Reference } from 'react-popper'; import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow'; import { isDownloaded } from '../../types/Attachment'; import type { LocalizerType } from '../../types/I18N'; import { handleOutsideClick } from '../../util/handleOutsideClick'; import { offsetDistanceModifier } from '../../util/popperUtil'; import { StopPropagation } from '../StopPropagation'; import { WidthBreakpoint } from '../_util'; import { Message } from './Message'; import type { SmartReactionPicker } from '../../state/smart/ReactionPicker'; import type { Props as MessageProps, PropsActions as MessagePropsActions, PropsData as MessagePropsData, PropsHousekeeping, } from './Message'; import { doesMessageBodyOverflow } from './MessageBodyReadMore'; import type { Props as ReactionPickerProps } from './ReactionPicker'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { useToggleReactionPicker } from '../../hooks/useKeyboardShortcuts'; export type PropsData = { canDownload: boolean; canRetry: boolean; canRetryDeleteForEveryone: boolean; canReact: boolean; canReply: boolean; selectedReaction?: string; isSelected?: boolean; } & Omit; export type PropsActions = { deleteMessage: (options: { conversationId: string; messageId: string; }) => void; deleteMessageForEveryone: (id: string) => void; toggleForwardMessageModal: (id: string) => void; reactToMessage: ( id: string, { emoji, remove }: { emoji: string; remove: boolean } ) => void; retryMessageSend: (id: string) => void; retryDeleteForEveryone: (id: string) => void; setQuoteByMessageId: (conversationId: string, messageId: string) => void; } & MessagePropsActions; export type Props = PropsData & PropsActions & Omit & Pick & { renderReactionPicker: ( props: React.ComponentProps ) => JSX.Element; }; type Trigger = { handleContextClick: (event: React.MouseEvent) => void; }; /** * Message with menu/context-menu (as necessary for rendering in the timeline) */ export function TimelineMessage(props: Props): JSX.Element { const { i18n, id, author, attachments, canDownload, canReact, canReply, canRetry, canDeleteForEveryone, canRetryDeleteForEveryone, contact, payment, conversationId, containerElementRef, containerWidthBreakpoint, deletedForEveryone, deleteMessage, deleteMessageForEveryone, direction, giftBadge, isSelected, isSticker, isTapToView, reactToMessage, setQuoteByMessageId, renderReactionPicker, renderEmojiPicker, retryMessageSend, retryDeleteForEveryone, selectedReaction, toggleForwardMessageModal, showMessageDetail, text, timestamp, kickOffAttachmentDownload, saveAttachment, } = props; const [reactionPickerRoot, setReactionPickerRoot] = useState< HTMLDivElement | undefined >(undefined); const menuTriggerRef = useRef(null); const isWindowWidthNotNarrow = containerWidthBreakpoint !== WidthBreakpoint.Narrow; const popperPreventOverflowModifier = useCallback((): Partial => { return { name: 'preventOverflow', options: { altAxis: true, boundary: containerElementRef.current || undefined, padding: { bottom: 16, left: 8, right: 8, top: 16, }, }, }; }, [containerElementRef]); // This id is what connects our triple-dot click with our associated pop-up menu. // It needs to be unique. const triggerId = String(id || `${author.id}-${timestamp}`); const toggleReactionPicker = useCallback( (onlyRemove = false): void => { if (reactionPickerRoot) { document.body.removeChild(reactionPickerRoot); setReactionPickerRoot(undefined); return; } if (!onlyRemove) { const root = document.createElement('div'); document.body.appendChild(root); setReactionPickerRoot(root); } }, [reactionPickerRoot] ); useEffect(() => { let cleanUpHandler: (() => void) | undefined; if (reactionPickerRoot) { cleanUpHandler = handleOutsideClick( () => { toggleReactionPicker(true); return true; }, { containerElements: [reactionPickerRoot], name: 'Message.reactionPicker', } ); } return () => { cleanUpHandler?.(); }; }); const openGenericAttachment = useCallback( (event?: React.MouseEvent): void => { if (event) { event.preventDefault(); event.stopPropagation(); } if (!attachments || attachments.length !== 1) { return; } const attachment = attachments[0]; if (!isDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id, }); return; } saveAttachment(attachment, timestamp); }, [kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp] ); const handleContextMenu = React.useCallback( (event: React.MouseEvent): void => { const selection = window.getSelection(); if (selection && !selection.isCollapsed) { return; } if (event.target instanceof HTMLAnchorElement) { return; } if (menuTriggerRef.current) { menuTriggerRef.current.handleContextClick(event); } }, [menuTriggerRef] ); const canForward = !isTapToView && !deletedForEveryone && !giftBadge && !contact && !payment; const shouldShowAdditional = doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow; const multipleAttachments = attachments && attachments.length > 1; const firstAttachment = attachments && attachments[0]; const handleDownload = canDownload && !isSticker && !multipleAttachments && !isTapToView && firstAttachment && !firstAttachment.pending ? openGenericAttachment : undefined; const handleReplyToMessage = useCallback(() => { if (!canReply) { return; } setQuoteByMessageId(conversationId, id); }, [canReply, conversationId, id, setQuoteByMessageId]); const handleReact = useCallback(() => { if (canReact) { toggleReactionPicker(); } }, [canReact, toggleReactionPicker]); const [hasDOEConfirmation, setHasDOEConfirmation] = useState(false); const [hasDeleteConfirmation, setHasDeleteConfirmation] = useState(false); const toggleReactionPickerKeyboard = useToggleReactionPicker( handleReact || noop ); useEffect(() => { if (isSelected) { document.addEventListener('keydown', toggleReactionPickerKeyboard); } return () => { document.removeEventListener('keydown', toggleReactionPickerKeyboard); }; }, [isSelected, toggleReactionPickerKeyboard]); const renderMenu = useCallback(() => { return ( {reactionPickerRoot && createPortal( {({ ref, style }) => renderReactionPicker({ ref, style, selected: selectedReaction, onClose: toggleReactionPicker, onPick: emoji => { toggleReactionPicker(true); reactToMessage(id, { emoji, remove: emoji === selectedReaction, }); }, renderEmojiPicker, }) } , reactionPickerRoot )} ); }, [ i18n, triggerId, isWindowWidthNotNarrow, direction, menuTriggerRef, handleContextMenu, handleDownload, handleReplyToMessage, handleReact, reactionPickerRoot, popperPreventOverflowModifier, renderReactionPicker, selectedReaction, reactToMessage, renderEmojiPicker, toggleReactionPicker, id, ]); return ( <> {hasDOEConfirmation && canDeleteForEveryone && ( deleteMessageForEveryone(id), style: 'negative', text: i18n('delete'), }, ]} dialogName="TimelineMessage/deleteMessageForEveryone" i18n={i18n} onClose={() => setHasDOEConfirmation(false)} > {i18n('deleteForEveryoneWarning')} )} {hasDeleteConfirmation && ( deleteMessage({ conversationId, messageId: id, }), style: 'negative', text: i18n('delete'), }, ]} dialogName="TimelineMessage/deleteMessage" i18n={i18n} onClose={() => setHasDeleteConfirmation(false)} > {i18n('deleteWarning')} )}
{ if (!handleReplyToMessage) { return; } ev.stopPropagation(); ev.preventDefault(); handleReplyToMessage(); }} >
retryMessageSend(id) : undefined} onRetryDeleteForEveryone={ canRetryDeleteForEveryone ? () => retryDeleteForEveryone(id) : undefined } onForward={canForward ? () => toggleForwardMessageModal(id) : undefined} onDeleteForMe={() => setHasDeleteConfirmation(true)} onDeleteForEveryone={ canDeleteForEveryone ? () => setHasDOEConfirmation(true) : undefined } onMoreInfo={() => showMessageDetail(id)} /> ); } type MessageMenuProps = { i18n: LocalizerType; triggerId: string; isWindowWidthNotNarrow: boolean; menuTriggerRef: Ref; showMenu: (event: React.MouseEvent) => void; onDownload: (() => void) | undefined; onReplyToMessage: (() => void) | undefined; onReact: (() => void) | undefined; } & Pick; function MessageMenu({ i18n, triggerId, direction, isWindowWidthNotNarrow, menuTriggerRef, showMenu, onDownload, onReplyToMessage, onReact, }: MessageMenuProps) { // This a menu meant for mouse use only /* eslint-disable jsx-a11y/interactive-supports-focus */ /* eslint-disable jsx-a11y/click-events-have-key-events */ const menuButton = ( {({ ref: popperRef }) => { // Only attach the popper reference to the collapsed menu button if the reaction // button is not visible (it is hidden when the timeline is narrow) const maybePopperRef = !isWindowWidthNotNarrow ? popperRef : undefined; return (
{ // Prevent double click from triggering the replyToMessage action ev.stopPropagation(); }} /> ); }} ); /* eslint-enable jsx-a11y/interactive-supports-focus */ /* eslint-enable jsx-a11y/click-events-have-key-events */ return (
{isWindowWidthNotNarrow && ( <> {onReact && ( {({ ref: popperRef }) => { // Only attach the popper reference to the reaction button if it is // visible (it is hidden when the timeline is narrow) const maybePopperRef = isWindowWidthNotNarrow ? popperRef : undefined; return ( // This a menu meant for mouse use only // eslint-disable-next-line max-len // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
{ event.stopPropagation(); event.preventDefault(); onReact(); }} role="button" className="module-message__buttons__react" aria-label={i18n('reactToMessage')} onDoubleClick={ev => { // Prevent double click from triggering the replyToMessage action ev.stopPropagation(); }} /> ); }} )} {onDownload && ( // This a menu meant for mouse use only // eslint-disable-next-line max-len // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
{ // Prevent double click from triggering the replyToMessage action ev.stopPropagation(); }} /> )} {onReplyToMessage && ( // This a menu meant for mouse use only // eslint-disable-next-line max-len // eslint-disable-next-line jsx-a11y/interactive-supports-focus, jsx-a11y/click-events-have-key-events
{ event.stopPropagation(); event.preventDefault(); onReplyToMessage(); }} // This a menu meant for mouse use only role="button" aria-label={i18n('replyToMessage')} className={classNames( 'module-message__buttons__reply', `module-message__buttons__download--${direction}` )} onDoubleClick={ev => { // Prevent double click from triggering the replyToMessage action ev.stopPropagation(); }} /> )} )} {menuButton}
); } type MessageContextProps = { i18n: LocalizerType; triggerId: string; shouldShowAdditional: boolean; onDownload: (() => void) | undefined; onReplyToMessage: (() => void) | undefined; onReact: (() => void) | undefined; onRetryMessageSend: (() => void) | undefined; onRetryDeleteForEveryone: (() => void) | undefined; onForward: (() => void) | undefined; onDeleteForMe: () => void; onDeleteForEveryone: (() => void) | undefined; onMoreInfo: () => void; }; const MessageContextMenu = ({ i18n, triggerId, shouldShowAdditional, onDownload, onReplyToMessage, onReact, onMoreInfo, onRetryMessageSend, onRetryDeleteForEveryone, onForward, onDeleteForMe, onDeleteForEveryone, }: MessageContextProps): JSX.Element => { const menu = ( {shouldShowAdditional && ( <> {onDownload && ( {i18n('downloadAttachment')} )} {onReplyToMessage && ( { event.stopPropagation(); event.preventDefault(); onReplyToMessage(); }} > {i18n('replyToMessage')} )} {onReact && ( { event.stopPropagation(); event.preventDefault(); onReact(); }} > {i18n('reactToMessage')} )} )} { event.stopPropagation(); event.preventDefault(); onMoreInfo(); }} > {i18n('moreInfo')} {onRetryMessageSend && ( { event.stopPropagation(); event.preventDefault(); onRetryMessageSend(); }} > {i18n('retrySend')} )} {onRetryDeleteForEveryone && ( { event.stopPropagation(); event.preventDefault(); onRetryDeleteForEveryone(); }} > {i18n('retryDeleteForEveryone')} )} {onForward && ( { event.stopPropagation(); event.preventDefault(); onForward(); }} > {i18n('forwardMessage')} )} { event.stopPropagation(); event.preventDefault(); onDeleteForMe(); }} > {i18n('deleteMessage')} {onDeleteForEveryone && ( { event.stopPropagation(); event.preventDefault(); onDeleteForEveryone(); }} > {i18n('deleteMessageForEveryone')} )} ); return ReactDOM.createPortal(menu, document.body); };