import React from 'react'; import classNames from 'classnames'; import { isImageTypeSupported, isVideoTypeSupported, } from '../../util/GoogleChrome'; import { MessageBody } from './MessageBody'; import { ExpireTimer, getIncrement } from './ExpireTimer'; import { Timestamp } from './Timestamp'; import { ContactName } from './ContactName'; import { Quote, QuotedAttachment } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import { Contact } from '../../types/Contact'; import { Color, Localizer } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; import * as MIME from '../../../ts/types/MIME'; interface Trigger { handleContextClick: (event: React.MouseEvent) => void; } interface Attachment { contentType: MIME.MIMEType; fileName: string; /** Not included in protobuf, needs to be pulled from flags */ isVoiceMessage: boolean; /** For messages not already on disk, this will be a data url */ url: string; fileSize?: string; width: number; height: number; screenshot?: { height: number; width: number; url: string; contentType: MIME.MIMEType; }; thumbnail?: { height: number; width: number; url: string; contentType: MIME.MIMEType; }; } export interface Props { disableMenu?: boolean; text?: string; id?: string; collapseMetadata?: boolean; direction: 'incoming' | 'outgoing'; timestamp: number; status?: 'sending' | 'sent' | 'delivered' | 'read' | 'error'; // What if changed this over to a single contact like quote, and put the events on it? contact?: Contact & { hasSignalAccount: boolean; onSendMessage?: () => void; onClick?: () => void; }; i18n: Localizer; authorName?: string; authorProfileName?: string; /** Note: this should be formatted for display */ authorPhoneNumber: string; authorColor: Color; conversationType: 'group' | 'direct'; attachment?: Attachment; quote?: { text: string; attachment?: QuotedAttachment; isFromMe: boolean; authorPhoneNumber: string; authorProfileName?: string; authorName?: string; authorColor: Color; onClick?: () => void; referencedMessageNotFound: boolean; }; authorAvatarPath?: string; isExpired: boolean; expirationLength?: number; expirationTimestamp?: number; onClickAttachment?: () => void; onReply?: () => void; onRetrySend?: () => void; onDownload?: () => void; onDelete?: () => void; onShowDetail: () => void; } interface State { expiring: boolean; expired: boolean; imageBroken: boolean; } function isImage(attachment?: Attachment) { return ( attachment && attachment.contentType && isImageTypeSupported(attachment.contentType) ); } function hasImage(attachment?: Attachment) { return attachment && attachment.url; } function isVideo(attachment?: Attachment) { return ( attachment && attachment.contentType && isVideoTypeSupported(attachment.contentType) ); } function hasVideoScreenshot(attachment?: Attachment) { return attachment && attachment.screenshot && attachment.screenshot.url; } function isAudio(attachment?: Attachment) { return ( attachment && attachment.contentType && MIME.isAudio(attachment.contentType) ); } function getInitial(name: string): string { return name.trim()[0] || '#'; } function getExtension({ fileName, contentType, }: { fileName: string; contentType: MIME.MIMEType; }): string | null { if (fileName && fileName.indexOf('.') >= 0) { const lastPeriod = fileName.lastIndexOf('.'); const extension = fileName.slice(lastPeriod + 1); if (extension.length) { return extension; } } const slash = contentType.indexOf('/'); if (slash >= 0) { return contentType.slice(slash + 1); } return null; } const MINIMUM_IMG_HEIGHT = 150; const MAXIMUM_IMG_HEIGHT = 300; const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRED_DELAY = 600; export class Message extends React.Component { public captureMenuTriggerBound: (trigger: any) => void; public showMenuBound: (event: React.MouseEvent) => void; public handleImageErrorBound: () => void; public menuTriggerRef: Trigger | null; public expirationCheckInterval: any; public expiredTimeout: any; public constructor(props: Props) { super(props); this.captureMenuTriggerBound = this.captureMenuTrigger.bind(this); this.showMenuBound = this.showMenu.bind(this); this.handleImageErrorBound = this.handleImageError.bind(this); this.menuTriggerRef = null; this.expirationCheckInterval = null; this.expiredTimeout = null; this.state = { expiring: false, expired: false, imageBroken: false, }; } public componentDidMount() { 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() { if (this.expirationCheckInterval) { clearInterval(this.expirationCheckInterval); } if (this.expiredTimeout) { clearTimeout(this.expiredTimeout); } } public componentDidUpdate() { this.checkExpired(); } public checkExpired() { 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); } } public handleImageError() { // tslint:disable-next-line no-console console.log('Message: Image failed to load; failing over to placeholder'); this.setState({ imageBroken: true, }); } public renderMetadata() { const { attachment, collapseMetadata, direction, expirationLength, expirationTimestamp, i18n, status, text, timestamp, } = this.props; const { imageBroken } = this.state; if (collapseMetadata) { return null; } const withImageNoCaption = Boolean( !text && !imageBroken && ((isImage(attachment) && hasImage(attachment)) || (isVideo(attachment) && hasVideoScreenshot(attachment))) ); const showError = status === 'error' && direction === 'outgoing'; return (
{showError ? ( {i18n('sendFailed')} ) : ( )} {expirationLength && expirationTimestamp ? ( ) : null} {direction === 'outgoing' && status !== 'error' ? (
) : null}
); } public renderAuthor() { const { authorName, authorPhoneNumber, authorProfileName, conversationType, direction, i18n, } = this.props; const title = authorName ? authorName : authorPhoneNumber; if (direction !== 'incoming' || conversationType !== 'group' || !title) { return null; } return (
); } // tslint:disable-next-line max-func-body-length cyclomatic-complexity public renderAttachment() { const { i18n, attachment, text, collapseMetadata, conversationType, direction, quote, onClickAttachment, } = this.props; const { imageBroken } = this.state; if (!attachment) { return null; } const withCaption = Boolean(text); // For attachments which aren't full-frame const withContentBelow = withCaption || !collapseMetadata; const withContentAbove = quote || (conversationType === 'group' && direction === 'incoming'); if (isImage(attachment)) { if (imageBroken || !attachment.url) { return (
{i18n('imageFailedToLoad')}
); } // Calculating height to prevent reflow when image loads const height = Math.max(MINIMUM_IMG_HEIGHT, attachment.height || 0); return (
{i18n('imageAttachmentAlt')}
{!withCaption && !collapseMetadata ? (
) : null}
); } else if (isVideo(attachment)) { const { screenshot } = attachment; if (imageBroken || !screenshot || !screenshot.url) { return (
{i18n('videoScreenshotFailedToLoad')}
); } // Calculating height to prevent reflow when image loads const height = Math.max(MINIMUM_IMG_HEIGHT, screenshot.height || 0); return (
{i18n('videoAttachmentAlt')}
{!withCaption && !collapseMetadata ? (
) : null}
); } else if (isAudio(attachment)) { return ( ); } else { const { fileName, fileSize, contentType } = attachment; const extension = getExtension({ contentType, fileName }); return (
{extension ? (
{extension}
) : null}
{fileName}
{fileSize}
); } } public renderQuote() { const { conversationType, direction, i18n, quote } = this.props; if (!quote) { return null; } const withContentAbove = conversationType === 'group' && direction === 'incoming'; return ( ); } public renderEmbeddedContact() { const { collapseMetadata, contact, conversationType, direction, i18n, text, } = this.props; if (!contact) { return null; } const withCaption = Boolean(text); const withContentAbove = conversationType === 'group' && direction === 'incoming'; const withContentBelow = withCaption || !collapseMetadata; return ( ); } public renderSendMessageButton() { const { contact, i18n } = this.props; if (!contact || !contact.hasSignalAccount) { return null; } return (
{i18n('sendMessageToContact')}
); } public renderAvatar() { const { authorName, authorPhoneNumber, authorProfileName, authorAvatarPath, authorColor, collapseMetadata, conversationType, direction, i18n, } = this.props; const title = `${authorName || authorPhoneNumber}${ !authorName && authorProfileName ? ` ~${authorProfileName}` : '' }`; if ( collapseMetadata || conversationType !== 'group' || direction === 'outgoing' ) { return; } if (!authorAvatarPath) { const label = authorName ? getInitial(authorName) : '#'; return (
{label}
); } return (
{i18n('contactAvatarAlt',
); } public renderText() { const { text, i18n, direction, status } = this.props; const contents = direction === 'incoming' && status === 'error' ? i18n('incomingError') : text; if (!contents) { return null; } return (
); } public renderError(isCorrectSide: boolean) { const { status, direction } = this.props; if (!isCorrectSide || status !== 'error') { return null; } return (
); } public captureMenuTrigger(triggerRef: Trigger) { this.menuTriggerRef = triggerRef; } public showMenu(event: React.MouseEvent) { if (this.menuTriggerRef) { this.menuTriggerRef.handleContextClick(event); } } public renderMenu(isCorrectSide: boolean, triggerId: string) { const { attachment, direction, disableMenu, onDownload, onReply, } = this.props; if (!isCorrectSide || disableMenu) { return null; } const downloadButton = attachment ? (
) : null; const replyButton = (
); const menuButton = (
); const first = direction === 'incoming' ? downloadButton : menuButton; const last = direction === 'incoming' ? menuButton : downloadButton; return (
{first} {replyButton} {last}
); } public renderContextMenu(triggerId: string) { const { attachment, direction, status, onDelete, onDownload, onReply, onRetrySend, onShowDetail, i18n, } = this.props; const showRetry = status === 'error' && direction === 'outgoing'; return ( {attachment ? ( {i18n('downloadAttachment')} ) : null} {i18n('replyToMessage')} {i18n('moreInfo')} {showRetry ? ( {i18n('retrySend')} ) : null} {i18n('deleteMessage')} ); } public render() { const { authorPhoneNumber, authorColor, direction, id, timestamp, } = this.props; const { expired, expiring } = 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 || `${authorPhoneNumber}-${timestamp}`); if (expired) { return null; } return (
{this.renderError(direction === 'incoming')} {this.renderMenu(direction === 'outgoing', triggerId)}
{this.renderAuthor()} {this.renderQuote()} {this.renderAttachment()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderMetadata()} {this.renderSendMessageButton()} {this.renderAvatar()}
{this.renderError(direction === 'outgoing')} {this.renderMenu(direction === 'incoming', triggerId)} {this.renderContextMenu(triggerId)}
); } }