session-ios/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m
2017-12-05 10:29:28 -05:00

826 lines
33 KiB
Objective-C

//
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
//
#import "ConversationInputToolbar.h"
#import "ConversationInputTextView.h"
#import "OWSMath.h"
#import "Signal-Swift.h"
#import "UIColor+OWS.h"
#import "UIFont+OWS.h"
#import "UIView+OWS.h"
#import "ViewControllerUtils.h"
#import <SignalMessaging/OWSFormat.h>
#import <SignalServiceKit/NSTimer+OWS.h>
NS_ASSUME_NONNULL_BEGIN
static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext;
@interface ConversationInputToolbar () <UIGestureRecognizerDelegate, ConversationTextViewToolbarDelegate>
@property (nonatomic, readonly) UIView *contentView;
@property (nonatomic, readonly) ConversationInputTextView *inputTextView;
@property (nonatomic, readonly) UIButton *attachmentButton;
@property (nonatomic, readonly) UIButton *sendButton;
@property (nonatomic, readonly) UIButton *voiceMemoButton;
@property (nonatomic, readonly) UIView *leftButtonWrapper;
@property (nonatomic, readonly) UIView *rightButtonWrapper;
@property (nonatomic) BOOL shouldShowVoiceMemoButton;
@property (nonatomic) NSArray<NSLayoutConstraint *> *contentContraints;
@property (nonatomic) NSValue *lastTextContentSize;
#pragma mark - Voice Memo Recording UI
@property (nonatomic, nullable) UIView *voiceMemoUI;
@property (nonatomic, nullable) UIView *voiceMemoContentView;
@property (nonatomic) NSDate *voiceMemoStartTime;
@property (nonatomic, nullable) NSTimer *voiceMemoUpdateTimer;
@property (nonatomic, nullable) UILabel *recordingLabel;
@property (nonatomic) BOOL isRecordingVoiceMemo;
@property (nonatomic) CGPoint voiceMemoGestureStartLocation;
#pragma mark - Attachment Approval
@property (nonatomic, nullable) MediaMessageView *attachmentView;
@property (nonatomic, nullable) UIView *cancelAttachmentWrapper;
@property (nonatomic, nullable) SignalAttachment *attachmentToApprove;
@end
#pragma mark -
@implementation ConversationInputToolbar
- (instancetype)init
{
self = [super init];
if (self) {
[self createContents];
}
return self;
}
- (void)dealloc
{
[self removeKVOObservers];
}
- (void)createContents
{
self.layoutMargins = UIEdgeInsetsZero;
self.backgroundColor = [UIColor ows_inputToolbarBackgroundColor];
UIView *borderView = [UIView new];
borderView.backgroundColor = [UIColor colorWithWhite:238 / 255.f alpha:1.f];
[self addSubview:borderView];
[borderView autoPinWidthToSuperview];
[borderView autoPinEdgeToSuperviewEdge:ALEdgeTop];
[borderView autoSetDimension:ALDimensionHeight toSize:0.5f];
_contentView = [UIView containerView];
[self addSubview:self.contentView];
[self.contentView autoPinEdgesToSuperviewEdges];
_inputTextView = [ConversationInputTextView new];
self.inputTextView.textViewToolbarDelegate = self;
self.inputTextView.font = [UIFont ows_dynamicTypeBodyFont];
[self.contentView addSubview:self.inputTextView];
// We want to be permissive about taps on the send and attachment buttons,
// so we use wrapper views that capture nearby taps. This is a lot easier
// than trying to manipulate the size of the buttons themselves, as you
// can't coordinate the layout of the button content (e.g. image or text)
// using iOS auto layout.
_leftButtonWrapper = [UIView containerView];
[self.leftButtonWrapper
addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(leftButtonTapped:)]];
[self.contentView addSubview:self.leftButtonWrapper];
_rightButtonWrapper = [UIView containerView];
[self.rightButtonWrapper
addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rightButtonTapped:)]];
[self.contentView addSubview:self.rightButtonWrapper];
_attachmentButton = [[UIButton alloc] init];
self.attachmentButton.accessibilityLabel
= NSLocalizedString(@"ATTACHMENT_LABEL", @"Accessibility label for attaching photos");
self.attachmentButton.accessibilityHint = NSLocalizedString(
@"ATTACHMENT_HINT", @"Accessibility hint describing what you can do with the attachment button");
[self.attachmentButton addTarget:self
action:@selector(attachmentButtonPressed)
forControlEvents:UIControlEventTouchUpInside];
[self.attachmentButton setImage:[UIImage imageNamed:@"btnAttachments--blue"] forState:UIControlStateNormal];
self.attachmentButton.contentEdgeInsets = UIEdgeInsetsMake(0, 3, 0, 3);
[self.leftButtonWrapper addSubview:self.attachmentButton];
// TODO: Fix layout in this class.
_sendButton = [UIButton buttonWithType:UIButtonTypeCustom];
[self.sendButton
setTitle:NSLocalizedString(@"SEND_BUTTON_TITLE", @"Label for the send button in the conversation view.")
forState:UIControlStateNormal];
[self.sendButton setTitleColor:[UIColor ows_materialBlueColor] forState:UIControlStateNormal];
self.sendButton.titleLabel.font = [UIFont ows_regularFontWithSize:17.0f];
self.sendButton.titleLabel.textAlignment = NSTextAlignmentCenter;
self.sendButton.titleLabel.font = [UIFont ows_mediumFontWithSize:16.f];
[self.sendButton addTarget:self action:@selector(sendButtonPressed) forControlEvents:UIControlEventTouchUpInside];
[self.rightButtonWrapper addSubview:self.sendButton];
UIImage *voiceMemoIcon = [UIImage imageNamed:@"voice-memo-button"];
OWSAssert(voiceMemoIcon);
_voiceMemoButton = [UIButton buttonWithType:UIButtonTypeCustom];
[self.voiceMemoButton setImage:[voiceMemoIcon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]
forState:UIControlStateNormal];
self.voiceMemoButton.imageView.tintColor = [UIColor ows_materialBlueColor];
[self.rightButtonWrapper addSubview:self.voiceMemoButton];
// We want to be permissive about the voice message gesture, so we hang
// the long press GR on the button's wrapper, not the button itself.
UILongPressGestureRecognizer *longPressGestureRecognizer =
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPressGestureRecognizer.minimumPressDuration = 0;
longPressGestureRecognizer.delegate = self;
[self.rightButtonWrapper addGestureRecognizer:longPressGestureRecognizer];
self.userInteractionEnabled = YES;
[self addKVOObservers];
[self ensureShouldShowVoiceMemoButton];
[self ensureContentConstraints];
}
- (void)updateFontSizes
{
self.inputTextView.font = [UIFont ows_dynamicTypeBodyFont];
[self ensureContentConstraints];
}
- (void)setInputTextViewDelegate:(id<ConversationInputTextViewDelegate>)value
{
OWSAssert(self.inputTextView);
OWSAssert(value);
self.inputTextView.inputTextViewDelegate = value;
}
- (NSString *)messageText
{
OWSAssert(self.inputTextView);
return self.inputTextView.trimmedText;
}
- (void)setMessageText:(NSString *_Nullable)value
{
OWSAssert(self.inputTextView);
self.inputTextView.text = value;
[self ensureShouldShowVoiceMemoButton];
}
- (void)clearTextMessage
{
[self setMessageText:nil];
[self.inputTextView.undoManager removeAllActions];
}
- (void)setShouldShowVoiceMemoButton:(BOOL)shouldShowVoiceMemoButton
{
if (_shouldShowVoiceMemoButton == shouldShowVoiceMemoButton) {
return;
}
_shouldShowVoiceMemoButton = shouldShowVoiceMemoButton;
[self ensureContentConstraints];
}
- (void)beginEditingTextMessage
{
[self.inputTextView becomeFirstResponder];
}
- (void)endEditingTextMessage
{
[self.inputTextView resignFirstResponder];
}
- (void)ensureContentConstraints
{
[NSLayoutConstraint deactivateConstraints:self.contentContraints];
const int textViewVInset = 5;
const int contentHInset = 6;
const int contentHSpacing = 6;
// We want to grow the text input area to fit its content within reason.
const CGFloat kMinTextViewHeight = ceil(self.inputTextView.font.lineHeight
+ self.inputTextView.textContainerInset.top + self.inputTextView.textContainerInset.bottom
+ self.inputTextView.contentInset.top + self.inputTextView.contentInset.bottom);
const CGFloat kMaxTextViewHeight = 100.f;
const CGFloat textViewDesiredHeight = (self.inputTextView.contentSize.height + self.inputTextView.contentInset.top
+ self.inputTextView.contentInset.bottom);
const CGFloat textViewHeight = ceil(Clamp(textViewDesiredHeight, kMinTextViewHeight, kMaxTextViewHeight));
const CGFloat kMinContentHeight = kMinTextViewHeight + textViewVInset * 2;
if (self.attachmentToApprove) {
OWSAssert(self.attachmentView);
self.leftButtonWrapper.hidden = YES;
self.inputTextView.hidden = YES;
self.voiceMemoButton.hidden = YES;
UIButton *rightButton = self.sendButton;
rightButton.enabled = YES;
rightButton.hidden = NO;
[rightButton setContentHuggingHigh];
[rightButton setCompressionResistanceHigh];
[self.attachmentView setContentHuggingLow];
OWSAssert(rightButton.superview == self.rightButtonWrapper);
self.contentContraints = @[
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:textViewVInset],
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:textViewVInset],
[self.attachmentView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:contentHInset],
[self.attachmentView autoSetDimension:ALDimensionHeight toSize:150.f],
[self.rightButtonWrapper autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.attachmentView],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[rightButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[rightButton autoPinLeadingToSuperviewWithMargin:contentHSpacing],
[rightButton autoPinTrailingToSuperviewWithMargin:contentHInset],
[rightButton autoPinEdgeToSuperviewEdge:ALEdgeBottom],
];
[self setNeedsLayout];
[self layoutIfNeeded];
// Ensure the keyboard is dismissed.
//
// NOTE: We need to do this _last_ or the layout changes in the input toolbar
// will be inadvertently animated.
[self.inputTextView resignFirstResponder];
return;
}
self.leftButtonWrapper.hidden = NO;
self.inputTextView.hidden = NO;
self.voiceMemoButton.hidden = NO;
[self.attachmentView removeFromSuperview];
self.attachmentView = nil;
[self.cancelAttachmentWrapper removeFromSuperview];
self.cancelAttachmentWrapper = nil;
UIButton *leftButton = self.attachmentButton;
UIButton *rightButton = (self.shouldShowVoiceMemoButton ? self.voiceMemoButton : self.sendButton);
UIButton *inactiveRightButton = (self.shouldShowVoiceMemoButton ? self.sendButton : self.voiceMemoButton);
leftButton.enabled = YES;
rightButton.enabled = YES;
inactiveRightButton.enabled = NO;
leftButton.hidden = NO;
rightButton.hidden = NO;
inactiveRightButton.hidden = YES;
[leftButton setContentHuggingHigh];
[rightButton setContentHuggingHigh];
[leftButton setCompressionResistanceHigh];
[rightButton setCompressionResistanceHigh];
[self.inputTextView setCompressionResistanceLow];
[self.inputTextView setContentHuggingLow];
OWSAssert(leftButton.superview == self.leftButtonWrapper);
OWSAssert(rightButton.superview == self.rightButtonWrapper);
// The leading and trailing buttons should be center-aligned with the
// inputTextView when the inputTextView is at its minimum size.
//
// We want the leading and trailing buttons to hug the bottom of the input
// toolbar as the inputTextView expands.
//
// Therefore we fix the button heights to the size of the toolbar when
// inputTextView is at its minimum size.
//
// Additionally, we use "wrapper" views around the leading and trailing
// buttons to expand their hot area.
self.contentContraints = @[
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeLeft],
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.leftButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[leftButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[leftButton autoPinLeadingToSuperviewWithMargin:contentHInset],
[leftButton autoPinTrailingToSuperviewWithMargin:contentHSpacing],
[leftButton autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[self.inputTextView autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.leftButtonWrapper],
[self.inputTextView autoPinEdgeToSuperviewEdge:ALEdgeTop withInset:textViewVInset],
[self.inputTextView autoPinEdgeToSuperviewEdge:ALEdgeBottom withInset:textViewVInset],
[self.inputTextView autoSetDimension:ALDimensionHeight toSize:textViewHeight],
[self.rightButtonWrapper autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.inputTextView],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeRight],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeTop],
[self.rightButtonWrapper autoPinEdgeToSuperviewEdge:ALEdgeBottom],
[rightButton autoSetDimension:ALDimensionHeight toSize:kMinContentHeight],
[rightButton autoPinLeadingToSuperviewWithMargin:contentHSpacing],
[rightButton autoPinTrailingToSuperviewWithMargin:contentHInset],
[rightButton autoPinEdgeToSuperviewEdge:ALEdgeBottom],
];
// Layout immediately, unless the input toolbar hasn't even been laid out yet.
if (self.bounds.size.width > 0 && self.bounds.size.height > 0) {
[self layoutIfNeeded];
}
}
- (void)ensureShouldShowVoiceMemoButton
{
self.shouldShowVoiceMemoButton = (self.attachmentToApprove == nil && self.inputTextView.trimmedText.length < 1);
}
- (void)handleLongPress:(UIGestureRecognizer *)sender
{
if (!self.shouldShowVoiceMemoButton) {
return;
}
switch (sender.state) {
case UIGestureRecognizerStatePossible:
case UIGestureRecognizerStateCancelled:
case UIGestureRecognizerStateFailed:
if (self.isRecordingVoiceMemo) {
// Cancel voice message if necessary.
self.isRecordingVoiceMemo = NO;
[self.inputToolbarDelegate voiceMemoGestureDidCancel];
}
break;
case UIGestureRecognizerStateBegan:
if (self.isRecordingVoiceMemo) {
// Cancel voice message if necessary.
self.isRecordingVoiceMemo = NO;
[self.inputToolbarDelegate voiceMemoGestureDidCancel];
}
// Start voice message.
self.isRecordingVoiceMemo = YES;
self.voiceMemoGestureStartLocation = [sender locationInView:self];
[self.inputToolbarDelegate voiceMemoGestureDidStart];
break;
case UIGestureRecognizerStateChanged:
if (self.isRecordingVoiceMemo) {
// Check for "slide to cancel" gesture.
CGPoint location = [sender locationInView:self];
// For LTR/RTL, swiping in either direction will cancel.
// This is okay because there's only space on screen to perform the
// gesture in one direction.
CGFloat offset = fabs(self.voiceMemoGestureStartLocation.x - location.x);
// The lower this value, the easier it is to cancel by accident.
// The higher this value, the harder it is to cancel.
const CGFloat kCancelOffsetPoints = 100.f;
CGFloat cancelAlpha = offset / kCancelOffsetPoints;
BOOL isCancelled = cancelAlpha >= 1.f;
if (isCancelled) {
self.isRecordingVoiceMemo = NO;
[self.inputToolbarDelegate voiceMemoGestureDidCancel];
} else {
[self.inputToolbarDelegate voiceMemoGestureDidChange:cancelAlpha];
}
}
break;
case UIGestureRecognizerStateEnded:
if (self.isRecordingVoiceMemo) {
// End voice message.
self.isRecordingVoiceMemo = NO;
[self.inputToolbarDelegate voiceMemoGestureDidEnd];
}
break;
}
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch
{
if ([gestureRecognizer isKindOfClass:[UILongPressGestureRecognizer class]]) {
return self.shouldShowVoiceMemoButton;
} else {
return YES;
}
}
#pragma mark - Voice Memo
- (void)showVoiceMemoUI
{
OWSAssert([NSThread isMainThread]);
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.voiceMemoContentView = [UIView new];
[self.voiceMemoUI addSubview:self.voiceMemoContentView];
[self.voiceMemoContentView autoPinToSuperviewEdges];
self.recordingLabel = [UILabel new];
self.recordingLabel.textColor = [UIColor ows_destructiveRedColor];
self.recordingLabel.font = [UIFont ows_mediumFontWithSize:14.f];
[self.voiceMemoContentView 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_destructiveRedColor];
[self.voiceMemoContentView addSubview:imageView];
NSMutableAttributedString *cancelString = [NSMutableAttributedString new];
const CGFloat cancelArrowFontSize = ScaleFromIPhone5To7Plus(18.4, 20.f);
const CGFloat cancelFontSize = ScaleFromIPhone5To7Plus(14.f, 16.f);
NSString *arrowHead = (self.isRTL ? @"\uf105" : @"\uf104");
[cancelString
appendAttributedString:[[NSAttributedString alloc]
initWithString:arrowHead
attributes:@{
NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize],
NSForegroundColorAttributeName : [UIColor ows_destructiveRedColor],
NSBaselineOffsetAttributeName : @(-1.f),
}]];
[cancelString
appendAttributedString:[[NSAttributedString alloc]
initWithString:@" "
attributes:@{
NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize],
NSForegroundColorAttributeName : [UIColor ows_destructiveRedColor],
NSBaselineOffsetAttributeName : @(-1.f),
}]];
[cancelString
appendAttributedString:[[NSAttributedString alloc]
initWithString:NSLocalizedString(@"VOICE_MESSAGE_CANCEL_INSTRUCTIONS",
@"Indicates how to cancel a voice message.")
attributes:@{
NSFontAttributeName : [UIFont ows_mediumFontWithSize:cancelFontSize],
NSForegroundColorAttributeName : [UIColor ows_destructiveRedColor],
}]];
[cancelString
appendAttributedString:[[NSAttributedString alloc]
initWithString:@" "
attributes:@{
NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize],
NSForegroundColorAttributeName : [UIColor ows_destructiveRedColor],
NSBaselineOffsetAttributeName : @(-1.f),
}]];
[cancelString
appendAttributedString:[[NSAttributedString alloc]
initWithString:arrowHead
attributes:@{
NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize],
NSForegroundColorAttributeName : [UIColor ows_destructiveRedColor],
NSBaselineOffsetAttributeName : @(-1.f),
}]];
UILabel *cancelLabel = [UILabel new];
cancelLabel.attributedText = cancelString;
[self.voiceMemoContentView addSubview:cancelLabel];
const CGFloat kRedCircleSize = 100.f;
UIView *redCircleView = [UIView new];
redCircleView.backgroundColor = [UIColor ows_destructiveRedColor];
redCircleView.layer.cornerRadius = kRedCircleSize * 0.5f;
[redCircleView autoSetDimension:ALDimensionWidth toSize:kRedCircleSize];
[redCircleView autoSetDimension:ALDimensionHeight toSize:kRedCircleSize];
[self.voiceMemoContentView addSubview:redCircleView];
[redCircleView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.voiceMemoButton];
[redCircleView autoAlignAxis:ALAxisVertical toSameAxisOfView:self.voiceMemoButton];
UIImage *whiteIcon = [UIImage imageNamed:@"voice-message-large-white"];
OWSAssert(whiteIcon);
UIImageView *whiteIconView = [[UIImageView alloc] initWithImage:whiteIcon];
[redCircleView addSubview:whiteIconView];
[whiteIconView autoCenterInSuperview];
[imageView autoVCenterInSuperview];
[imageView autoPinLeadingToSuperviewWithMargin:10.f];
[self.recordingLabel autoVCenterInSuperview];
[self.recordingLabel autoPinLeadingToTrailingOfView:imageView margin: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.isRTL ? -self.voiceMemoUI.bounds.size.width : 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 invalidate];
self.voiceMemoUpdateTimer = [NSTimer weakScheduledTimerWithTimeInterval:0.1f
target:self
selector:@selector(updateVoiceMemo)
userInfo:nil
repeats:YES];
}
- (void)hideVoiceMemoUI:(BOOL)animated
{
OWSAssert([NSThread isMainThread]);
UIView *oldVoiceMemoUI = self.voiceMemoUI;
self.voiceMemoUI = nil;
self.voiceMemoContentView = nil;
self.recordingLabel = nil;
NSTimer *voiceMemoUpdateTimer = self.voiceMemoUpdateTimer;
self.voiceMemoUpdateTimer = nil;
[oldVoiceMemoUI.layer removeAllAnimations];
if (animated) {
[UIView animateWithDuration:0.35f
animations:^{
oldVoiceMemoUI.layer.opacity = 0.f;
}
completion:^(BOOL finished) {
[oldVoiceMemoUI removeFromSuperview];
[voiceMemoUpdateTimer invalidate];
}];
} else {
[oldVoiceMemoUI removeFromSuperview];
[voiceMemoUpdateTimer invalidate];
}
}
- (void)setVoiceMemoUICancelAlpha:(CGFloat)cancelAlpha
{
OWSAssert([NSThread isMainThread]);
// Fade out the voice message views as the cancel gesture
// proceeds as feedback.
self.voiceMemoContentView.layer.opacity = MAX(0.f, MIN(1.f, 1.f - (float)cancelAlpha));
}
- (void)updateVoiceMemo
{
OWSAssert([NSThread isMainThread]);
NSTimeInterval durationSeconds = fabs([self.voiceMemoStartTime timeIntervalSinceNow]);
self.recordingLabel.text = [OWSFormat formatDurationSeconds:(long)round(durationSeconds)];
[self.recordingLabel sizeToFit];
}
- (void)cancelVoiceMemoIfNecessary
{
if (self.isRecordingVoiceMemo) {
self.isRecordingVoiceMemo = NO;
}
}
#pragma mark - Event Handlers
- (void)leftButtonTapped:(UIGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateRecognized) {
[self attachmentButtonPressed];
}
}
- (void)rightButtonTapped:(UIGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateRecognized) {
if (!self.shouldShowVoiceMemoButton) {
[self sendButtonPressed];
}
}
}
- (void)sendButtonPressed
{
OWSAssert(self.inputToolbarDelegate);
if (self.attachmentToApprove) {
[self attachmentApprovalSendPressed];
} else {
[self.inputToolbarDelegate sendButtonPressed];
}
}
- (void)attachmentButtonPressed
{
OWSAssert(self.inputToolbarDelegate);
[self.inputToolbarDelegate attachmentButtonPressed];
}
#pragma mark - ConversationTextViewToolbarDelegate
- (void)textViewDidChange
{
OWSAssert(self.inputToolbarDelegate);
[self ensureShouldShowVoiceMemoButton];
}
#pragma mark - Text Input Sizing
- (void)addKVOObservers
{
[self.inputTextView addObserver:self
forKeyPath:NSStringFromSelector(@selector(contentSize))
options:NSKeyValueObservingOptionOld | NSKeyValueObservingOptionNew
context:kConversationInputTextViewObservingContext];
}
- (void)removeKVOObservers
{
@try {
[self.inputTextView removeObserver:self
forKeyPath:NSStringFromSelector(@selector(contentSize))
context:kConversationInputTextViewObservingContext];
} @catch (NSException *__unused exception) {
// TODO: This try/catch can probably be safely removed.
OWSFail(@"%@ removeKVOObservers failed.", self.logTag);
}
}
- (void)observeValueForKeyPath:(nullable NSString *)keyPath
ofObject:(nullable id)object
change:(nullable NSDictionary<NSKeyValueChangeKey, id> *)change
context:(nullable void *)context
{
if (context == kConversationInputTextViewObservingContext) {
if (object == self.inputTextView && [keyPath isEqualToString:NSStringFromSelector(@selector(contentSize))]) {
CGSize textContentSize = self.inputTextView.contentSize;
NSValue *_Nullable lastTextContentSize = self.lastTextContentSize;
self.lastTextContentSize = [NSValue valueWithCGSize:textContentSize];
// Update view constraints, but only when text content size changes.
//
// NOTE: We use a "fuzzy equals" comparison to avoid infinite recursion,
// since ensureContentConstraints can affect the text content size.
if (!lastTextContentSize || fabs(lastTextContentSize.CGSizeValue.width - textContentSize.width) > 0.1f
|| fabs(lastTextContentSize.CGSizeValue.height - textContentSize.height) > 0.1f) {
[self ensureContentConstraints];
}
}
}
}
#pragma mark - Attachment Approval
- (void)showApprovalUIForAttachment:(SignalAttachment *)attachment
{
OWSAssert(attachment);
self.attachmentToApprove = attachment;
MediaMessageView *attachmentView =
[[MediaMessageView alloc] initWithAttachment:attachment mode:MediaMessageViewModeSmall];
self.attachmentView = attachmentView;
[self.contentView addSubview:attachmentView];
UIView *cancelAttachmentWrapper = [UIView containerView];
self.cancelAttachmentWrapper = cancelAttachmentWrapper;
[cancelAttachmentWrapper
addGestureRecognizer:[[UITapGestureRecognizer alloc] initWithTarget:self
action:@selector(cancelAttachmentWrapperTapped:)]];
UIView *_Nullable attachmentContentView = [self.attachmentView contentView];
// Place the cancel button inside the attachment view's content area,
// if possible. If not, just place it inside the attachment view.
UIView *cancelButtonReferenceView = attachmentContentView;
if (attachmentContentView) {
attachmentContentView.layer.borderColor = self.inputTextView.layer.borderColor;
attachmentContentView.layer.borderWidth = self.inputTextView.layer.borderWidth;
attachmentContentView.layer.cornerRadius = self.inputTextView.layer.cornerRadius;
attachmentContentView.clipsToBounds = YES;
} else {
cancelButtonReferenceView = self.attachmentView;
}
[self.contentView addSubview:cancelAttachmentWrapper];
[cancelAttachmentWrapper autoPinEdge:ALEdgeTop toEdge:ALEdgeTop ofView:cancelButtonReferenceView];
[cancelAttachmentWrapper autoPinEdge:ALEdgeRight toEdge:ALEdgeRight ofView:cancelButtonReferenceView];
UIImage *cancelIcon = [UIImage imageNamed:@"cancel-cross-white"];
OWSAssert(cancelIcon);
UIButton *cancelButton = [UIButton buttonWithType:UIButtonTypeCustom];
[cancelButton setImage:cancelIcon forState:UIControlStateNormal];
[cancelButton setBackgroundColor:[UIColor ows_materialBlueColor]];
OWSAssert(cancelIcon.size.width == cancelIcon.size.height);
CGFloat cancelIconSize = MIN(cancelIcon.size.width, cancelIcon.size.height);
CGFloat cancelIconInset = round(cancelIconSize * 0.35f);
[cancelButton
setContentEdgeInsets:UIEdgeInsetsMake(cancelIconInset, cancelIconInset, cancelIconInset, cancelIconInset)];
CGFloat cancelButtonRadius = cancelIconInset + cancelIconSize * 0.5f;
cancelButton.layer.cornerRadius = cancelButtonRadius;
CGFloat cancelButtonInset = 10.f;
[cancelButton addTarget:self
action:@selector(attachmentApprovalCancelPressed)
forControlEvents:UIControlEventTouchUpInside];
[cancelAttachmentWrapper addSubview:cancelButton];
[cancelButton autoPinWidthToSuperviewWithMargin:cancelButtonInset];
[cancelButton autoPinHeightToSuperviewWithMargin:cancelButtonInset];
CGFloat cancelButtonSize = cancelIconSize + 2 * cancelIconInset;
[cancelButton autoSetDimension:ALDimensionWidth toSize:cancelButtonSize];
[cancelButton autoSetDimension:ALDimensionHeight toSize:cancelButtonSize];
[self ensureContentConstraints];
[self ensureShouldShowVoiceMemoButton];
}
- (void)cancelAttachmentWrapperTapped:(UIGestureRecognizer *)sender
{
if (sender.state == UIGestureRecognizerStateRecognized) {
[self attachmentApprovalCancelPressed];
}
}
- (void)attachmentApprovalCancelPressed
{
self.attachmentToApprove = nil;
[self ensureContentConstraints];
[self ensureShouldShowVoiceMemoButton];
}
- (void)attachmentApprovalSendPressed
{
SignalAttachment *attachment = self.attachmentToApprove;
self.attachmentToApprove = nil;
if (attachment) {
[self.inputToolbarDelegate didApproveAttachment:attachment];
}
[self ensureContentConstraints];
[self ensureShouldShowVoiceMemoButton];
}
- (void)viewWillAppear:(BOOL)animated
{
[self.attachmentView viewWillAppear:animated];
}
- (void)viewWillDisappear:(BOOL)animated
{
[self.attachmentView viewWillDisappear:animated];
[self endEditingTextMessage];
}
- (nullable NSString *)textInputPrimaryLanguage
{
return self.inputTextView.textInputMode.primaryLanguage;
}
@end
NS_ASSUME_NONNULL_END