import React from 'react'; import classNames from 'classnames'; import { Avatar } from '../Avatar'; import { Spinner } from '../Spinner'; import { MessageBody } from './MessageBody'; import { ExpireTimer } from './ExpireTimer'; import { ImageGrid } from './ImageGrid'; import { Image } from './Image'; import { Timestamp } from './Timestamp'; import { ContactName } from './ContactName'; import { Quote, QuotedAttachmentType } from './Quote'; import { EmbeddedContact } from './EmbeddedContact'; import { canDisplayImage, getExtensionForDisplay, getGridDimensions, getImageDimensions, hasImage, hasVideoScreenshot, isAudio, isImage, isImageAttachment, isVideo, } from '../../../ts/types/Attachment'; import { AttachmentType } from '../../types/Attachment'; import { Contact } from '../../types/Contact'; import { getIncrement } from '../../util/timer'; import { isFileDangerous } from '../../util/isFileDangerous'; import { ColorType, LocalizerType } from '../../types/Util'; import { ContextMenu, ContextMenuTrigger, MenuItem } from 'react-contextmenu'; declare global { interface Window { shortenPubkey: any; contextMenuShown: boolean; } } interface Trigger { handleContextClick: (event: React.MouseEvent) => void; } // Same as MIN_WIDTH in ImageGrid.tsx const MINIMUM_LINK_PREVIEW_IMAGE_WIDTH = 200; interface LinkPreviewType { title: string; domain: string; url: string; image?: AttachmentType; } export interface Props { disableMenu?: boolean; senderIsModerator?: boolean; isDeletable: boolean; isModerator?: boolean; text?: string; textPending?: boolean; 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: LocalizerType; authorName?: string; authorProfileName?: string; /** Note: this should be formatted for display */ authorPhoneNumber: string; authorColor?: ColorType; conversationType: 'group' | 'direct'; attachments?: Array; quote?: { text: string; attachment?: QuotedAttachmentType; isFromMe: boolean; authorPhoneNumber: string; authorProfileName?: string; authorName?: string; authorColor?: ColorType; onClick?: () => void; referencedMessageNotFound: boolean; }; previews: Array; authorAvatarPath?: string; isExpired: boolean; expirationLength?: number; expirationTimestamp?: number; convoId: string; isP2p?: boolean; isPublic?: boolean; isRss?: boolean; selected: boolean; // whether or not to show check boxes multiSelectMode: boolean; onClickAttachment?: (attachment: AttachmentType) => void; onClickLinkPreview?: (url: string) => void; onCopyText?: () => void; onSelectMessage: () => void; onSelectMessageUnchecked: () => void; onReply?: () => void; onRetrySend?: () => void; onDownload?: (isDangerous: boolean) => void; onDelete?: () => void; onCopyPubKey?: () => void; onBanUser?: () => void; onShowDetail: () => void; onShowUserDetails: (userPubKey: string) => void; } interface State { expiring: boolean; expired: boolean; imageBroken: boolean; } const EXPIRATION_CHECK_MINIMUM = 2000; const EXPIRED_DELAY = 600; export class Message extends React.PureComponent { public captureMenuTriggerBound: (trigger: any) => void; public showMenuBound: (event: React.MouseEvent) => void; public handleImageErrorBound: () => void; public menuTriggerRef: Trigger | undefined; 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.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 renderMetadataBadges() { const { direction, isP2p, isPublic, senderIsModerator } = this.props; const badges = [ isPublic && 'Public', isP2p && 'P2p', senderIsModerator && 'Mod', ]; return badges .map(badgeText => { if (typeof badgeText !== 'string') { return null; } return (  • {badgeText} ); }) .filter(i => !!i); } public renderMetadata() { const { collapseMetadata, direction, expirationLength, expirationTimestamp, i18n, status, text, textPending, timestamp, } = this.props; if (collapseMetadata) { return null; } const isShowingImage = this.isShowingImage(); const withImageNoCaption = Boolean(!text && isShowingImage); const showError = status === 'error' && direction === 'outgoing'; return (
{showError ? ( {i18n('sendFailed')} ) : ( )} {this.renderMetadataBadges()} {expirationLength && expirationTimestamp ? ( ) : null} {textPending ? (
) : null} {!textPending && direction === 'outgoing' && status !== 'error' ? (
) : null}
); } // tslint:disable-next-line max-func-body-length cyclomatic-complexity public renderAttachment() { const { attachments, text, collapseMetadata, conversationType, direction, i18n, quote, onClickAttachment, } = 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 && ((isImage(attachments) && hasImage(attachments)) || (isVideo(attachments) && hasVideoScreenshot(attachments))) ) { return (
); } else if (!firstAttachment.pending && isAudio(attachments)) { return ( ); } else { const { pending, fileName, fileSize, contentType } = firstAttachment; const extension = getExtensionForDisplay({ contentType, fileName }); const isDangerous = isFileDangerous(fileName || ''); return (
{pending ? (
) : (
{extension ? (
{extension}
) : null}
{isDangerous ? (
) : null}
)}
{fileName}
{fileSize}
); } } // tslint:disable-next-line cyclomatic-complexity public renderPreview() { const { attachments, conversationType, direction, i18n, onClickLinkPreview, previews, quote, } = 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 = first.image && isImageAttachment(first.image); const width = first.image && first.image.width; const isFullSizeImage = width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH; return (
{ if (onClickLinkPreview) { onClickLinkPreview(first.url); } }} > {first.image && previewHasImage && isFullSizeImage ? ( ) : null}
{first.image && previewHasImage && !isFullSizeImage ? (
{i18n('previewThumbnail',
) : null}
{first.title}
{first.domain}
); } public renderQuote() { const { conversationType, authorColor, direction, i18n, quote, isPublic, convoId, } = this.props; if (!quote) { return null; } const withContentAbove = conversationType === 'group' && direction === 'incoming'; const quoteColor = direction === 'incoming' ? authorColor : quote.authorColor; const shortenedPubkey = window.shortenPubkey(quote.authorPhoneNumber); const displayedPubkey = quote.authorProfileName ? shortenedPubkey : quote.authorPhoneNumber; 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 { authorAvatarPath, authorName, authorPhoneNumber, authorProfileName, collapseMetadata, senderIsModerator, authorColor, conversationType, direction, i18n, onShowUserDetails, } = this.props; if ( collapseMetadata || conversationType !== 'group' || direction === 'outgoing' ) { return; } return (
{ onShowUserDetails(authorPhoneNumber); }} /> {senderIsModerator && (
)}
); } public renderText() { const { text, textPending, i18n, direction, status, isRss, conversationType, convoId, } = 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 { attachments, direction, disableMenu, onDownload, onReply, } = this.props; if (!isCorrectSide || disableMenu) { return null; } const fileName = attachments && attachments[0] ? attachments[0].fileName : null; const isDangerous = isFileDangerous(fileName || ''); const multipleAttachments = attachments && attachments.length > 1; const firstAttachment = attachments && attachments[0]; const downloadButton = !multipleAttachments && firstAttachment && !firstAttachment.pending ? (
{ if (onDownload) { onDownload(isDangerous); } e.stopPropagation(); }} role="button" className={classNames( 'module-message__buttons__download', `module-message__buttons__download--${direction}` )} /> ) : null; const replyButton = (
{ if (onReply) { onReply(); } e.stopPropagation(); }} role="button" className={classNames( 'module-message__buttons__reply', `module-message__buttons__download--${direction}` )} /> ); const menuButton = (
); const first = direction === 'incoming' ? downloadButton : menuButton; const last = direction === 'incoming' ? menuButton : downloadButton; return (
{first} {replyButton} {last}
); } public renderContextMenu(triggerId: string) { const { attachments, onCopyText, onSelectMessageUnchecked, direction, status, isDeletable, onDelete, onDownload, onReply, onRetrySend, onShowDetail, onCopyPubKey, isPublic, i18n, isModerator, onBanUser, } = this.props; const showRetry = status === 'error' && direction === 'outgoing'; const fileName = attachments && attachments[0] ? attachments[0].fileName : null; const isDangerous = isFileDangerous(fileName || ''); const multipleAttachments = attachments && attachments.length > 1; // Wraps a function to prevent event propagation, thus preventing // message selection whenever any of the menu buttons are pressed. const wrap = (f: any) => (event: Event) => { event.stopPropagation(); if (f) { f(); } }; const onContextMenuShown = () => { window.contextMenuShown = true; }; const onContextMenuHidden = () => { // This function will called before the click event // on the message would trigger (and I was unable to // prevent propagation in this case), so use a short timeout setTimeout(() => { window.contextMenuShown = false; }, 100); }; // CONTEXT MENU "Select Message" does not work return ( {!multipleAttachments && attachments && attachments[0] ? ( { e.stopPropagation(); if (onDownload) { onDownload(isDangerous); } }} > {i18n('downloadAttachment')} ) : null} {i18n('copyMessage')} {i18n('selectMessage')} {i18n('replyToMessage')} {i18n('moreInfo')} {showRetry ? ( {i18n('retrySend')} ) : null} {isDeletable ? ( {i18n('deleteMessage')} ) : null} {isPublic ? ( {i18n('copyPublicKey')} ) : null} {isModerator && isPublic ? ( {i18n('banUser')} ) : null} ); } public getWidth(): number | undefined { const { attachments, previews } = this.props; if (attachments && attachments.length) { const dimensions = getGridDimensions(attachments); if (dimensions) { return dimensions.width; } } if (previews && previews.length) { const first = previews[0]; if (!first || !first.image) { return; } const { width } = first.image; if ( isImageAttachment(first.image) && width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH ) { const dimensions = getImageDimensions(first.image); if (dimensions) { return dimensions.width; } } } return; } public isShowingImage() { const { attachments, previews } = this.props; const { imageBroken } = this.state; if (imageBroken) { return false; } if (attachments && attachments.length) { const displayImage = canDisplayImage(attachments); return ( displayImage && ((isImage(attachments) && hasImage(attachments)) || (isVideo(attachments) && hasVideoScreenshot(attachments))) ); } if (previews && previews.length) { const first = previews[0]; const { image } = first; if (!image) { return false; } return isImageAttachment(image); } return false; } public render() { const { authorPhoneNumber, authorColor, direction, id, isRss, timestamp, selected, multiSelectMode, conversationType, } = 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}`); const rightClickTriggerId = `${authorPhoneNumber}-ctx-${timestamp}`; if (expired) { return null; } const width = this.getWidth(); const isShowingImage = this.isShowingImage(); // We parse the message later, but we still need to do an early check // to see if the message mentions us, so we can display the entire // message differently const mentions = this.props.text ? this.props.text.match(window.pubkeyPattern) : []; const mentionMe = mentions && mentions.some(m => m.slice(1) === window.lokiPublicChatAPI.ourKey); const isIncoming = direction === 'incoming'; const shouldHightlight = mentionMe && isIncoming && this.props.isPublic; const divClasses = ['loki-message-wrapper']; if (shouldHightlight) { divClasses.push('message-highlighted'); } if (selected) { divClasses.push('message-selected'); } if (conversationType === 'group') { divClasses.push('public-chat-message-wrapper'); } const enableContextMenu = !isRss && !multiSelectMode; return (
{ const selection = window.getSelection(); if (selection && selection.type === 'Range') { return; } this.props.onSelectMessage(); }} > {this.renderCheckBox()} {this.renderAvatar()}
{this.renderError(isIncoming)} {isRss ? null : this.renderMenu(!isIncoming, triggerId)}
{this.renderAuthor()} {this.renderQuote()} {this.renderAttachment()} {this.renderPreview()} {this.renderEmbeddedContact()} {this.renderText()} {this.renderMetadata()} {this.renderSendMessageButton()}
{this.renderError(!isIncoming)} {isRss || multiSelectMode ? null : this.renderMenu(isIncoming, triggerId)} {enableContextMenu ? this.renderContextMenu(triggerId) : null} {enableContextMenu ? this.renderContextMenu(rightClickTriggerId) : null}
); } private renderCheckBox() { const classes = ['check-box-container']; if (this.props.multiSelectMode) { classes.push('check-box-visible'); } else { classes.push('check-box-invisible'); } if (this.props.selected) { classes.push('check-box-selected'); } return (
); } private renderAuthor() { const { authorName, authorPhoneNumber, authorProfileName, conversationType, direction, i18n, } = this.props; const title = authorName ? authorName : authorPhoneNumber; if (direction !== 'incoming' || conversationType !== 'group' || !title) { return null; } const shortenedPubkey = window.shortenPubkey(authorPhoneNumber); const displayedPubkey = authorProfileName ? shortenedPubkey : authorPhoneNumber; return (
); } }