From 21bf02c94db3ff9339a76b445fe2a523d5b94e00 Mon Sep 17 00:00:00 2001
From: Scott Nonnenberg <scott@signal.org>
Date: Mon, 9 Apr 2018 18:31:52 -0700
Subject: [PATCH] Fixed examples in Quote.md, rough Android visuals

---
 _locales/en/messages.json            |  12 +++
 background.html                      |   1 +
 images/play.svg                      |   1 +
 js/modules/types/mime.js             |   8 ++
 js/views/attachment_view.js          |   3 +-
 js/views/message_view.js             |  53 ++++++++++-
 package.json                         |   1 +
 stylesheets/_conversation.scss       |  69 +++++++++++++++
 stylesheets/_mixins.scss             |  58 ++++++++++++
 stylesheets/_variables.scss          |   5 ++
 test/index.html                      |   1 +
 test/styleguide/legacy_templates.js  |   1 +
 ts/components/conversation/Quote.md  | 122 ++++++++++++++-----------
 ts/components/conversation/Quote.tsx | 128 ++++++++++++++++++++++++++-
 yarn.lock                            |   4 +
 15 files changed, 408 insertions(+), 59 deletions(-)
 create mode 100644 images/play.svg

diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index de3771b9c..762177d7e 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -428,6 +428,18 @@
     "selectAContact": {
       "message": "Select a contact or group to start chatting."
     },
+    "audio": {
+      "message": "Audio",
+      "description": "Shown in a quotation of a message containing an audio attachment if no text was originally provided with that attachment"
+    },
+    "video": {
+      "message": "Video",
+      "description": "Shown in a quotation of a message containing a video if no text was originally provided with that video"
+    },
+    "photo": {
+      "message": "Photo",
+      "description": "Shown in a quotation of a message containing a photo if no text was originally provided with that image"
+    },
     "ok": {
       "message": "OK"
     },
diff --git a/background.html b/background.html
index 58e1b1946..83d5740a7 100644
--- a/background.html
+++ b/background.html
@@ -278,6 +278,7 @@
             {{ /profileName }}
           </div>
           <div class='inner-bubble {{ innerBubbleClasses }}'>
+            <div class='quote-wrapper'></div>
             <div class='attachments'></div>
             <p class='content' dir='auto'>
               {{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
diff --git a/images/play.svg b/images/play.svg
new file mode 100644
index 000000000..87a70f2d1
--- /dev/null
+++ b/images/play.svg
@@ -0,0 +1 @@
+<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="24" height="24" viewBox="0 0 24 24"><path d="M8,5.14V19.14L19,12.14L8,5.14Z" /></svg>
\ No newline at end of file
diff --git a/js/modules/types/mime.js b/js/modules/types/mime.js
index 82228f9dc..b149aead4 100644
--- a/js/modules/types/mime.js
+++ b/js/modules/types/mime.js
@@ -1,2 +1,10 @@
 exports.isJPEG = mimeType =>
   mimeType === 'image/jpeg';
+
+exports.isVideo = mimeType =>
+  mimeType.startsWith('video/') && mimeType !== 'video/wmv';
+
+exports.isImage = mimeType =>
+  mimeType.startsWith('image/') && mimeType !== 'image/tiff';
+
+exports.isAudio = mimeType => mimeType.startsWith('audio/');
diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js
index 2fbcc7e1c..b7ae7d982 100644
--- a/js/views/attachment_view.js
+++ b/js/views/attachment_view.js
@@ -136,7 +136,8 @@
       return this.model.contentType.startsWith('audio/');
     },
     isVideo() {
-      return this.model.contentType.startsWith('video/');
+      const type = this.model.contentType;
+      return type.startsWith('video/') && type !== 'image/wmv';
     },
     isImage() {
       const type = this.model.contentType;
diff --git a/js/views/message_view.js b/js/views/message_view.js
index a40761854..f2512793f 100644
--- a/js/views/message_view.js
+++ b/js/views/message_view.js
@@ -235,7 +235,6 @@
       // Failsafe: if in the background, animation events don't fire
       setTimeout(this.remove.bind(this), 1000);
     },
-    /* jshint ignore:start */
     onUnload() {
       if (this.avatarView) {
         this.avatarView.remove();
@@ -252,6 +251,9 @@
       if (this.timeStampView) {
         this.timeStampView.remove();
       }
+      if (this.replyView) {
+        this.replyView.remove();
+      }
 
       // NOTE: We have to do this in the background (`then` instead of `await`)
       // as our tests rely on `onUnload` synchronously removing the view from
@@ -265,7 +267,6 @@
 
       this.remove();
     },
-    /* jshint ignore:end */
     onDestroy() {
       if (this.$el.hasClass('expired')) {
         return;
@@ -359,6 +360,53 @@
       this.timerView.setElement(this.$('.timer'));
       this.timerView.update();
     },
+    renderReply() {
+      const VOICE_MESSAGE_FLAG =
+        textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE;
+      function addVoiceMessageFlag(attachment) {
+        return Object.assign({}, attachment, {
+          // eslint-disable-next-line no-bitwise
+          isVoiceMessage: attachment.flags & VOICE_MESSAGE_FLAG,
+        });
+      }
+      function getObjectUrl(attachment) {
+        if (!attachment || attachment.objectUrl) {
+          return attachment;
+        }
+
+        const blob = new Blob([attachment.data], {
+          type: attachment.contentType,
+        });
+        return Object.assign({}, attachment, {
+          objectUrl: URL.createObjectURL(blob),
+        });
+      }
+      function processAttachment(attachment) {
+        return getObjectUrl(addVoiceMessageFlag(attachment));
+      }
+
+      const quote = this.model.get('quote');
+      if (!quote) {
+        return;
+      }
+
+      const props = {
+        authorName: 'someone',
+        authorColor: 'indigo',
+        text: quote.text,
+        attachments: quote.attachments && quote.attachments.map(processAttachment),
+      };
+
+      if (!this.replyView) {
+        this.replyView = new Whisper.ReactWrapperView({
+          el: this.$('.quote-wrapper'),
+          Component: window.Signal.Components.Quote,
+          props,
+        });
+      } else {
+        this.replyView.update(props);
+      }
+    },
     isImageWithoutCaption() {
       const attachments = this.model.get('attachments');
       const body = this.model.get('body');
@@ -406,6 +454,7 @@
       this.renderRead();
       this.renderErrors();
       this.renderExpiring();
+      this.renderReply();
 
 
       // NOTE: We have to do this in the background (`then` instead of `await`)
diff --git a/package.json b/package.json
index 8a5396ece..3d5226c71 100644
--- a/package.json
+++ b/package.json
@@ -94,6 +94,7 @@
   },
   "devDependencies": {
     "@types/chai": "^4.1.2",
+    "@types/classnames": "^2.2.3",
     "@types/lodash": "^4.14.106",
     "@types/mocha": "^5.0.0",
     "@types/qs": "^6.5.1",
diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss
index 89f36953d..db31620b6 100644
--- a/stylesheets/_conversation.scss
+++ b/stylesheets/_conversation.scss
@@ -450,6 +450,75 @@ span.status {
       max-width: calc(100% - 45px - #{$error-icon-size}); // avatar size + padding + error-icon size
     }
 
+    .quote {
+      @include message-replies-colors;
+
+      display: flex;
+      flex-direction: row;
+      align-items: stretch;
+
+      border-radius: 2px;
+      background-color: #eee;
+      position: relative;
+
+      margin-top: $android-bubble-quote-padding - $android-bubble-padding-vertical;
+      margin-right: $android-bubble-quote-padding - $android-bubble-padding-horizontal;
+      margin-left: $android-bubble-quote-padding - $android-bubble-padding-horizontal;
+      margin-bottom: 0.5em;
+
+      // Accent color border:
+      border-left-width: 3;
+      border-left-style: solid;
+
+      .primary {
+        flex-grow: 1;
+        padding-left: 10px;
+        padding-right: 10px;
+        padding-top: 6px;
+        padding-bottom: 6px;
+
+        .author {
+          font-weight: bold;
+          margin-bottom: 0.3em;
+        }
+
+        .text {
+          white-space: pre-wrap;
+        }
+
+        .type-label {
+          font-style: italic;
+          font-size: 12px;
+        }
+
+        .filename-label {
+          font-size: 12px;
+        }
+      }
+
+      .icon-container {
+        flex: initial;
+        min-width: 48px;
+        @include aspect-ratio(1, 1);
+
+        .inner {
+          border: 1px red solid;
+          max-height: 48px;
+          max-width: 48px;
+
+          &.file {
+            @include color-svg('../images/file.svg', $grey_d);
+          }
+          &.microphone {
+            @include color-svg('../images/microphone.svg', $grey_d);
+          }
+          &.play {
+            @include color-svg('../images/play.svg', $grey_d);
+          }
+        }
+      }
+    }
+
     .body {
       margin-top: 0.5em;
       white-space: pre-wrap;
diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss
index 1b81f1958..33d8dfb5f 100644
--- a/stylesheets/_mixins.scss
+++ b/stylesheets/_mixins.scss
@@ -1,3 +1,20 @@
+@mixin aspect-ratio($width, $height) {
+  position: relative;
+  &:before {
+    display: block;
+    content: "";
+    width: 100%;
+    padding-top: ($height / $width) * 100%;
+  }
+  > .inner {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+  }
+}
+
 @mixin color-svg($svg, $color) {
     -webkit-mask: url($svg) no-repeat center;
     -webkit-mask-size: 100%;
@@ -53,6 +70,47 @@
   &.grey        { background-color: #666666               ; }
   &.default     { background-color: $blue                 ; }
 }
+
+// TODO: Deduplicate these! Can SASS functions generate property names?
+@mixin message-replies-colors {
+  &.red         { border-left-color: $material_red         ; }
+  &.pink        { border-left-color: $material_pink        ; }
+  &.purple      { border-left-color: $material_purple      ; }
+  &.deep_purple { border-left-color: $material_deep_purple ; }
+  &.indigo      { border-left-color: $material_indigo      ; }
+  &.blue        { border-left-color: $material_blue        ; }
+  &.light_blue  { border-left-color: $material_light_blue  ; }
+  &.cyan        { border-left-color: $material_cyan        ; }
+  &.teal        { border-left-color: $material_teal        ; }
+  &.green       { border-left-color: $material_green       ; }
+  &.light_green { border-left-color: $material_light_green ; }
+  &.orange      { border-left-color: $material_orange      ; }
+  &.deep_orange { border-left-color: $material_deep_orange ; }
+  &.amber       { border-left-color: $material_amber       ; }
+  &.blue_grey   { border-left-color: $material_blue_grey   ; }
+  &.grey        { border-left-color: #999999               ; }
+  &.default     { border-left-color: $blue                 ; }
+}
+@mixin dark-message-replies-colors {
+  &.red         { border-left-color: $dark_material_red         ; }
+  &.pink        { border-left-color: $dark_material_pink        ; }
+  &.purple      { border-left-color: $dark_material_purple      ; }
+  &.deep_purple { border-left-color: $dark_material_deep_purple ; }
+  &.indigo      { border-left-color: $dark_material_indigo      ; }
+  &.blue        { border-left-color: $dark_material_blue        ; }
+  &.light_blue  { border-left-color: $dark_material_light_blue  ; }
+  &.cyan        { border-left-color: $dark_material_cyan        ; }
+  &.teal        { border-left-color: $dark_material_teal        ; }
+  &.green       { border-left-color: $dark_material_green       ; }
+  &.light_green { border-left-color: $dark_material_light_green ; }
+  &.orange      { border-left-color: $dark_material_orange      ; }
+  &.deep_orange { border-left-color: $dark_material_deep_orange ; }
+  &.amber       { border-left-color: $dark_material_amber       ; }
+  &.blue_grey   { border-left-color: $dark_material_blue_grey   ; }
+  &.grey        { border-left-color: #666666                    ; }
+  &.default     { border-left-color: $blue                      ; }
+}
+
 @mixin invert-text-color {
   color: white;
 
diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss
index 9c9e6420e..70364d890 100644
--- a/stylesheets/_variables.scss
+++ b/stylesheets/_variables.scss
@@ -82,3 +82,8 @@ $dark_material_orange: #F57C00;
 $dark_material_deep_orange: #E64A19;
 $dark_material_amber: #FFA000;
 $dark_material_blue_grey: #455A64;
+
+// Android
+$android-bubble-padding-horizontal: 12px;
+$android-bubble-padding-vertical: 9px;
+$android-bubble-quote-padding: 4px;
diff --git a/test/index.html b/test/index.html
index 0b92f4d65..3a5aef198 100644
--- a/test/index.html
+++ b/test/index.html
@@ -213,6 +213,7 @@
             {{ /profileName }}
           </div>
           <div class='inner-bubble {{ innerBubbleClasses }}'>
+            <div class='quote-wrapper'></div>
             <div class='attachments'></div>
             <p class='content' dir='auto'>
               {{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
diff --git a/test/styleguide/legacy_templates.js b/test/styleguide/legacy_templates.js
index 04fc8df60..4c7f7e14d 100644
--- a/test/styleguide/legacy_templates.js
+++ b/test/styleguide/legacy_templates.js
@@ -33,6 +33,7 @@ window.Whisper.View.Templates = {
           {{ /profileName }}
         </div>
         <div class='inner-bubble {{ innerBubbleClasses }}'>
+          <div class='quote-wrapper'></div>
           <div class='attachments'></div>
           <p class='content' dir='auto'>
             {{ #message }}<div class='body'>{{ message }}</div>{{ /message }}
diff --git a/ts/components/conversation/Quote.md b/ts/components/conversation/Quote.md
index 8a930eb1c..69bc0d7a5 100644
--- a/ts/components/conversation/Quote.md
+++ b/ts/components/conversation/Quote.md
@@ -45,14 +45,16 @@ const outgoing = new Whisper.Message({
     text: 'I am pretty confused about Pi.',
     author: '+12025550100',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'image/gif',
-      fileName: 'pi.gif',
-      thumbnail: {
+    attachments: [
+      {
         contentType: 'image/gif',
-        data: util.gif,
-      }
-    }
+        fileName: 'pi.gif',
+        thumbnail: {
+          contentType: 'image/gif',
+          data: util.gif,
+        },
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -85,14 +87,16 @@ const outgoing = new Whisper.Message({
   quote: {
     author: '+12025550100',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'image/gif',
-      fileName: 'pi.gif',
-      thumbnail: {
+    attachments: [
+      {
         contentType: 'image/gif',
-        data: util.gif,
-      }
-    }
+        fileName: 'pi.gif',
+        thumbnail: {
+          contentType: 'image/gif',
+          data: util.gif,
+        },
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -126,14 +130,16 @@ const outgoing = new Whisper.Message({
     author: '+12025550100',
     text: 'Check out this video I found!',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'video/mp4',
-      fileName: 'freezing_bubble.mp4',
-      thumbnail: {
-        contentType: 'image/gif',
-        data: util.gif,
-      }
-    }
+    attachments: [
+      {
+        contentType: 'video/mp4',
+        fileName: 'freezing_bubble.mp4',
+        thumbnail: {
+          contentType: 'image/gif',
+          data: util.gif,
+        },
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -166,14 +172,16 @@ const outgoing = new Whisper.Message({
   quote: {
     author: '+12025550100',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'video/mp4',
-      fileName: 'freezing_bubble.mp4',
-      thumbnail: {
-        contentType: 'image/gif',
-        data: util.gif,
-      }
-    }
+    attachments: [
+      {
+        contentType: 'video/mp4',
+        fileName: 'freezing_bubble.mp4',
+        thumbnail: {
+          contentType: 'image/gif',
+          data: util.gif,
+        }
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -207,10 +215,12 @@ const outgoing = new Whisper.Message({
     author: '+12025550100',
     text: 'Check out this beautiful song!',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'audio/mp3',
-      fileName: 'agnus_dei.mp4',
-    }
+    attachments: [
+      {
+        contentType: 'audio/mp3',
+        fileName: 'agnus_dei.mp4',
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -243,10 +253,12 @@ const outgoing = new Whisper.Message({
   quote: {
     author: '+12025550100',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'audio/mp3',
-      fileName: 'agnus_dei.mp4',
-    }
+    attachments: [
+      {
+        contentType: 'audio/mp3',
+        fileName: 'agnus_dei.mp4',
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -279,12 +291,14 @@ const outgoing = new Whisper.Message({
   quote: {
     author: '+12025550100',
     id: Date.now() - 1000,
-    attachments: {
-      // proposed as of afternoon of 4/6 in Quoted Replies group
-      flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
-      contentType: 'audio/mp3',
-      fileName: 'agnus_dei.mp4',
-    }
+    attachments: [
+      {
+        // proposed as of afternoon of 4/6 in Quoted Replies group
+        flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE,
+        contentType: 'audio/mp3',
+        fileName: 'agnus_dei.mp4',
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -318,10 +332,12 @@ const outgoing = new Whisper.Message({
     author: '+12025550100',
     text: 'This is my manifesto. Tell me what you think!',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'text/plain',
-      fileName: 'lorum_ipsum.txt',
-    }
+    attachments: [
+      {
+        contentType: 'text/plain',
+        fileName: 'lorum_ipsum.txt',
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
@@ -354,10 +370,12 @@ const outgoing = new Whisper.Message({
   quote: {
     author: '+12025550100',
     id: Date.now() - 1000,
-    attachments: {
-      contentType: 'text/plain',
-      fileName: 'lorum_ipsum.txt',
-    }
+    attachments: [
+      {
+        contentType: 'text/plain',
+        fileName: 'lorum_ipsum.txt',
+      },
+    ],
   },
 });
 const incoming = new Whisper.Message(Object.assign({}, outgoing.attributes, {
diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx
index 58ff7773e..830253f70 100644
--- a/ts/components/conversation/Quote.tsx
+++ b/ts/components/conversation/Quote.tsx
@@ -1,14 +1,134 @@
 import React from 'react';
+import classnames from 'classnames';
 
+// @ts-ignore
+import Mime from '../../../js/modules/types/mime';
 
-interface Props { name: string; }
 
-interface State { count: number; }
+interface Props {
+  i18n: (key: string, values?: Array<string>) => string;
+  authorName: string;
+  authorColor: string;
+  attachments: Array<QuotedAttachment>;
+  text: string;
+}
+
+interface QuotedAttachment {
+  fileName: string;
+  contentType: string;
+  isVoiceMessage: boolean;
+  objectUrl: string;
+  thumbnail: {
+    contentType: string;
+    data: ArrayBuffer;
+  }
+}
+
+function validateQuote(quote: Props): boolean {
+  if (quote.text) {
+    return true;
+  }
+
+  if (quote.attachments && quote.attachments.length > 0) {
+    return true;
+  }
+
+  return false;
+}
+
+function getContentType(attachments: Array<QuotedAttachment>): string | null {
+  if (!attachments || attachments.length === 0) {
+    return null;
+  }
+
+  const first = attachments[0];
+  return first.contentType;
+}
+
+export class Quote extends React.Component<Props, {}> {
+  public renderIcon(first: QuotedAttachment) {
+    const contentType = first.contentType;
+    const objectUrl = first.objectUrl;
+
+    if (Mime.isVideo(contentType)) {
+      // Render play icon on top of thumbnail
+      // We'd have to generate our own thumbnail from a local video??
+      return <div className='inner play'>Video</div>;
+    } else if (Mime.isImage(contentType)) {
+      if (objectUrl) {
+        return <div className='inner'><img src={objectUrl} /></div>;
+      } else {
+        return <div className='inner'>Loading Widget</div>
+      }
+    } else if (Mime.isAudio(contentType)) {
+      // Show microphone inner in circle
+      return <div className='inner microphone'>Audio</div>;
+    } else {
+      // Show file icon
+      return <div className='inner file'>File</div>;
+    }
+  }
+
+  public renderIconContainer() {
+    const { attachments } = this.props;
+
+    if (!attachments || attachments.length === 0) {
+      return null;
+    }
+
+    const first = attachments[0];
+
+    return <div className='icon-container'>
+      {this.renderIcon(first)}
+    </div>
+  }
+
+  public renderText() {
+    const { i18n, text, attachments } = this.props;
+
+    if (text) {
+      return <div className='text'>{text}</div>;
+    }
+
+    if (!attachments || attachments.length === 0) {
+      return null;
+    }
+
+    const contentType = getContentType(attachments);
+    const first = attachments[0];
+    const fileName = first.fileName;
+
+    console.log(contentType);
+
+    if (Mime.isVideo(contentType)) {
+      return <div className='type-label'>{i18n('video')}</div>;
+    } else if (Mime.isImage(contentType)) {
+      return <div className='type-label'>{i18n('photo')}</div>;
+    } else if (Mime.isAudio(contentType) && first.isVoiceMessage) {
+      return <div className='type-label'>{i18n('voiceMessage')}</div>;
+    } else if (Mime.isAudio(contentType)) {
+      console.log(first);
+      return <div className='type-label'>{i18n('audio')}</div>;
+    }
+
+    return <div className='filename-label'>{fileName}</div>;
+  }
 
-export class Reply extends React.Component<Props, State> {
   public render() {
+    const { authorName, authorColor } = this.props;
+
+    if (!validateQuote(this.props)) {
+      return null;
+    }
+
     return (
-      <div>Placeholder</div>
+      <div className={classnames(authorColor, 'quote')} >
+        <div className="primary">
+          <div className="author">{authorName}</div>
+          {this.renderText()}
+        </div>
+        {this.renderIconContainer()}
+      </div>
     );
   }
 }
diff --git a/yarn.lock b/yarn.lock
index 9a9c20ed8..3748ca6bb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -40,6 +40,10 @@
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.1.2.tgz#f1af664769cfb50af805431c407425ed619daa21"
 
+"@types/classnames@^2.2.3":
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.3.tgz#3f0ff6873da793870e20a260cada55982f38a9e5"
+
 "@types/lodash@^4.14.106":
   version "4.14.106"
   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.106.tgz#6093e9a02aa567ddecfe9afadca89e53e5dce4dd"