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;
   }