// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; import ReactDOM, { createPortal } from 'react-dom'; import classNames from 'classnames'; import { drop, groupBy, orderBy, take } from 'lodash'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import { Manager, Popper, Reference } from 'react-popper'; import { ConversationType } from '../../state/ducks/conversations'; import { Avatar } from '../Avatar'; import { Spinner } from '../Spinner'; import { MessageBody } from './MessageBody'; import { ExpireTimer } from './ExpireTimer'; import { ImageGrid } from './ImageGrid'; import { GIF } from './GIF'; import { Image } from './Image'; import { Timestamp } from './Timestamp'; import { ContactName } from './ContactName'; import { Quote, QuotedAttachmentType } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import { OwnProps as ReactionViewerProps, ReactionViewer, } from './ReactionViewer'; import { Props as ReactionPickerProps } from './ReactionPicker'; import { Emoji } from '../emoji/Emoji'; import { LinkPreviewDate } from './LinkPreviewDate'; import { LinkPreviewType } from '../../types/message/LinkPreviews'; import { shouldUseFullSizeLinkPreviewImage } from '../../linkPreviews/shouldUseFullSizeLinkPreviewImage'; import { AttachmentType, canDisplayImage, getExtensionForDisplay, getGridDimensions, getImageDimensions, hasImage, hasNotDownloaded, hasVideoScreenshot, isAudio, isImage, isImageAttachment, isVideo, isGIF, } from '../../types/Attachment'; import { ContactType } from '../../types/Contact'; import { getIncrement } from '../../util/timer'; import { isFileDangerous } from '../../util/isFileDangerous'; import { BodyRangesType, LocalizerType, ThemeType } from '../../types/Util'; import { ColorType } from '../../types/Colors'; import { createRefMerger } from '../_util'; import { emojiToData } from '../emoji/lib'; import { SmartReactionPicker } from '../../state/smart/ReactionPicker'; type Trigger = { handleContextClick: (event: React.MouseEvent) => void; }; const STICKER_SIZE = 200; const GIF_SIZE = 300; const SELECTED_TIMEOUT = 1000; const THREE_HOURS = 3 * 60 * 60 * 1000; export const MessageStatuses = [ 'delivered', 'error', 'partial-sent', 'read', 'sending', 'sent', ] as const; export type MessageStatusType = typeof MessageStatuses[number]; export const InteractionModes = ['mouse', 'keyboard'] as const; export type InteractionModeType = typeof InteractionModes[number]; export const Directions = ['incoming', 'outgoing'] as const; export type DirectionType = typeof Directions[number]; export const ConversationTypes = ['direct', 'group'] as const; export type ConversationTypesType = typeof ConversationTypes[number]; export type AudioAttachmentProps = { id: string; i18n: LocalizerType; buttonRef: React.RefObject; direction: DirectionType; theme: ThemeType | undefined; attachment: AttachmentType; withContentAbove: boolean; withContentBelow: boolean; kickOffAttachmentDownload(): void; onCorrupted(): void; }; export type PropsData = { id: string; conversationId: string; text?: string; textPending?: boolean; isSticker?: boolean; isSelected?: boolean; isSelectedCounter?: number; direction: DirectionType; timestamp: number; status?: MessageStatusType; contact?: ContactType; author: Pick< ConversationType, | 'avatarPath' | 'color' | 'id' | 'name' | 'phoneNumber' | 'profileName' | 'title' >; reducedMotion?: boolean; conversationType: ConversationTypesType; attachments?: Array; quote?: { text: string; rawAttachment?: QuotedAttachmentType; isFromMe: boolean; sentAt: number; authorId: string; authorPhoneNumber?: string; authorProfileName?: string; authorTitle: string; authorName?: string; authorColor?: ColorType; bodyRanges?: BodyRangesType; referencedMessageNotFound: boolean; }; previews: Array; isExpired?: boolean; isTapToView?: boolean; isTapToViewExpired?: boolean; isTapToViewError?: boolean; expirationLength?: number; expirationTimestamp?: number; reactions?: ReactionViewerProps['reactions']; selectedReaction?: string; deletedForEveryone?: boolean; canReply: boolean; canDownload: boolean; canDeleteForEveryone: boolean; isBlocked: boolean; isMessageRequestAccepted: boolean; bodyRanges?: BodyRangesType; }; export type PropsHousekeeping = { i18n: LocalizerType; interactionMode: InteractionModeType; theme?: ThemeType; disableMenu?: boolean; disableScroll?: boolean; collapseMetadata?: boolean; renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; }; export type PropsActions = { clearSelectedMessage: () => unknown; reactToMessage: ( id: string, { emoji, remove }: { emoji: string; remove: boolean } ) => void; replyToMessage: (id: string) => void; retrySend: (id: string) => void; showForwardMessageModal: (id: string) => void; deleteMessage: (id: string) => void; deleteMessageForEveryone: (id: string) => void; showMessageDetail: (id: string) => void; openConversation: (conversationId: string, messageId?: string) => void; showContactDetail: (options: { contact: ContactType; signalAccount?: string; }) => void; showContactModal: (contactId: string) => void; kickOffAttachmentDownload: (options: { attachment: AttachmentType; messageId: string; }) => void; markAttachmentAsCorrupted: (options: { attachment: AttachmentType; messageId: string; }) => void; showVisualAttachment: (options: { attachment: AttachmentType; messageId: string; }) => void; downloadAttachment: (options: { attachment: AttachmentType; timestamp: number; isDangerous: boolean; }) => void; displayTapToViewMessage: (messageId: string) => unknown; openLink: (url: string) => void; scrollToQuotedMessage: (options: { authorId: string; sentAt: number; }) => void; selectMessage?: (messageId: string, conversationId: string) => unknown; showExpiredIncomingTapToViewToast: () => unknown; showExpiredOutgoingTapToViewToast: () => unknown; }; export type Props = PropsData & PropsHousekeeping & PropsActions & Pick; type State = { expiring: boolean; expired: boolean; imageBroken: boolean; isSelected?: boolean; prevSelectedCounter?: number; reactionViewerRoot: HTMLDivElement | null; reactionPickerRoot: HTMLDivElement | null; isWide: boolean; canDeleteForEveryone: boolean; }; const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRED_DELAY = 600; export class Message extends React.Component { public menuTriggerRef: Trigger | undefined; public focusRef: React.RefObject = React.createRef(); public audioButtonRef: React.RefObject = React.createRef(); public reactionsContainerRef: React.RefObject = React.createRef(); public reactionsContainerRefMerger = createRefMerger(); public wideMl: MediaQueryList; public expirationCheckInterval: NodeJS.Timeout | undefined; public expiredTimeout: NodeJS.Timeout | undefined; public selectedTimeout: NodeJS.Timeout | undefined; public deleteForEveryoneTimeout: NodeJS.Timeout | undefined; public constructor(props: Props) { super(props); this.wideMl = window.matchMedia('(min-width: 926px)'); this.wideMl.addEventListener('change', this.handleWideMlChange); this.state = { expiring: false, expired: false, imageBroken: false, isSelected: props.isSelected, prevSelectedCounter: props.isSelectedCounter, reactionViewerRoot: null, reactionPickerRoot: null, isWide: this.wideMl.matches, canDeleteForEveryone: props.canDeleteForEveryone, }; } public static getDerivedStateFromProps(props: Props, state: State): State { const newState = { ...state, canDeleteForEveryone: props.canDeleteForEveryone && state.canDeleteForEveryone, }; if (!props.isSelected) { return { ...newState, isSelected: false, prevSelectedCounter: 0, }; } if ( props.isSelected && props.isSelectedCounter !== state.prevSelectedCounter ) { return { ...newState, isSelected: props.isSelected, prevSelectedCounter: props.isSelectedCounter, }; } return newState; } private hasReactions(): boolean { const { reactions } = this.props; return Boolean(reactions && reactions.length); } public handleWideMlChange = (event: MediaQueryListEvent): void => { this.setState({ isWide: event.matches }); }; public captureMenuTrigger = (triggerRef: Trigger): void => { this.menuTriggerRef = triggerRef; }; public showMenu = (event: React.MouseEvent): void => { if (this.menuTriggerRef) { this.menuTriggerRef.handleContextClick(event); } }; public handleImageError = (): void => { const { id } = this.props; window.log.info( `Message ${id}: Image failed to load; failing over to placeholder` ); this.setState({ imageBroken: true, }); }; public handleFocus = (): void => { const { interactionMode } = this.props; if (interactionMode === 'keyboard') { this.setSelected(); } }; public setSelected = (): void => { const { id, conversationId, selectMessage } = this.props; if (selectMessage) { selectMessage(id, conversationId); } }; public setFocus = (): void => { const container = this.focusRef.current; if (container && !container.contains(document.activeElement)) { container.focus(); } }; public componentDidMount(): void { this.startSelectedTimer(); this.startDeleteForEveryoneTimer(); const { isSelected } = this.props; if (isSelected) { this.setFocus(); } const { expirationLength } = this.props; if (!expirationLength) { return; } const increment = getIncrement(expirationLength); const checkFrequency = Math.max(EXPIRATION_CHECK_MINIMUM, increment); this.checkExpired(); this.expirationCheckInterval = setInterval(() => { this.checkExpired(); }, checkFrequency); } public componentWillUnmount(): void { if (this.selectedTimeout) { clearTimeout(this.selectedTimeout); } if (this.expirationCheckInterval) { clearInterval(this.expirationCheckInterval); } if (this.expiredTimeout) { clearTimeout(this.expiredTimeout); } if (this.deleteForEveryoneTimeout) { clearTimeout(this.deleteForEveryoneTimeout); } this.toggleReactionViewer(true); this.toggleReactionPicker(true); this.wideMl.removeEventListener('change', this.handleWideMlChange); } public componentDidUpdate(prevProps: Props): void { const { canDeleteForEveryone, isSelected } = this.props; this.startSelectedTimer(); if (!prevProps.isSelected && isSelected) { this.setFocus(); } this.checkExpired(); if (canDeleteForEveryone !== prevProps.canDeleteForEveryone) { this.startDeleteForEveryoneTimer(); } } public startSelectedTimer(): void { const { clearSelectedMessage, interactionMode } = this.props; const { isSelected } = this.state; if (interactionMode === 'keyboard' || !isSelected) { return; } if (!this.selectedTimeout) { this.selectedTimeout = setTimeout(() => { this.selectedTimeout = undefined; this.setState({ isSelected: false }); clearSelectedMessage(); }, SELECTED_TIMEOUT); } } public startDeleteForEveryoneTimer(): void { if (this.deleteForEveryoneTimeout) { clearTimeout(this.deleteForEveryoneTimeout); } const { canDeleteForEveryone } = this.props; if (!canDeleteForEveryone) { return; } const { timestamp } = this.props; const timeToDeletion = timestamp - Date.now() + THREE_HOURS; if (timeToDeletion <= 0) { this.setState({ canDeleteForEveryone: false }); } else { this.deleteForEveryoneTimeout = setTimeout(() => { this.setState({ canDeleteForEveryone: false }); }, timeToDeletion); } } public checkExpired(): void { const now = Date.now(); const { isExpired, expirationTimestamp, expirationLength } = this.props; if (!expirationTimestamp || !expirationLength) { return; } if (this.expiredTimeout) { return; } if (isExpired || now >= expirationTimestamp) { this.setState({ expiring: true, }); const setExpired = () => { this.setState({ expired: true, }); }; this.expiredTimeout = setTimeout(setExpired, EXPIRED_DELAY); } } private areLinksEnabled(): boolean { const { isMessageRequestAccepted, isBlocked } = this.props; return isMessageRequestAccepted && !isBlocked; } public renderTimestamp(): JSX.Element { const { direction, i18n, id, isSticker, isTapToViewExpired, showMessageDetail, status, text, timestamp, } = this.props; const isShowingImage = this.isShowingImage(); const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage); const isError = status === 'error' && direction === 'outgoing'; const isPartiallySent = status === 'partial-sent' && direction === 'outgoing'; if (isError || isPartiallySent) { return ( {isError ? ( i18n('sendFailed') ) : ( )} ); } const metadataDirection = isSticker ? undefined : direction; return ( ); } public renderMetadata(): JSX.Element | null { const { collapseMetadata, direction, expirationLength, expirationTimestamp, isSticker, isTapToViewExpired, status, text, textPending, } = this.props; if (collapseMetadata) { return null; } const isShowingImage = this.isShowingImage(); const withImageNoCaption = Boolean(!isSticker && !text && isShowingImage); const metadataDirection = isSticker ? undefined : direction; return (
{this.renderTimestamp()} {expirationLength && expirationTimestamp ? ( ) : null} {textPending ? (
) : null} {!textPending && direction === 'outgoing' && status !== 'error' && status !== 'partial-sent' ? (
) : null}
); } public renderAuthor(): JSX.Element | null { const { author, collapseMetadata, conversationType, direction, i18n, isSticker, isTapToView, isTapToViewExpired, } = this.props; if (collapseMetadata) { return null; } if ( direction !== 'incoming' || conversationType !== 'group' || !author.title ) { return null; } const withTapToViewExpired = isTapToView && isTapToViewExpired; const stickerSuffix = isSticker ? '_with_sticker' : ''; const tapToViewSuffix = withTapToViewExpired ? '--with-tap-to-view-expired' : ''; const moduleName = `module-message__author${stickerSuffix}${tapToViewSuffix}`; return (
); } public renderAttachment(): JSX.Element | null { const { attachments, collapseMetadata, conversationType, direction, i18n, id, kickOffAttachmentDownload, markAttachmentAsCorrupted, quote, showVisualAttachment, isSticker, text, theme, reducedMotion, renderAudioAttachment, } = this.props; const { imageBroken } = this.state; if (!attachments || !attachments[0]) { return null; } const firstAttachment = attachments[0]; // For attachments which aren't full-frame const withContentBelow = Boolean(text); const withContentAbove = Boolean(quote) || (conversationType === 'group' && direction === 'incoming'); const displayImage = canDisplayImage(attachments); if (displayImage && !imageBroken) { const prefix = isSticker ? 'sticker' : 'attachment'; const containerClassName = classNames( `module-message__${prefix}-container`, withContentAbove ? `module-message__${prefix}-container--with-content-above` : null, withContentBelow ? 'module-message__attachment-container--with-content-below' : null, isSticker && !collapseMetadata ? 'module-message__sticker-container--with-content-below' : null ); if (isGIF(attachments)) { return (
{ kickOffAttachmentDownload({ attachment: firstAttachment, messageId: id, }); }} />
); } if (isImage(attachments) || isVideo(attachments)) { const bottomOverlay = !isSticker && !collapseMetadata; // We only want users to tab into this if there's more than one const tabIndex = attachments.length > 1 ? 0 : -1; return (
{ if (hasNotDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id }); } else { showVisualAttachment({ attachment, messageId: id }); } }} />
); } } if (isAudio(attachments)) { return renderAudioAttachment({ i18n, buttonRef: this.audioButtonRef, id, direction, theme, attachment: firstAttachment, withContentAbove, withContentBelow, kickOffAttachmentDownload() { kickOffAttachmentDownload({ attachment: firstAttachment, messageId: id, }); }, onCorrupted() { markAttachmentAsCorrupted({ attachment: firstAttachment, messageId: id, }); }, }); } const { pending, fileName, fileSize, contentType } = firstAttachment; const extension = getExtensionForDisplay({ contentType, fileName }); const isDangerous = isFileDangerous(fileName || ''); return ( ); } public renderPreview(): JSX.Element | null { const { id, attachments, conversationType, direction, i18n, openLink, previews, quote, theme, kickOffAttachmentDownload, } = this.props; // Attachments take precedence over Link Previews if (attachments && attachments.length) { return null; } if (!previews || previews.length < 1) { return null; } const first = previews[0]; if (!first) { return null; } const withContentAbove = Boolean(quote) || (conversationType === 'group' && direction === 'incoming'); const previewHasImage = isImageAttachment(first.image); const isFullSizeImage = shouldUseFullSizeLinkPreviewImage(first); const linkPreviewDate = first.date || null; const isClickable = this.areLinksEnabled(); const className = classNames( 'module-message__link-preview', `module-message__link-preview--${direction}`, { 'module-message__link-preview--with-content-above': withContentAbove, 'module-message__link-preview--nonclickable': !isClickable, } ); const onPreviewImageClick = () => { if (first.image && hasNotDownloaded(first.image)) { kickOffAttachmentDownload({ attachment: first.image, messageId: id, }); } }; const contents = ( <> {first.image && previewHasImage && isFullSizeImage ? ( ) : null}
{first.image && previewHasImage && !isFullSizeImage ? (
{i18n('previewThumbnail',
) : null}
{first.title}
{first.description && (
{first.description}
)}
{first.domain}
); return isClickable ? (
{ if (event.key === 'Enter' || event.key === 'Space') { event.stopPropagation(); event.preventDefault(); openLink(first.url); } }} onClick={(event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); openLink(first.url); }} > {contents}
) : (
{contents}
); } public renderQuote(): JSX.Element | null { const { author, conversationType, direction, disableScroll, i18n, quote, scrollToQuotedMessage, } = this.props; if (!quote) { return null; } const withContentAbove = conversationType === 'group' && direction === 'incoming'; const quoteColor = direction === 'incoming' ? author.color : quote.authorColor; const { referencedMessageNotFound } = quote; const clickHandler = disableScroll ? undefined : () => { scrollToQuotedMessage({ authorId: quote.authorId, sentAt: quote.sentAt, }); }; return ( ); } public renderEmbeddedContact(): JSX.Element | null { const { collapseMetadata, contact, conversationType, direction, i18n, showContactDetail, text, } = this.props; if (!contact) { return null; } const withCaption = Boolean(text); const withContentAbove = conversationType === 'group' && direction === 'incoming'; const withContentBelow = withCaption || !collapseMetadata; const otherContent = (contact && contact.signalAccount) || withCaption; const tabIndex = otherContent ? 0 : -1; return ( { showContactDetail({ contact, signalAccount: contact.signalAccount }); }} withContentAbove={withContentAbove} withContentBelow={withContentBelow} tabIndex={tabIndex} /> ); } public renderSendMessageButton(): JSX.Element | null { const { contact, openConversation, i18n } = this.props; if (!contact || !contact.signalAccount) { return null; } return ( ); } public renderAvatar(): JSX.Element | undefined { const { author, collapseMetadata, conversationType, direction, i18n, showContactModal, } = this.props; if ( collapseMetadata || conversationType !== 'group' || direction === 'outgoing' ) { return undefined; } return (
); } public renderText(): JSX.Element | null { const { bodyRanges, deletedForEveryone, direction, i18n, openConversation, status, text, textPending, } = this.props; // eslint-disable-next-line no-nested-ternary const contents = deletedForEveryone ? i18n('message--deletedForEveryone') : direction === 'incoming' && status === 'error' ? i18n('incomingError') : text; if (!contents) { return null; } return (
); } public renderError(isCorrectSide: boolean): JSX.Element | null { const { status, direction } = this.props; if (!isCorrectSide || (status !== 'error' && status !== 'partial-sent')) { return null; } return (
); } public renderMenu( isCorrectSide: boolean, triggerId: string ): JSX.Element | null { const { attachments, canDownload, canReply, direction, disableMenu, i18n, id, isSticker, isTapToView, reactToMessage, renderEmojiPicker, replyToMessage, selectedReaction, } = this.props; if (!isCorrectSide || disableMenu) { return null; } const { reactionPickerRoot, isWide } = this.state; const multipleAttachments = attachments && attachments.length > 1; const firstAttachment = attachments && attachments[0]; const downloadButton = !isSticker && !multipleAttachments && !isTapToView && firstAttachment && !firstAttachment.pending ? ( // 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
) : null; const reactButton = ( {({ ref: popperRef }) => { // Only attach the popper reference to the reaction button if it is // visible in the page (it is hidden when the page is narrow) const maybePopperRef = isWide ? 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(); this.toggleReactionPicker(); }} role="button" className="module-message__buttons__react" aria-label={i18n('reactToMessage')} /> ); }} ); const replyButton = ( // 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(); replyToMessage(id); }} // 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}` )} /> ); // 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 in the page (it is hidden when // the page is narrow) const maybePopperRef = !isWide ? popperRef : undefined; return (
); }} ); /* eslint-enable jsx-a11y/interactive-supports-focus */ /* eslint-enable jsx-a11y/click-events-have-key-events */ return (
{canReply ? reactButton : null} {canDownload ? downloadButton : null} {canReply ? replyButton : null} {menuButton}
{reactionPickerRoot && createPortal( // eslint-disable-next-line consistent-return {({ ref, style }) => ( { this.toggleReactionPicker(true); reactToMessage(id, { emoji, remove: emoji === selectedReaction, }); }} renderEmojiPicker={renderEmojiPicker} /> )} , reactionPickerRoot )}
); } public renderContextMenu(triggerId: string): JSX.Element { const { attachments, canDownload, canReply, deleteMessage, deleteMessageForEveryone, deletedForEveryone, direction, i18n, id, isSticker, isTapToView, replyToMessage, retrySend, showForwardMessageModal, showMessageDetail, status, } = this.props; const canForward = !isTapToView && !deletedForEveryone; const { canDeleteForEveryone } = this.state; const showRetry = (status === 'error' || status === 'partial-sent') && direction === 'outgoing'; const multipleAttachments = attachments && attachments.length > 1; const menu = ( {canDownload && !isSticker && !multipleAttachments && !isTapToView && attachments && attachments[0] ? ( {i18n('downloadAttachment')} ) : null} {canReply ? ( <> { event.stopPropagation(); event.preventDefault(); replyToMessage(id); }} > {i18n('replyToMessage')} { event.stopPropagation(); event.preventDefault(); this.toggleReactionPicker(); }} > {i18n('reactToMessage')} ) : null} { event.stopPropagation(); event.preventDefault(); showMessageDetail(id); }} > {i18n('moreInfo')} {showRetry ? ( { event.stopPropagation(); event.preventDefault(); retrySend(id); }} > {i18n('retrySend')} ) : null} {canForward ? ( { event.stopPropagation(); event.preventDefault(); showForwardMessageModal(id); }} > {i18n('forwardMessage')} ) : null} { event.stopPropagation(); event.preventDefault(); deleteMessage(id); }} > {i18n('deleteMessage')} {canDeleteForEveryone ? ( { event.stopPropagation(); event.preventDefault(); deleteMessageForEveryone(id); }} > {i18n('deleteMessageForEveryone')} ) : null} ); return ReactDOM.createPortal(menu, document.body); } public getWidth(): number | undefined { const { attachments, isSticker, previews } = this.props; if (attachments && attachments.length) { if (isGIF(attachments)) { // Message container border + image border return GIF_SIZE + 4; } if (isSticker) { // Padding is 8px, on both sides, plus two for 1px border return STICKER_SIZE + 8 * 2 + 2; } const dimensions = getGridDimensions(attachments); if (dimensions) { // Add two for 1px border return dimensions.width + 2; } } const firstLinkPreview = (previews || [])[0]; if ( firstLinkPreview && firstLinkPreview.image && shouldUseFullSizeLinkPreviewImage(firstLinkPreview) ) { const dimensions = getImageDimensions(firstLinkPreview.image); if (dimensions) { // Add two for 1px border return dimensions.width + 2; } } return undefined; } // Messy return here. // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types public isShowingImage() { const { isTapToView, attachments, previews } = this.props; const { imageBroken } = this.state; if (imageBroken || isTapToView) { return false; } if (attachments && attachments.length) { const displayImage = canDisplayImage(attachments); return displayImage && (isImage(attachments) || isVideo(attachments)); } if (previews && previews.length) { const first = previews[0]; const { image } = first; return isImageAttachment(image); } return false; } public isAttachmentPending(): boolean { const { attachments } = this.props; if (!attachments || attachments.length < 1) { return false; } const first = attachments[0]; return Boolean(first.pending); } public renderTapToViewIcon(): JSX.Element { const { direction, isTapToViewExpired } = this.props; const isDownloadPending = this.isAttachmentPending(); return !isTapToViewExpired && isDownloadPending ? (
) : (
); } public renderTapToViewText(): string | undefined { const { attachments, direction, i18n, isTapToViewExpired, isTapToViewError, } = this.props; const incomingString = isTapToViewExpired ? i18n('Message--tap-to-view-expired') : i18n( `Message--tap-to-view--incoming${ isVideo(attachments) ? '-video' : '' }` ); const outgoingString = i18n('Message--tap-to-view--outgoing'); const isDownloadPending = this.isAttachmentPending(); if (isDownloadPending) { return; } // eslint-disable-next-line consistent-return, no-nested-ternary return isTapToViewError ? i18n('incomingError') : direction === 'outgoing' ? outgoingString : incomingString; } public renderTapToView(): JSX.Element { const { collapseMetadata, conversationType, direction, isTapToViewExpired, isTapToViewError, } = this.props; const withContentBelow = !collapseMetadata; const withContentAbove = !collapseMetadata && conversationType === 'group' && direction === 'incoming'; return (
{isTapToViewError ? null : this.renderTapToViewIcon()}
{this.renderTapToViewText()}
); } public toggleReactionViewer = (onlyRemove = false): void => { this.setState(({ reactionViewerRoot }) => { if (reactionViewerRoot) { document.body.removeChild(reactionViewerRoot); document.body.removeEventListener( 'click', this.handleClickOutsideReactionViewer, true ); return { reactionViewerRoot: null }; } if (!onlyRemove) { const root = document.createElement('div'); document.body.appendChild(root); document.body.addEventListener( 'click', this.handleClickOutsideReactionViewer, true ); return { reactionViewerRoot: root, }; } return { reactionViewerRoot: null }; }); }; public toggleReactionPicker = (onlyRemove = false): void => { this.setState(({ reactionPickerRoot }) => { if (reactionPickerRoot) { document.body.removeChild(reactionPickerRoot); document.body.removeEventListener( 'click', this.handleClickOutsideReactionPicker, true ); return { reactionPickerRoot: null }; } if (!onlyRemove) { const root = document.createElement('div'); document.body.appendChild(root); document.body.addEventListener( 'click', this.handleClickOutsideReactionPicker, true ); return { reactionPickerRoot: root, }; } return { reactionPickerRoot: null }; }); }; public handleClickOutsideReactionViewer = (e: MouseEvent): void => { const { reactionViewerRoot } = this.state; const { current: reactionsContainer } = this.reactionsContainerRef; if (reactionViewerRoot && reactionsContainer) { if ( !reactionViewerRoot.contains(e.target as HTMLElement) && !reactionsContainer.contains(e.target as HTMLElement) ) { this.toggleReactionViewer(true); } } }; public handleClickOutsideReactionPicker = (e: MouseEvent): void => { const { reactionPickerRoot } = this.state; if (reactionPickerRoot) { if (!reactionPickerRoot.contains(e.target as HTMLElement)) { this.toggleReactionPicker(true); } } }; public renderReactions(outgoing: boolean): JSX.Element | null { const { reactions = [], i18n } = this.props; if (!this.hasReactions()) { return null; } const reactionsWithEmojiData = reactions.map(reaction => ({ ...reaction, ...emojiToData(reaction.emoji), })); // Group by emoji and order each group by timestamp descending const groupedAndSortedReactions = Object.values( groupBy(reactionsWithEmojiData, 'short_name') ).map(groupedReactions => orderBy( groupedReactions, [reaction => reaction.from.isMe, 'timestamp'], ['desc', 'desc'] ) ); // Order groups by length and subsequently by most recent reaction const ordered = orderBy( groupedAndSortedReactions, ['length', ([{ timestamp }]) => timestamp], ['desc', 'desc'] ); // Take the first three groups for rendering const toRender = take(ordered, 3).map(res => ({ emoji: res[0].emoji, count: res.length, isMe: res.some(re => Boolean(re.from.isMe)), })); const someNotRendered = ordered.length > 3; // We only drop two here because the third emoji would be replaced by the // more button const maybeNotRendered = drop(ordered, 2); const maybeNotRenderedTotal = maybeNotRendered.reduce( (sum, res) => sum + res.length, 0 ); const notRenderedIsMe = someNotRendered && maybeNotRendered.some(res => res.some(re => Boolean(re.from.isMe))); const { reactionViewerRoot } = this.state; const popperPlacement = outgoing ? 'bottom-end' : 'bottom-start'; return ( {({ ref: popperRef }) => (
{toRender.map((re, i) => { const isLast = i === toRender.length - 1; const isMore = isLast && someNotRendered; const isMoreWithMe = isMore && notRenderedIsMe; return ( ); })}
)}
{reactionViewerRoot && createPortal( {({ ref, style }) => ( )} , reactionViewerRoot )}
); } public renderContents(): JSX.Element | null { const { isTapToView, deletedForEveryone } = this.props; if (deletedForEveryone) { return this.renderText(); } if (isTapToView) { return ( <> {this.renderTapToView()} {this.renderMetadata()} ); } return ( <> {this.renderQuote()} {this.renderAttachment()} {this.renderPreview()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderMetadata()} {this.renderSendMessageButton()} ); } public handleOpen = ( event: React.KeyboardEvent | React.MouseEvent ): void => { const { attachments, contact, displayTapToViewMessage, direction, id, isTapToView, isTapToViewExpired, kickOffAttachmentDownload, openConversation, showContactDetail, showVisualAttachment, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, } = this.props; const { imageBroken } = this.state; const isAttachmentPending = this.isAttachmentPending(); // Don't show lightbox for GIFs if (isGIF(attachments)) { return; } if (isTapToView) { if (isAttachmentPending) { return; } if (isTapToViewExpired) { const action = direction === 'outgoing' ? showExpiredOutgoingTapToViewToast : showExpiredIncomingTapToViewToast; action(); } else { event.preventDefault(); event.stopPropagation(); displayTapToViewMessage(id); } return; } if ( !imageBroken && attachments && attachments.length > 0 && !isAttachmentPending && (isImage(attachments) || isVideo(attachments)) && hasNotDownloaded(attachments[0]) ) { event.preventDefault(); event.stopPropagation(); const attachment = attachments[0]; kickOffAttachmentDownload({ attachment, messageId: id }); return; } if ( !imageBroken && attachments && attachments.length > 0 && !isAttachmentPending && canDisplayImage(attachments) && ((isImage(attachments) && hasImage(attachments)) || (isVideo(attachments) && hasVideoScreenshot(attachments))) ) { event.preventDefault(); event.stopPropagation(); const attachment = attachments[0]; showVisualAttachment({ attachment, messageId: id }); return; } if ( attachments && attachments.length === 1 && !isAttachmentPending && !isAudio(attachments) ) { event.preventDefault(); event.stopPropagation(); this.openGenericAttachment(); return; } if ( !isAttachmentPending && isAudio(attachments) && this.audioButtonRef && this.audioButtonRef.current ) { event.preventDefault(); event.stopPropagation(); this.audioButtonRef.current.click(); } if (contact && contact.signalAccount) { openConversation(contact.signalAccount); event.preventDefault(); event.stopPropagation(); } if (contact) { showContactDetail({ contact, signalAccount: contact.signalAccount }); event.preventDefault(); event.stopPropagation(); } }; public openGenericAttachment = (event?: React.MouseEvent): void => { const { id, attachments, downloadAttachment, timestamp, kickOffAttachmentDownload, } = this.props; if (event) { event.preventDefault(); event.stopPropagation(); } if (!attachments || attachments.length !== 1) { return; } const attachment = attachments[0]; if (hasNotDownloaded(attachment)) { kickOffAttachmentDownload({ attachment, messageId: id, }); return; } const { fileName } = attachment; const isDangerous = isFileDangerous(fileName || ''); downloadAttachment({ isDangerous, attachment, timestamp, }); }; public handleKeyDown = (event: React.KeyboardEvent): void => { // Do not allow reactions to error messages const { canReply } = this.props; if ( (event.key === 'E' || event.key === 'e') && (event.metaKey || event.ctrlKey) && event.shiftKey && canReply ) { this.toggleReactionPicker(); } if (event.key !== 'Enter' && event.key !== 'Space') { return; } this.handleOpen(event); }; public handleClick = (event: React.MouseEvent): void => { // We don't want clicks on body text to result in the 'default action' for the message const { text } = this.props; if (text && text.length > 0) { return; } // If there an incomplete attachment, do not execute the default action const { attachments } = this.props; if (attachments && attachments.length > 0) { const [firstAttachment] = attachments; if (!firstAttachment.url) { return; } } this.handleOpen(event); }; public renderContainer(): JSX.Element { const { attachments, author, deletedForEveryone, direction, isSticker, isTapToView, isTapToViewExpired, isTapToViewError, } = this.props; const { isSelected } = this.state; const isAttachmentPending = this.isAttachmentPending(); const width = this.getWidth(); const isShowingImage = this.isShowingImage(); const containerClassnames = classNames( 'module-message__container', isGIF(attachments) ? 'module-message__container--gif' : null, isSelected && !isSticker ? 'module-message__container--selected' : null, isSticker ? 'module-message__container--with-sticker' : null, !isSticker ? `module-message__container--${direction}` : null, isTapToView ? 'module-message__container--with-tap-to-view' : null, isTapToView && isTapToViewExpired ? 'module-message__container--with-tap-to-view-expired' : null, !isSticker && direction === 'incoming' ? `module-message__container--incoming-${author.color}` : null, isTapToView && isAttachmentPending && !isTapToViewExpired ? 'module-message__container--with-tap-to-view-pending' : null, isTapToView && isAttachmentPending && !isTapToViewExpired ? `module-message__container--${direction}-${author.color}-tap-to-view-pending` : null, isTapToViewError ? 'module-message__container--with-tap-to-view-error' : null, this.hasReactions() ? 'module-message__container--with-reactions' : null, deletedForEveryone ? 'module-message__container--deleted-for-everyone' : null ); const containerStyles = { width: isShowingImage ? width : undefined, }; return (
{this.renderAuthor()} {this.renderContents()}
{this.renderReactions(direction === 'outgoing')}
); } public render(): JSX.Element | null { const { author, attachments, direction, id, isSticker, timestamp, } = this.props; const { expired, expiring, imageBroken, isSelected } = this.state; // 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}`); if (expired) { return null; } if (isSticker && (imageBroken || !attachments || !attachments.length)) { return null; } return (
{this.renderError(direction === 'incoming')} {this.renderMenu(direction === 'outgoing', triggerId)} {this.renderAvatar()} {this.renderContainer()} {this.renderError(direction === 'outgoing')} {this.renderMenu(direction === 'incoming', triggerId)} {this.renderContextMenu(triggerId)}
); } }