From 45c8695ab4b4e69d0b6558be9ced7d1834d0974a Mon Sep 17 00:00:00 2001
From: Matthew Chen <charlesmchen@gmail.com>
Date: Thu, 4 May 2017 21:21:27 -0400
Subject: [PATCH] Sketch out the voice memo UI.

// FREEBIE
---
 .../voice-memo-button.imageset/Contents.json  |  23 +
 .../voice-memo-button-25.png                  | Bin 0 -> 1496 bytes
 .../voice-memo-button-50.png                  | Bin 0 -> 1766 bytes
 .../voice-memo-button-75.png                  | Bin 0 -> 2124 bytes
 .../ViewControllers/MessagesViewController.m  | 464 +++++++++++++++++-
 .../translations/en.lproj/Localizable.strings |   3 +
 6 files changed, 489 insertions(+), 1 deletion(-)
 create mode 100644 Signal/Images.xcassets/voice-memo-button.imageset/Contents.json
 create mode 100644 Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-25.png
 create mode 100644 Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-50.png
 create mode 100644 Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-75.png

diff --git a/Signal/Images.xcassets/voice-memo-button.imageset/Contents.json b/Signal/Images.xcassets/voice-memo-button.imageset/Contents.json
new file mode 100644
index 000000000..960c724fb
--- /dev/null
+++ b/Signal/Images.xcassets/voice-memo-button.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "filename" : "voice-memo-button-25.png",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "voice-memo-button-50.png",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "universal",
+      "filename" : "voice-memo-button-75.png",
+      "scale" : "3x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}
\ No newline at end of file
diff --git a/Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-25.png b/Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-25.png
new file mode 100644
index 0000000000000000000000000000000000000000..5b7221dc46a1d2359309dbed2eb287c137656841
GIT binary patch
literal 1496
zcmeAS@N?(olHy`uVBq!ia0vp^k|4~%1|*NXY)uAIEa{HEjtmSN`?>!lvI6;>1s;*b
z3=De8Ak0{?)V>U;MkO;Oq9nrC$0|8LS1&OoKPgqOBDVmjnt{Ql!V1XDO)W`OsL0L9
zE4HezRRXK90<uBE`br95B_-LmN)f&R3eNdOsS2igCVB=+c3cVy3N}S4X;wilZcyz&
zo~=?wNlAf~zJ7Umxn8-kUVc%!zM-Y1rM`iYzLAkGP=#)BWnM{Qg>GK4GQ<#=IWDQi
z$wiq3C7Jno3LtY6lk!VTY?YKi7Qq3;oh6xR2%GYXq22;|P#+|tZ>VRWk4;-@MJ5hy
zAQ_z6Qj+1mDkv?=0sAQ>SwA%=H8(Y{q!_5r5UX{-u!U<xab!he0mL$JAVU0R19G`l
zP-=00PAMn|Lh~|9?2K%{9zqvJga|~JbAE0?QEG89P@A18R12~gy7~x&HFm}Z22dTy
zqUbvOi!y;;O$0g2&Jd~|Sqxo$Bvu=cMUixX0^ceivm!Mo!X*<Jq+ov-0X>3M0$rnX
zeolT-a6w{nD#&GEEl5J>s=?Mo;<5%w0!d>^vQ=?uQdVkm2~vCkV-1}8of6aak%VpZ
zv8uKKrUfhC{FKbJN|(fvR68RBLvt$wQ!4`#1qj2$#6}-Y4U!8$GR{S*i6!|(A^G_^
zc3@xRg18FCdS(!v$nsFFHu|7^gp{u!nHDSx%(Zr0K%KA>z>e#quJL6C21YMW7sn6}
z-nUZ>y__9IT-CLd4ju~HAjs`_f-6VYg<}WXLABQc(b0}9*Au2{^|o3I9AsUwl!db^
z!zTQ`$^7SMg7?Mh#iTvkYyI8t+5h=d(ta$LzH#LHsTGy4Up)N5u-xrzXtrqjj;9Sa
ztHch)cuc+2w)k^apU%>Z4!`>?;n`XHu3Z%He}46!$fnclRA0=EGX1(lb5YqW<+Ak$
z%8$E(d19YV<-HG22?oiC8RhS`?(<%k+N=Ci@2b<g2`8VNEe!Cr&Foq+H)-Ns?hl;b
zr2W*_h<@S7m)aYzdp$tjxpnTLje*5iuS{OnQL|q4qTN|tjmPUa)q9vLxb@e5dn~|t
z@tN!n_W4^GP8V>vM_m!%o!B2YiP?Bh!k-P5r5BugotAx9<74-b=Z$n-y??@=3D;-t
xci0;l>azZ|%<|kBUw>U%|4?zx!n@KxSQ>3izApXoZ91r|^>p=fS?83{1OPYX`gQ;S

literal 0
HcmV?d00001

diff --git a/Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-50.png b/Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-50.png
new file mode 100644
index 0000000000000000000000000000000000000000..c04acfac7680b9107f6f0e47bc79c70094595078
GIT binary patch
literal 1766
zcmeAS@N?(olHy`uVBq!ia0vp^Mj*_=1|;R|J2nC-mUKs7M+SzC{oH>NS%G}c0*}aI
z1_nK45N51cYF`FaqmmgCQ4-<nW0jnrtCyIPpOmUsky`*%&A?z&VFhI7rj{fsROII5
z6<bx<DuGp40ofp7eI*63l9Fs&r3l{u1?T*tR0UH#6FmbZJ1zwU1)HLjG^-#NH>h?X
z&sHg;q@=(~U%$M(T(8_%FTW^V-_X+1Qs2Nx-^fT8s6w~6GOr}DLN~8i8Da>`9GBGM
z<f6=ilFa-(1(3OkN%^HEwn|DMi{Jp_&XUYDgiU$HP;UV}s1K6SH`FuG$EGc{A`^!;
zkPJ?3DamkO6_gg`fc=z`te={bnwy$eQVi5*h}Ak^*upiUII<$K0Ad+95FviE0lC~N
zD7830rxX+fp?R4lc1AW}51|VqLIk4AIX}0cD7Cm4sLjq4ss&jLU3~<?8arbH1E>yU
zQFI;tMVUaaCW0JhX9!h~EQYQ=5~~f!qDVSGfo~O%S&^C(;gSgqQiwl*&cZ5zuF*L^
zC%-7TATc==<T9`pBq4OwU~3|AS%V~jq%kGgs<<>MD>b<UDZYTQ22TA>iRt=C!Z!L?
zRoej5f|YN6N@iN6OJYf?osof|xs`#bm4S%@gkb`VH#9X!E&$0m7o{ea<QIkH=jYgg
zeUS^|Dj4gTL3AR^L$%uIgYpqlzJg?0uqZIs+HnDO!b$);uA@uR7cej|MR>Y6hGek5
zoxa;!)=}c<dj2gOZql8xDorexs#p)!I|Q-oym9hyICAXTsbg0%laj?bA|h@^a4VUK
zajpz`!<5-{BcrFP?j2uwM8vmubMxOjKRa-9=G?#cjIB3EuXa7c1%WD}GeR~i-dnQ7
zUh|tsnCM&ik9N+-Dy|7f1W&oivg!MY64N;CUD}4y72o6}Q_fz<Pt!EqvgBE$u-Qb%
zYdqIFuXwwJxox|!rc_C}RZf~IHAOng()P7W(VY-`8R3=tjy<)BIDf){^LuIv_t_}*
zpcfmo8ZWq{iQL%YSa9P&*zp3j^S9@HSN^(rdBr{3O?*J1(;RarT35C$<k%)W%Ois+
z?U$k8<{&A_F5^jJmo^DFZflw{^~_F-Cu{lsouA#R-_4t|wdaCM+0&m>pZ`7bTD-J2
zGAChm@&l89x_!$9&#d~Z{!F$hPew9@ao3F(FHWAD^g~gvn{WQrLdNG?PgpGI=B@AC
zm7be>|MczFA2wgDR+I%lkv*YyNaFki#up2^dlkO0W~!fJ*{c3eRNS?Rz4(#!M_%(o
z{)O%~PuvcCFE8||(Z1Iudz{<#z^5N-eP)8ZMkf|E=Y=OaU3}BM{|LX5exX>;M;^(P
zi-wIyFMVQuxc>d(9ZH-2{WN(l%b@SN?WXs~?j5TC#C;Ryc5r7!%&j`*|MEfj*5jJ%
zFK?We<-rifwVgNY<q}!DtcCM6Ld6zLd8<Cz!+P0XUG}+O*KJ+*)j)OE<+)D{&%0W9
z%sQ@J=YNzpUuQ+Ot#Zw@4R3!vXTBevpqMGQevWz0QNfK&5V$wCk)8Q)?bGGb(p{jM
N$J5o%Wt~$(69CSDb-Dlm

literal 0
HcmV?d00001

diff --git a/Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-75.png b/Signal/Images.xcassets/voice-memo-button.imageset/voice-memo-button-75.png
new file mode 100644
index 0000000000000000000000000000000000000000..775e470fdbfb39559fd531e8ded4cf624a2b234e
GIT binary patch
literal 2124
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
Iol`;+07zOOQvd(}

literal 0
HcmV?d00001

diff --git a/Signal/src/ViewControllers/MessagesViewController.m b/Signal/src/ViewControllers/MessagesViewController.m
index 0c673705e..53b67f86d 100644
--- a/Signal/src/ViewControllers/MessagesViewController.m
+++ b/Signal/src/ViewControllers/MessagesViewController.m
@@ -12,6 +12,7 @@
 #import "FingerprintViewController.h"
 #import "FullImageViewController.h"
 #import "NSDate+millisecondTimeStamp.h"
+#import "NSTimer+OWS.h"
 #import "NewGroupViewController.h"
 #import "OWSAudioAttachmentPlayer.h"
 #import "OWSCall.h"
@@ -96,20 +97,322 @@ typedef enum : NSUInteger {
 
 - (void)didPasteAttachment:(SignalAttachment * _Nullable)attachment;
 
+- (void)didStartVoiceMemo;
+
+- (void)didEndVoiceMemo;
+
+- (void)didCancelVoiceMemo;
+
 @end
 
 #pragma mark -
 
-@interface OWSMessagesComposerTextView ()
+@interface OWSMessagesComposerTextView () <UITextViewDelegate>
 
 @property (weak, nonatomic) id<OWSTextViewPasteDelegate> textViewPasteDelegate;
 
+@property (nonatomic) BOOL shouldShowVoiceMemoButton;
+
+@property (nonatomic) UIView *voiceMemoButton;
+
+// This view serves as its own delegate but also needs to forward delegate events
+// to JSQ.
+@property (weak, nonatomic) id<UITextViewDelegate> jsqDelegate;
+
+@property (nonatomic) BOOL isRecordingVoiceMemo;
+
 @end
 
 #pragma mark -
 
 @implementation OWSMessagesComposerTextView
 
+- (instancetype)init
+{
+    self = [super init];
+    if (!self) {
+        return self;
+    }
+
+    [self commonInit];
+
+    return self;
+}
+
+- (instancetype)initWithCoder:(NSCoder *)aDecoder
+{
+    self = [super initWithCoder:aDecoder];
+    if (!self) {
+        return self;
+    }
+
+    [self commonInit];
+
+    return self;
+}
+
+- (instancetype)initWithFrame:(CGRect)frame
+{
+    self = [super initWithFrame:frame];
+    if (!self) {
+        return self;
+    }
+
+    [self commonInit];
+
+    return self;
+}
+
+- (void)commonInit
+{
+    self.delegate = self;
+    [self ensureShouldShowVoiceMemoButton];
+}
+
+- (void)setDelegate:(id<UITextViewDelegate>)delegate
+{
+    if (delegate == self) {
+        [super setDelegate:delegate];
+    } else {
+        self.jsqDelegate = delegate;
+    }
+}
+
+#pragma mark - UITextViewDelegate
+
+- (BOOL)textViewShouldBeginEditing:(UITextView *)textView
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textViewShouldBeginEditing:)]) {
+        return [self.jsqDelegate textViewShouldBeginEditing:textView];
+    }
+    return YES;
+}
+
+- (BOOL)textViewShouldEndEditing:(UITextView *)textView
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textViewShouldEndEditing:)]) {
+        return [self.jsqDelegate textViewShouldEndEditing:textView];
+    }
+    return YES;
+}
+
+- (void)textViewDidBeginEditing:(UITextView *)textView
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textViewDidBeginEditing:)]) {
+        [self.jsqDelegate textViewDidBeginEditing:textView];
+    }
+}
+
+- (void)textViewDidEndEditing:(UITextView *)textView
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textViewDidEndEditing:)]) {
+        [self.jsqDelegate textViewDidEndEditing:textView];
+    }
+}
+
+- (BOOL)textView:(UITextView *)textView shouldChangeTextInRange:(NSRange)range replacementText:(NSString *)text
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textView:shouldChangeTextInRange:replacementText:)]) {
+        return [self.jsqDelegate textView:textView shouldChangeTextInRange:range replacementText:text];
+    }
+    return YES;
+}
+
+- (void)textViewDidChange:(UITextView *)textView
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textViewDidChange:)]) {
+        [self.jsqDelegate textViewDidChange:textView];
+    }
+
+    [self ensureShouldShowVoiceMemoButton];
+}
+
+- (void)textViewDidChangeSelection:(UITextView *)textView
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textViewDidChangeSelection:)]) {
+        [self.jsqDelegate textViewDidChangeSelection:textView];
+    }
+}
+
+- (BOOL)textView:(UITextView *)textView
+    shouldInteractWithURL:(NSURL *)URL
+                  inRange:(NSRange)characterRange
+              interaction:(UITextItemInteraction)interaction
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textView:shouldInteractWithURL:inRange:interaction:)]) {
+        return [self.jsqDelegate textView:textView
+                    shouldInteractWithURL:URL
+                                  inRange:characterRange
+                              interaction:interaction];
+    }
+    return YES;
+}
+
+- (BOOL)textView:(UITextView *)textView
+    shouldInteractWithTextAttachment:(NSTextAttachment *)textAttachment
+                             inRange:(NSRange)characterRange
+                         interaction:(UITextItemInteraction)interaction
+{
+    if ([self.jsqDelegate
+            respondsToSelector:@selector(textView:shouldInteractWithTextAttachment:inRange:interaction:)]) {
+        return [self.jsqDelegate textView:textView
+            shouldInteractWithTextAttachment:textAttachment
+                                     inRange:characterRange
+                                 interaction:interaction];
+    }
+    return YES;
+}
+
+- (BOOL)textView:(UITextView *)textView shouldInteractWithURL:(NSURL *)URL inRange:(NSRange)characterRange
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textView:shouldInteractWithURL:inRange:)]) {
+        return [self.jsqDelegate textView:textView shouldInteractWithURL:URL inRange:characterRange];
+    }
+    return YES;
+}
+
+- (BOOL)textView:(UITextView *)textView
+    shouldInteractWithTextAttachment:(NSTextAttachment *)textAttachment
+                             inRange:(NSRange)characterRange
+{
+    if ([self.jsqDelegate respondsToSelector:@selector(textView:shouldInteractWithTextAttachment:inRange:)]) {
+        return
+            [self.jsqDelegate textView:textView shouldInteractWithTextAttachment:textAttachment inRange:characterRange];
+    }
+    return YES;
+}
+
+- (void)ensureShouldShowVoiceMemoButton
+{
+    self.shouldShowVoiceMemoButton = self.text.length < 1;
+}
+
+- (void)setShouldShowVoiceMemoButton:(BOOL)shouldShowVoiceMemoButton
+{
+    if (_shouldShowVoiceMemoButton == shouldShowVoiceMemoButton) {
+        return;
+    }
+
+    _shouldShowVoiceMemoButton = shouldShowVoiceMemoButton;
+
+    [self ensureVoiceMemoButton];
+}
+
+- (CGFloat)voiceMemoButtonSize
+{
+    return 25;
+}
+
+- (void)ensureVoiceMemoButton
+{
+    if (!self.superview) {
+        return;
+    }
+
+    if (self.shouldShowVoiceMemoButton) {
+        [self.voiceMemoButton removeFromSuperview];
+        self.voiceMemoButton = nil;
+
+        UIView *button = [UIView new];
+        button.frame = CGRectMake(0, 0, self.voiceMemoButtonSize, self.voiceMemoButtonSize);
+        [button addGestureRecognizer:[[UILongPressGestureRecognizer alloc] initWithTarget:self
+                                                                                   action:@selector(handleLongPress:)]];
+        button.userInteractionEnabled = YES;
+
+        UIImage *icon = [UIImage imageNamed:@"voice-memo-button"];
+        OWSAssert(icon);
+        UIImageView *imageView = [[UIImageView alloc] initWithImage:icon];
+        imageView.layer.opacity = 0.8f;
+        [button addSubview:imageView];
+
+        self.voiceMemoButton = button;
+        [self addSubview:button];
+        [self layoutVoiceMemoButton];
+    } else {
+        [self.voiceMemoButton removeFromSuperview];
+        self.voiceMemoButton = nil;
+    }
+}
+
+- (void)ensureSubviews
+{
+    [self ensureVoiceMemoButton];
+}
+
+- (void)setFrame:(CGRect)frame
+{
+    [super setFrame:frame];
+
+    [self layoutVoiceMemoButton];
+}
+
+- (void)setBounds:(CGRect)bounds
+{
+    [super setBounds:bounds];
+
+    [self layoutVoiceMemoButton];
+}
+
+- (void)setCenter:(CGPoint)center
+{
+    [super setCenter:center];
+
+    [self layoutVoiceMemoButton];
+}
+
+- (void)layoutVoiceMemoButton
+{
+    if (!self.voiceMemoButton) {
+        return;
+    }
+    CGRect buttonFrame = CGRectMake(floor(self.frame.size.width - (self.voiceMemoButtonSize + 5)),
+        floor((self.frame.size.height - self.voiceMemoButtonSize) * 0.5f),
+        self.voiceMemoButtonSize,
+        self.voiceMemoButtonSize);
+    buttonFrame = [self.voiceMemoButton.superview convertRect:buttonFrame fromView:self];
+    self.voiceMemoButton.frame = buttonFrame;
+    [self.voiceMemoButton.superview bringSubviewToFront:self.voiceMemoButton];
+}
+
+- (void)handleLongPress:(UIGestureRecognizer *)sender
+{
+    switch (sender.state) {
+        case UIGestureRecognizerStatePossible:
+        case UIGestureRecognizerStateCancelled:
+        case UIGestureRecognizerStateFailed:
+            if (self.isRecordingVoiceMemo) {
+                self.isRecordingVoiceMemo = NO;
+                [self.textViewPasteDelegate didCancelVoiceMemo];
+            }
+            break;
+        case UIGestureRecognizerStateBegan:
+            if (self.isRecordingVoiceMemo) {
+                self.isRecordingVoiceMemo = NO;
+                [self.textViewPasteDelegate didCancelVoiceMemo];
+            }
+            self.isRecordingVoiceMemo = YES;
+            [self.textViewPasteDelegate didStartVoiceMemo];
+            break;
+        case UIGestureRecognizerStateChanged:
+            // TODO:
+            break;
+        case UIGestureRecognizerStateEnded:
+            if (self.isRecordingVoiceMemo) {
+                self.isRecordingVoiceMemo = NO;
+                [self.textViewPasteDelegate didEndVoiceMemo];
+            }
+            break;
+    }
+}
+
+- (void)cancelVoiceMemoIfNecessary
+{
+    if (self.isRecordingVoiceMemo) {
+        self.isRecordingVoiceMemo = NO;
+        [self.textViewPasteDelegate didCancelVoiceMemo];
+    }
+}
+
 - (BOOL)canBecomeFirstResponder {
     return YES;
 }
@@ -159,6 +462,20 @@ typedef enum : NSUInteger {
 
 #pragma mark -
 
+@interface OWSMessagesInputToolbar ()
+
+@property (nonatomic) UIView *voiceMemoUI;
+
+@property (nonatomic) NSDate *voiceMemoStartTime;
+
+@property (nonatomic) NSTimer *voiceMemoUpdateTimer;
+
+@property (nonatomic) UILabel *recordingLabel;
+
+@end
+
+#pragma mark -
+
 @implementation OWSMessagesInputToolbar
 
 - (JSQMessagesToolbarContentView *)loadToolbarContentView {
@@ -170,6 +487,126 @@ typedef enum : NSUInteger {
     return view;
 }
 
+- (CGFloat)voiceMemoButtonSize
+{
+    return 25;
+}
+
+- (void)showVoiceMemoUI
+{
+    self.voiceMemoStartTime = [NSDate date];
+
+    [self.voiceMemoUI removeFromSuperview];
+
+    self.voiceMemoUI = [UIView new];
+    self.voiceMemoUI.userInteractionEnabled = NO;
+    self.voiceMemoUI.backgroundColor = [UIColor whiteColor];
+    [self addSubview:self.voiceMemoUI];
+    self.voiceMemoUI.frame = CGRectMake(0, 0, self.bounds.size.width, self.bounds.size.height);
+
+    self.recordingLabel = [UILabel new];
+    self.recordingLabel.textColor = [UIColor ows_materialBlueColor];
+    self.recordingLabel.font = [UIFont ows_mediumFontWithSize:14.f];
+    [self.voiceMemoUI addSubview:self.recordingLabel];
+    [self updateVoiceMemo];
+
+    UIImage *icon = [UIImage imageNamed:@"voice-memo-button"];
+    OWSAssert(icon);
+    UIImageView *imageView =
+        [[UIImageView alloc] initWithImage:[icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]];
+    imageView.tintColor = [UIColor ows_materialBlueColor];
+    //    imageView.layer.opacity = 0.8f;
+    [self.voiceMemoUI addSubview:imageView];
+
+    UILabel *cancelLabel = [UILabel new];
+    cancelLabel.textColor = [UIColor ows_destructiveRedColor];
+    cancelLabel.font = [UIFont ows_mediumFontWithSize:14.f];
+    cancelLabel.text = NSLocalizedString(@"VOICE_MEMO_CANCEL_INSTRUCTIONS", @"Indicates how to cancel a voice memo.");
+    [self.voiceMemoUI addSubview:cancelLabel];
+
+    [imageView autoVCenterInSuperview];
+    [imageView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:10];
+    [self.recordingLabel autoVCenterInSuperview];
+    [self.recordingLabel autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:imageView withOffset:5.f];
+    [cancelLabel autoVCenterInSuperview];
+    [cancelLabel autoHCenterInSuperview];
+    [self.voiceMemoUI setNeedsLayout];
+    [self.voiceMemoUI layoutSubviews];
+
+    // Slide in the "slide to cancel" label.
+    CGRect cancelLabelStartFrame = cancelLabel.frame;
+    CGRect cancelLabelEndFrame = cancelLabel.frame;
+    cancelLabelStartFrame.origin.x = self.voiceMemoUI.bounds.size.width;
+    cancelLabel.frame = cancelLabelStartFrame;
+    [UIView animateWithDuration:0.35f
+                          delay:0.f
+                        options:UIViewAnimationOptionCurveEaseOut
+                     animations:^{
+                         cancelLabel.frame = cancelLabelEndFrame;
+                     }
+                     completion:nil];
+
+    // Pulse the icon.
+    imageView.layer.opacity = 1.f;
+    [UIView animateWithDuration:0.5f
+                          delay:0.2f
+                        options:UIViewAnimationOptionRepeat | UIViewAnimationOptionAutoreverse
+                        | UIViewAnimationOptionCurveEaseIn
+                     animations:^{
+                         imageView.layer.opacity = 0.f;
+                     }
+                     completion:nil];
+
+    // Fade in the view.
+    self.voiceMemoUI.layer.opacity = 0.f;
+    [UIView animateWithDuration:0.2f
+        animations:^{
+            self.voiceMemoUI.layer.opacity = 1.f;
+        }
+        completion:^(BOOL finished) {
+            if (finished) {
+                self.voiceMemoUI.layer.opacity = 1.f;
+            }
+        }];
+
+    self.voiceMemoUpdateTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.1f
+                                                                     target:self
+                                                                   selector:@selector(updateVoiceMemo)
+                                                                   userInfo:nil
+                                                                    repeats:YES];
+}
+
+- (void)hideVoiceMemoUI:(BOOL)animated
+{
+    UIView *voiceMemoUI = self.voiceMemoUI;
+    self.voiceMemoUI = nil;
+    NSTimer *voiceMemoUpdateTimer = self.voiceMemoUpdateTimer;
+    self.voiceMemoUpdateTimer = nil;
+
+    [self.voiceMemoUI.layer removeAllAnimations];
+
+    if (animated) {
+        [UIView animateWithDuration:0.35f
+            animations:^{
+                voiceMemoUI.layer.opacity = 0.f;
+            }
+            completion:^(BOOL finished) {
+                [voiceMemoUI removeFromSuperview];
+                [voiceMemoUpdateTimer invalidate];
+            }];
+    } else {
+        [voiceMemoUI removeFromSuperview];
+        [voiceMemoUpdateTimer invalidate];
+    }
+}
+
+- (void)updateVoiceMemo
+{
+    NSTimeInterval durationSeconds = fabs([self.voiceMemoStartTime timeIntervalSinceNow]);
+    self.recordingLabel.text = [ViewControllerUtils formatDurationSeconds:(long)round(durationSeconds)];
+    [self.recordingLabel sizeToFit];
+}
+
 @end
 
 #pragma mark -
@@ -561,6 +998,8 @@ typedef enum : NSUInteger {
     [self ensureBlockStateIndicator];
 
     [self resetContentAndLayout];
+
+    [((OWSMessagesComposerTextView *)self.inputToolbar.contentView.textView)ensureSubviews];
 }
 
 - (void)resetContentAndLayout
@@ -749,6 +1188,8 @@ typedef enum : NSUInteger {
 
     [self cancelReadTimer];
     [self saveDraft];
+
+    [((OWSMessagesComposerTextView *)self.inputToolbar.contentView.textView)cancelVoiceMemoIfNecessary];
 }
 
 - (void)startExpirationTimerAnimations
@@ -3037,6 +3478,27 @@ typedef enum : NSUInteger {
                      completion:nil];
 }
 
+- (void)didStartVoiceMemo
+{
+    DDLogError(@"didStartVoiceMemo");
+
+    [((OWSMessagesInputToolbar *)self.inputToolbar)showVoiceMemoUI];
+}
+
+- (void)didEndVoiceMemo
+{
+    DDLogError(@"didEndVoiceMemo");
+
+    [((OWSMessagesInputToolbar *)self.inputToolbar) hideVoiceMemoUI:YES];
+}
+
+- (void)didCancelVoiceMemo
+{
+    DDLogError(@"didCancelVoiceMemo");
+
+    [((OWSMessagesInputToolbar *)self.inputToolbar) hideVoiceMemoUI:NO];
+}
+
 #pragma mark - UIScrollViewDelegate
 
 - (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView
diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings
index 40a9df2c0..0a66c1f7e 100644
--- a/Signal/translations/en.lproj/Localizable.strings
+++ b/Signal/translations/en.lproj/Localizable.strings
@@ -1280,6 +1280,9 @@
 /* table cell label in conversation settings */
 "VERIFY_PRIVACY" = "Verify Safety Number";
 
+/* Indicates how to cancel a voice memo. */
+"VOICE_MEMO_CANCEL_INSTRUCTIONS" = "Slide To Cancel";
+
 /* Activity indicator title, shown upon returning to the device manager, until you complete the provisioning process on desktop */
 "WAITING_TO_COMPLETE_DEVICE_LINK_TEXT" = "Complete setup on Signal Desktop.";