From e6128fa5a784eb8de854ff0ab920a6a71ebcab5d Mon Sep 17 00:00:00 2001 From: Warrick <wcor690@aucklanduni.ac.nz> Date: Tue, 11 May 2021 17:02:41 +1000 Subject: [PATCH] Reply attachments (#1591) * First attachment showing in reply composition. * WIP: Adding thumbnail to quote response composition component. * Added icon for voice recording attachment * Updated formatting. * Formatting. * removed duplicate styling. * WIP: Converting quote component to functional components. * Fix bug where thumbnails for attachment replies wasn't showing. * yarn Formatting. * Removed old quote component. * Add type to contentTypeSupported method. * Moved quote subcomponents out of Quote component. * yarn format * Add export to quote subcomponents. * Fixing linting errors. * remove commented line. * Addressing PR comments. --- ts/components/conversation/Quote.tsx | 426 +++++++++--------- .../SessionQuotedMessageComposition.tsx | 43 +- ts/receiver/queuedJob.ts | 4 +- 3 files changed, 245 insertions(+), 228 deletions(-) diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 0193380ec..d5b4ce05e 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -1,6 +1,6 @@ // tslint:disable:react-this-binding-issue -import React from 'react'; +import React, { useState } from 'react'; import classNames from 'classnames'; import * as MIME from '../../../ts/types/MIME'; @@ -12,7 +12,9 @@ import { ContactName } from './ContactName'; import { PubKey } from '../../session/types'; import { ConversationTypeEnum } from '../../models/conversation'; -interface Props { +import { useEncryptedFileFetch } from '../../hooks/useEncryptedFileFetch'; + +interface QuoteProps { attachment?: QuotedAttachmentType; authorPhoneNumber: string; authorProfileName?: string; @@ -25,15 +27,10 @@ interface Props { isPublic?: boolean; withContentAbove: boolean; onClick?: (e: any) => void; - onClose?: () => void; text: string; referencedMessageNotFound: boolean; } -interface State { - imageBroken: boolean; -} - export interface QuotedAttachmentType { contentType: MIME.MIMEType; fileName: string; @@ -48,7 +45,7 @@ interface Attachment { objectUrl?: string; } -function validateQuote(quote: Props): boolean { +function validateQuote(quote: QuoteProps): boolean { if (quote.text) { return true; } @@ -92,30 +89,11 @@ function getTypeLabel({ return; } +export const QuoteIcon = (props: any) => { + const { icon } = props; -export class Quote extends React.Component<Props, State> { - public handleImageErrorBound: () => void; - - public constructor(props: Props) { - super(props); - - this.handleImageErrorBound = this.handleImageError.bind(this); - - this.state = { - imageBroken: false, - }; - } - - public handleImageError() { - // tslint:disable-next-line no-console - console.log('Message: Image failed to load; failing over to placeholder'); - this.setState({ - imageBroken: true, - }); - } - - public renderImage(url: string, i18n: LocalizerType, icon?: string) { - const iconElement = icon ? ( + return ( + <div className="module-quote__icon-container"> <div className="module-quote__icon-container__inner"> <div className="module-quote__icon-container__circle-background"> <div @@ -126,235 +104,236 @@ export class Quote extends React.Component<Props, State> { /> </div> </div> - ) : null; - - return ( - <div className="module-quote__icon-container"> - <img src={url} alt={i18n('quoteThumbnailAlt')} onError={this.handleImageErrorBound} /> - {iconElement} - </div> - ); - } - - public renderIcon(icon: string) { - return ( - <div className="module-quote__icon-container"> - <div className="module-quote__icon-container__inner"> - <div className="module-quote__icon-container__circle-background"> - <div - className={classNames( - 'module-quote__icon-container__icon', - `module-quote__icon-container__icon--${icon}` - )} - /> - </div> - </div> - </div> - ); - } - - public renderGenericFile() { - const { attachment, isIncoming } = this.props; + </div> + ); +}; - if (!attachment) { - return; - } +export const QuoteImage = (props: any) => { + const { url, i18n, icon, contentType, handleImageErrorBound } = props; - const { fileName, contentType } = attachment; - const isGenericFile = - !GoogleChrome.isVideoTypeSupported(contentType) && - !GoogleChrome.isImageTypeSupported(contentType) && - !MIME.isAudio(contentType); + const { loading, urlToLoad } = useEncryptedFileFetch(url, contentType); + const srcData = !loading ? urlToLoad : ''; - if (!isGenericFile) { - return null; - } - - return ( - <div className="module-quote__generic-file"> - <div className="module-quote__generic-file__icon" /> + const iconElement = icon ? ( + <div className="module-quote__icon-container__inner"> + <div className="module-quote__icon-container__circle-background"> <div className={classNames( - 'module-quote__generic-file__text', - isIncoming ? 'module-quote__generic-file__text--incoming' : null + 'module-quote__icon-container__icon', + `module-quote__icon-container__icon--${icon}` )} - > - {fileName} - </div> + /> </div> - ); + </div> + ) : null; + + return ( + <div className="module-quote__icon-container"> + <img src={srcData} alt={i18n('quoteThumbnailAlt')} onError={handleImageErrorBound} /> + {iconElement} + </div> + ); +}; + +export const QuoteGenericFile = (props: any) => { + const { attachment, isIncoming } = props; + + if (!attachment) { + return <></>; } - public renderIconContainer() { - const { attachment, i18n } = this.props; - const { imageBroken } = this.state; - - if (!attachment) { - return null; - } - - const { contentType, thumbnail } = attachment; - const objectUrl = getObjectUrl(thumbnail); - - if (GoogleChrome.isVideoTypeSupported(contentType)) { - return objectUrl && !imageBroken - ? this.renderImage(objectUrl, i18n, 'play') - : this.renderIcon('movie'); - } - if (GoogleChrome.isImageTypeSupported(contentType)) { - return objectUrl && !imageBroken - ? this.renderImage(objectUrl, i18n) - : this.renderIcon('image'); - } - if (MIME.isAudio(contentType)) { - return this.renderIcon('microphone'); - } + const { fileName, contentType } = attachment; + const isGenericFile = + !GoogleChrome.isVideoTypeSupported(contentType) && + !GoogleChrome.isImageTypeSupported(contentType) && + !MIME.isAudio(contentType); - return null; + if (!isGenericFile) { + return <></>; } - public renderText() { - const { i18n, text, attachment, isIncoming, conversationType, convoId } = this.props; - - if (text) { - return ( - <div - dir="auto" - className={classNames( - 'module-quote__primary__text', - isIncoming ? 'module-quote__primary__text--incoming' : null - )} - > - <MessageBody - isGroup={conversationType === 'group'} - convoId={convoId} - text={text} - disableLinks={true} - i18n={i18n} - /> - </div> - ); - } - - if (!attachment) { - return null; - } + return ( + <div className="module-quote__generic-file"> + <div className="module-quote__generic-file__icon" /> + <div + className={classNames( + 'module-quote__generic-file__text', + isIncoming ? 'module-quote__generic-file__text--incoming' : null + )} + > + {fileName} + </div> + </div> + ); +}; - const { contentType, isVoiceMessage } = attachment; - - const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage }); - if (typeLabel) { - return ( - <div - className={classNames( - 'module-quote__primary__type-label', - isIncoming ? 'module-quote__primary__type-label--incoming' : null - )} - > - {typeLabel} - </div> - ); - } +export const QuoteIconContainer = (props: any) => { + const { attachment, i18n, imageBroken, handleImageErrorBound } = props; + if (!attachment) { return null; } - public renderClose() { - const { onClose } = this.props; + const { contentType, thumbnail } = attachment; + const objectUrl = getObjectUrl(thumbnail); - if (!onClose) { - return null; - } + if (GoogleChrome.isVideoTypeSupported(contentType)) { + return objectUrl && !imageBroken ? ( + <QuoteImage url={objectUrl} i18n={i18n} icon={'play'} /> + ) : ( + <QuoteIcon icon="movie" /> + ); + } + if (GoogleChrome.isImageTypeSupported(contentType)) { + return objectUrl && !imageBroken ? ( + <QuoteImage + url={objectUrl} + i18n={i18n} + contentType={contentType} + handleImageErrorBound={handleImageErrorBound} + /> + ) : ( + <QuoteIcon icon="image" /> + ); + } + if (MIME.isAudio(contentType)) { + return <QuoteIcon icon="microphone" />; + } + return null; +}; - // We don't want the overall click handler for the quote to fire, so we stop - // propagation before handing control to the caller's callback. - const onClick = (e: any): void => { - e.stopPropagation(); - onClose(); - }; +export const QuoteText = (props: any) => { + const { i18n, text, attachment, isIncoming, conversationType, convoId } = props; + const isGroup = conversationType === ConversationTypeEnum.GROUP; - // We need the container to give us the flexibility to implement the iOS design. + if (text) { return ( - <div className="module-quote__close-container"> - <div className="module-quote__close-button" role="button" onClick={onClick} /> + <div + dir="auto" + className={classNames( + 'module-quote__primary__text', + isIncoming ? 'module-quote__primary__text--incoming' : null + )} + > + <MessageBody + isGroup={isGroup} + convoId={convoId} + text={text} + disableLinks={true} + i18n={i18n} + /> </div> ); } - public renderAuthor() { - const { - authorProfileName, - authorPhoneNumber, - authorName, - i18n, - isFromMe, - isIncoming, - isPublic, - } = this.props; + if (!attachment) { + return null; + } + + const { contentType, isVoiceMessage } = attachment; + const typeLabel = getTypeLabel({ i18n, contentType, isVoiceMessage }); + if (typeLabel) { return ( <div className={classNames( - 'module-quote__primary__author', - isIncoming ? 'module-quote__primary__author--incoming' : null + 'module-quote__primary__type-label', + isIncoming ? 'module-quote__primary__type-label--incoming' : null )} > - {isFromMe ? ( - i18n('you') - ) : ( - <ContactName - phoneNumber={PubKey.shorten(authorPhoneNumber)} - name={authorName} - profileName={authorProfileName} - i18n={i18n} - compact={true} - shouldShowPubkey={Boolean(isPublic)} - /> - )} + {typeLabel} </div> ); } - public renderReferenceWarning() { - const { i18n, isIncoming, referencedMessageNotFound } = this.props; + return null; +}; + +export const QuoteAuthor = (props: any) => { + const { + authorProfileName, + authorPhoneNumber, + authorName, + i18n, + isFromMe, + isIncoming, + isPublic, + } = props; + + return ( + <div + className={classNames( + 'module-quote__primary__author', + isIncoming ? 'module-quote__primary__author--incoming' : null + )} + > + {isFromMe ? ( + i18n('you') + ) : ( + <ContactName + phoneNumber={PubKey.shorten(authorPhoneNumber)} + name={authorName} + profileName={authorProfileName} + i18n={i18n} + compact={true} + shouldShowPubkey={Boolean(isPublic)} + /> + )} + </div> + ); +}; - if (!referencedMessageNotFound) { - return null; - } +export const QuoteReferenceWarning = (props: any) => { + const { i18n, isIncoming, referencedMessageNotFound } = props; - return ( + if (!referencedMessageNotFound) { + return null; + } + + return ( + <div + className={classNames( + 'module-quote__reference-warning', + isIncoming ? 'module-quote__reference-warning--incoming' : null + )} + > <div className={classNames( - 'module-quote__reference-warning', - isIncoming ? 'module-quote__reference-warning--incoming' : null + 'module-quote__reference-warning__icon', + isIncoming ? 'module-quote__reference-warning__icon--incoming' : null + )} + /> + <div + className={classNames( + 'module-quote__reference-warning__text', + isIncoming ? 'module-quote__reference-warning__text--incoming' : null )} > - <div - className={classNames( - 'module-quote__reference-warning__icon', - isIncoming ? 'module-quote__reference-warning__icon--incoming' : null - )} - /> - <div - className={classNames( - 'module-quote__reference-warning__text', - isIncoming ? 'module-quote__reference-warning__text--incoming' : null - )} - > - {i18n('originalMessageNotFound')} - </div> + {i18n('originalMessageNotFound')} </div> - ); - } + </div> + ); +}; - public render() { - const { isIncoming, onClick, referencedMessageNotFound, withContentAbove } = this.props; +export const Quote = (props: QuoteProps) => { + const [imageBroken, setImageBroken] = useState(false); - if (!validateQuote(this.props)) { - return null; - } + const handleImageErrorBound = null; - return ( + const handleImageError = () => { + // tslint:disable-next-line no-console + console.log('Message: Image failed to load; failing over to placeholder'); + setImageBroken(true); + }; + + const { isIncoming, onClick, referencedMessageNotFound, withContentAbove } = props; + + if (!validateQuote(props)) { + return null; + } + + return ( + <> <div className={classNames( 'module-quote-container', @@ -373,15 +352,14 @@ export class Quote extends React.Component<Props, State> { )} > <div className="module-quote__primary"> - {this.renderAuthor()} - {this.renderGenericFile()} - {this.renderText()} + <QuoteAuthor {...props} /> + <QuoteGenericFile {...props} /> + <QuoteText {...props} /> </div> - {this.renderIconContainer()} - {this.renderClose()} + <QuoteIconContainer {...props} handleImageErrorBound={handleImageErrorBound} /> </div> - {this.renderReferenceWarning()} + <QuoteReferenceWarning {...props} /> </div> - ); - } -} + </> + ); +}; diff --git a/ts/components/session/conversation/SessionQuotedMessageComposition.tsx b/ts/components/session/conversation/SessionQuotedMessageComposition.tsx index 8a7a2a240..140c37d2d 100644 --- a/ts/components/session/conversation/SessionQuotedMessageComposition.tsx +++ b/ts/components/session/conversation/SessionQuotedMessageComposition.tsx @@ -1,8 +1,10 @@ import React, { useContext } from 'react'; import { Flex } from '../../basic/Flex'; -import { SessionIconButton, SessionIconSize, SessionIconType } from '../icon'; +import { SessionIcon, SessionIconButton, SessionIconSize, SessionIconType } from '../icon'; import { ReplyingToMessageProps } from './SessionCompositionBox'; import styled, { DefaultTheme, ThemeContext } from 'styled-components'; +import { getAlt, isAudio, isImageAttachment } from '../../../types/Attachment'; +import { Image } from '../../conversation/Image'; // tslint:disable: react-unused-props-and-state interface Props { @@ -44,6 +46,18 @@ export const SessionQuotedMessageComposition = (props: Props) => { const { text: body, attachments } = quotedMessageProps; const hasAttachments = attachments && attachments.length > 0; + + let hasImageAttachment = false; + + let firstImageAttachment; + if (attachments && attachments.length > 0) { + firstImageAttachment = attachments[0]; + hasImageAttachment = true; + } + + const hasAudioAttachment = + hasAttachments && attachments && attachments.length > 0 && isAudio(attachments); + return ( <QuotedMessageComposition theme={theme}> <Flex @@ -61,7 +75,32 @@ export const SessionQuotedMessageComposition = (props: Props) => { /> </Flex> <QuotedMessageCompositionReply> - <Subtle>{(hasAttachments && window.i18n('mediaMessage')) || body}</Subtle> + <Flex container={true} justifyContent="space-between" margin={theme.common.margins.xs}> + <Subtle>{(hasAttachments && window.i18n('mediaMessage')) || body}</Subtle> + + {hasImageAttachment && ( + <Image + alt={getAlt(firstImageAttachment, window.i18n)} + i18n={window.i18n} + attachment={firstImageAttachment} + height={100} + width={100} + curveTopLeft={true} + curveTopRight={true} + curveBottomLeft={true} + curveBottomRight={true} + url={firstImageAttachment.thumbnail.objectUrl} + /> + )} + + {hasAudioAttachment && ( + <SessionIcon + iconType={SessionIconType.Microphone} + iconSize={SessionIconSize.Huge} + theme={theme} + /> + )} + </Flex> </QuotedMessageCompositionReply> </QuotedMessageComposition> ); diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index 23ca84dbd..195a1ad0e 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -79,7 +79,7 @@ async function handleGroups( return groupUpdate; } -function contentTypeSupported(type: any): boolean { +function contentTypeSupported(type: string): boolean { const Chrome = window.Signal.Util.GoogleChrome; return Chrome.isImageTypeSupported(type) || Chrome.isVideoTypeSupported(type); } @@ -139,7 +139,7 @@ async function copyFromQuotedMessage( return; } - if (!firstAttachment || !contentTypeSupported(firstAttachment)) { + if (!firstAttachment || !contentTypeSupported(firstAttachment.contentType)) { return; }