// Copyright 2019 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 { ContextMenuTrigger } from 'react-contextmenu'; import { 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 type { PushPanelForConversationActionType } from '../../state/ducks/conversations'; import { doesMessageBodyOverflow } from './MessageBodyReadMore'; import type { Props as ReactionPickerProps } from './ReactionPicker'; import { useKeyboardShortcutsConditionally, useOpenContextMenu, useToggleReactionPicker, } from '../../hooks/useKeyboardShortcuts'; import { PanelType } from '../../types/Panels'; import type { DeleteMessagesPropsType } from '../../state/ducks/globalModals'; import { useScrollerLock } from '../../hooks/useScrollLock'; import { type ContextMenuTriggerType, MessageContextMenu, useHandleMessageContextMenu, } from './MessageContextMenu'; export type PropsData = { canDownload: boolean; canCopy: boolean; canEditMessage: boolean; canRetry: boolean; canRetryDeleteForEveryone: boolean; canReact: boolean; canReply: boolean; selectedReaction?: string; isTargeted?: boolean; } & Omit; export type PropsActions = { pushPanelForConversation: PushPanelForConversationActionType; toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; toggleForwardMessagesModal: (messageIds: Array) => void; reactToMessage: ( id: string, { emoji, remove }: { emoji: string; remove: boolean } ) => void; retryMessageSend: (id: string) => void; copyMessageText: (id: string) => void; retryDeleteForEveryone: (id: string) => void; setMessageToEdit: (conversationId: string, messageId: string) => unknown; setQuoteByMessageId: (conversationId: string, messageId: string) => void; toggleSelectMessage: ( conversationId: string, messageId: string, shift: boolean, selected: boolean ) => void; } & Omit; export type Props = PropsData & PropsActions & Omit & Pick & { renderReactionPicker: ( props: React.ComponentProps ) => JSX.Element; }; /** * Message with menu/context-menu (as necessary for rendering in the timeline) */ export function TimelineMessage(props: Props): JSX.Element { const { attachments, author, canDownload, canCopy, canEditMessage, canReact, canReply, canRetry, canRetryDeleteForEveryone, contact, containerElementRef, containerWidthBreakpoint, conversationId, deletedForEveryone, direction, giftBadge, i18n, id, isTargeted, isSticker, isTapToView, kickOffAttachmentDownload, payment, copyMessageText, pushPanelForConversation, reactToMessage, renderEmojiPicker, renderReactionPicker, retryDeleteForEveryone, retryMessageSend, saveAttachment, selectedReaction, setQuoteByMessageId, setMessageToEdit, text, timestamp, toggleDeleteMessagesModal, toggleForwardMessagesModal, toggleSelectMessage, } = 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] ); useScrollerLock({ reason: 'TimelineMessage reactionPicker', lockScrollWhen: reactionPickerRoot != null, onUserInterrupt() { toggleReactionPicker(true); }, }); 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 = useHandleMessageContextMenu(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 toggleReactionPickerKeyboard = useToggleReactionPicker( handleReact || noop ); const openContextMenuKeyboard = useOpenContextMenu(handleContextMenu); useKeyboardShortcutsConditionally( Boolean(isTargeted), openContextMenuKeyboard, 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, canReply, canReact, handleContextMenu, handleDownload, handleReplyToMessage, handleReact, reactionPickerRoot, popperPreventOverflowModifier, renderReactionPicker, selectedReaction, reactToMessage, renderEmojiPicker, toggleReactionPicker, id, ]); return ( <> { toggleSelectMessage(conversationId, id, shift, selected); }} onReplyToMessage={handleReplyToMessage} /> setMessageToEdit(conversationId, id) : undefined } onReplyToMessage={handleReplyToMessage} onReact={handleReact} onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined} onRetryDeleteForEveryone={ canRetryDeleteForEveryone ? () => retryDeleteForEveryone(id) : undefined } onCopy={canCopy ? () => copyMessageText(id) : undefined} onSelect={() => toggleSelectMessage(conversationId, id, false, true)} onForward={ canForward ? () => toggleForwardMessagesModal([id]) : undefined } onDeleteMessage={() => { toggleDeleteMessagesModal({ conversationId, messageIds: [id], }); }} onMoreInfo={() => pushPanelForConversation({ type: PanelType.MessageDetails, args: { messageId: 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('icu: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('icu: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}
); }