// Copyright 2019-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import classNames from 'classnames'; import React, { 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 { isFileDangerous } from '../../util/isFileDangerous'; 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'; export type PropsData = { canDownload: boolean; canRetry: boolean; canRetryDeleteForEveryone: boolean; canReact: boolean; canReply: boolean; selectedReaction?: string; } & Omit; export type PropsActions = { deleteMessage: (id: string) => void; deleteMessageForEveryone: (id: string) => void; showForwardMessageModal: (id: string) => void; reactToMessage: ( id: string, { emoji, remove }: { emoji: string; remove: boolean } ) => void; retrySend: (id: string) => void; retryDeleteForEveryone: (id: string) => void; replyToMessage: (id: 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 const TimelineMessage = (props: Props): JSX.Element => { const { i18n, id, author, attachments, canDownload, canReact, canReply, canRetry, canDeleteForEveryone, canRetryDeleteForEveryone, contact, containerElementRef, containerWidthBreakpoint, deletedForEveryone, deleteMessage, deleteMessageForEveryone, direction, giftBadge, isSticker, isTapToView, reactToMessage, replyToMessage, renderReactionPicker, renderEmojiPicker, retrySend, retryDeleteForEveryone, selectedReaction, showForwardMessageModal, showMessageDetail, text, timestamp, } = props; const [reactionPickerRoot, setReactionPickerRoot] = useState< HTMLDivElement | undefined >(undefined); const menuTriggerRef = useRef(null); const isWindowWidthNotNarrow = containerWidthBreakpoint !== WidthBreakpoint.Narrow; function popperPreventOverflowModifier(): Partial { return { name: 'preventOverflow', options: { altAxis: true, boundary: containerElementRef.current || undefined, padding: { bottom: 16, left: 8, right: 8, top: 16, }, }, }; } // 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 = React.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 = (event?: React.MouseEvent): void => { const { downloadAttachment, kickOffAttachmentDownload } = props; if (event) { event.preventDefault(); event.stopPropagation(); } if (!attachments || attachments.length !== 1) { return; } const attachment = attachments[0]; if (!isDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id, }); return; } const { fileName } = attachment; const isDangerous = isFileDangerous(fileName || ''); downloadAttachment({ isDangerous, attachment, timestamp, }); }; const handleContextMenu = (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); } }; const canForward = !isTapToView && !deletedForEveryone && !giftBadge && !contact; 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 = canReply ? () => replyToMessage(id) : undefined; const handleReact = canReact ? () => toggleReactionPicker() : undefined; return ( <> {reactionPickerRoot && createPortal( {({ ref, style }) => renderReactionPicker({ ref, style, selected: selectedReaction, onClose: toggleReactionPicker, onPick: emoji => { toggleReactionPicker(true); reactToMessage(id, { emoji, remove: emoji === selectedReaction, }); }, renderEmojiPicker, }) } , reactionPickerRoot )} } /> retrySend(id) : undefined} onRetryDeleteForEveryone={ canRetryDeleteForEveryone ? () => retryDeleteForEveryone(id) : undefined } onForward={canForward ? () => showForwardMessageModal(id) : undefined} onDeleteForMe={() => deleteMessage(id)} onDeleteForEveryone={ canDeleteForEveryone ? () => deleteMessageForEveryone(id) : 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; const 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 (
); }} ); /* 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')} /> ); }} )} {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
)} {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}` )} /> )} )} {menuButton}
); }; type MessageContextProps = { i18n: LocalizerType; triggerId: string; shouldShowAdditional: boolean; onDownload: (() => void) | undefined; onReplyToMessage: (() => void) | undefined; onReact: (() => void) | undefined; onRetrySend: (() => void) | undefined; onRetryDeleteForEveryone: (() => void) | undefined; onForward: (() => void) | undefined; onDeleteForMe: () => void; onDeleteForEveryone: (() => void) | undefined; onMoreInfo: () => void; }; const MessageContextMenu = ({ i18n, triggerId, shouldShowAdditional, onDownload, onReplyToMessage, onReact, onMoreInfo, onRetrySend, 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')} {onRetrySend && ( { event.stopPropagation(); event.preventDefault(); onRetrySend(); }} > {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); };