// Copyright 2018-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useRef, useState, useEffect } from 'react'; import { noop } from 'lodash'; import classNames from 'classnames'; import * as MIME from '../../types/MIME'; import * as GoogleChrome from '../../util/GoogleChrome'; import { MessageBody } from './MessageBody'; import type { AttachmentType, ThumbnailType } from '../../types/Attachment'; import type { BodyRangesType, LocalizerType } from '../../types/Util'; import type { ConversationColorType, CustomColorType, } from '../../types/Colors'; import { ContactName } from './ContactName'; import { getTextWithMentions } from '../../util/getTextWithMentions'; import { getClassNamesFor } from '../../util/getClassNamesFor'; import { getCustomColorStyle } from '../../util/getCustomColorStyle'; export type Props = { authorTitle: string; conversationColor: ConversationColorType; curveTopLeft?: boolean; curveTopRight?: boolean; customColor?: CustomColorType; bodyRanges?: BodyRangesType; i18n: LocalizerType; isFromMe: boolean; isIncoming?: boolean; moduleClassName?: string; onClick?: () => void; onClose?: () => void; text: string; rawAttachment?: QuotedAttachmentType; isViewOnce: boolean; referencedMessageNotFound: boolean; doubleCheckMissingQuoteReference?: () => unknown; }; type State = { imageBroken: boolean; }; export type QuotedAttachmentType = Pick< AttachmentType, 'contentType' | 'fileName' | 'isVoiceMessage' | 'thumbnail' >; function validateQuote(quote: Props): boolean { if (quote.text) { return true; } if (quote.rawAttachment) { return true; } return false; } // Long message attachments should not be shown. function getAttachment( rawAttachment: undefined | QuotedAttachmentType ): undefined | QuotedAttachmentType { return rawAttachment && !MIME.isLongMessage(rawAttachment.contentType) ? rawAttachment : undefined; } function getUrl(thumbnail?: ThumbnailType): string | undefined { if (!thumbnail) { return; } return thumbnail.objectUrl || thumbnail.url; } function getTypeLabel({ i18n, isViewOnce = false, contentType, isVoiceMessage, }: { i18n: LocalizerType; isViewOnce?: boolean; contentType: MIME.MIMEType; isVoiceMessage?: boolean; }): string | undefined { if (GoogleChrome.isVideoTypeSupported(contentType)) { if (isViewOnce) { return i18n('message--getDescription--disappearing-video'); } return i18n('video'); } if (GoogleChrome.isImageTypeSupported(contentType)) { if (isViewOnce) { return i18n('message--getDescription--disappearing-photo'); } return i18n('photo'); } if (isViewOnce) { return i18n('message--getDescription--disappearing-media'); } if (MIME.isAudio(contentType) && isVoiceMessage) { return i18n('voiceMessage'); } return MIME.isAudio(contentType) ? i18n('audio') : undefined; } export class Quote extends React.Component { private getClassName: (modifier?: string) => string; constructor(props: Props) { super(props); this.state = { imageBroken: false, }; this.getClassName = getClassNamesFor('module-quote', props.moduleClassName); } override componentDidMount(): void { const { doubleCheckMissingQuoteReference, referencedMessageNotFound } = this.props; if (referencedMessageNotFound) { doubleCheckMissingQuoteReference?.(); } } public handleKeyDown = ( event: React.KeyboardEvent ): void => { const { onClick } = this.props; // This is important to ensure that using this quote to navigate to the referenced // message doesn't also trigger its parent message's keydown. if (onClick && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); event.stopPropagation(); onClick(); } }; public handleClick = (event: React.MouseEvent): void => { const { onClick } = this.props; if (onClick) { event.preventDefault(); event.stopPropagation(); onClick(); } }; public handleImageError = (): void => { window.console.info( 'Message: Image failed to load; failing over to placeholder' ); this.setState({ imageBroken: true, }); }; public renderImage(url: string, icon?: string): JSX.Element { const iconElement = icon ? (
) : null; return ( {iconElement} ); } public renderIcon(icon: string): JSX.Element { return (
); } public renderGenericFile(): JSX.Element | null { const { rawAttachment, isIncoming } = this.props; const attachment = getAttachment(rawAttachment); if (!attachment) { return null; } const { fileName, contentType } = attachment; const isGenericFile = !GoogleChrome.isVideoTypeSupported(contentType) && !GoogleChrome.isImageTypeSupported(contentType) && !MIME.isAudio(contentType); if (!isGenericFile) { return null; } return (
{fileName}
); } public renderIconContainer(): JSX.Element | null { const { rawAttachment, isViewOnce } = this.props; const { imageBroken } = this.state; const attachment = getAttachment(rawAttachment); if (!attachment) { return null; } const { contentType, thumbnail } = attachment; const url = getUrl(thumbnail); if (isViewOnce) { return this.renderIcon('view-once'); } if (GoogleChrome.isVideoTypeSupported(contentType)) { return url && !imageBroken ? this.renderImage(url, 'play') : this.renderIcon('movie'); } if (GoogleChrome.isImageTypeSupported(contentType)) { return url && !imageBroken ? this.renderImage(url) : this.renderIcon('image'); } if (MIME.isAudio(contentType)) { return this.renderIcon('microphone'); } return null; } public renderText(): JSX.Element | null { const { bodyRanges, i18n, text, rawAttachment, isIncoming, isViewOnce } = this.props; if (text) { const quoteText = bodyRanges ? getTextWithMentions(bodyRanges, text) : text; return (
); } const attachment = getAttachment(rawAttachment); if (!attachment) { return null; } const { contentType, isVoiceMessage } = attachment; const typeLabel = getTypeLabel({ i18n, isViewOnce, contentType, isVoiceMessage, }); if (typeLabel) { return (
{typeLabel}
); } return null; } public renderClose(): JSX.Element | null { const { i18n, onClose } = this.props; if (!onClose) { return null; } const clickHandler = (e: React.MouseEvent): void => { e.stopPropagation(); e.preventDefault(); onClose(); }; const keyDownHandler = (e: React.KeyboardEvent): void => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onClose(); } }; // We need the container to give us the flexibility to implement the iOS design. return (
); } public renderAuthor(): JSX.Element { const { authorTitle, i18n, isFromMe, isIncoming } = this.props; return (
{isFromMe ? i18n('you') : }
); } public renderReferenceWarning(): JSX.Element | null { const { conversationColor, customColor, i18n, isIncoming, referencedMessageNotFound, } = this.props; if (!referencedMessageNotFound) { return null; } return (
{i18n('originalMessageNotFound')}
); } public override render(): JSX.Element | null { const { conversationColor, curveTopLeft, curveTopRight, customColor, isIncoming, onClick, referencedMessageNotFound, } = this.props; if (!validateQuote(this.props)) { return null; } return (
{this.renderReferenceWarning()}
); } } function ThumbnailImage({ className, src, onError, children, }: Readonly<{ className: string; src: string; onError: () => void; children: ReactNode; }>): JSX.Element { const imageRef = useRef(new Image()); const [loadedSrc, setLoadedSrc] = useState(null); useEffect(() => { const image = new Image(); image.onload = () => { setLoadedSrc(src); }; image.src = src; imageRef.current = image; return () => { image.onload = noop; }; }, [src]); useEffect(() => { setLoadedSrc(null); }, [src]); useEffect(() => { const image = imageRef.current; image.onerror = onError; return () => { image.onerror = noop; }; }, [onError]); return (
{children}
); }