// // Copyright (c) 2018 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 #import NS_ASSUME_NONNULL_BEGIN static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext; static const CGFloat ConversationInputToolbarBorderViewHeight = 0.5; @interface ConversationInputToolbar () @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 *contentContraints; @property (nonatomic) NSValue *lastTextContentSize; @property (nonatomic) CGFloat toolbarHeight; @property (nonatomic) CGFloat textViewHeight; #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]; } - (CGSize)intrinsicContentSize { CGSize newSize = CGSizeMake(self.bounds.size.width, self.toolbarHeight + ConversationInputToolbarBorderViewHeight); return newSize; } - (void)createContents { self.layoutMargins = UIEdgeInsetsZero; self.backgroundColor = [UIColor ows_inputToolbarBackgroundColor]; self.autoresizingMask = UIViewAutoresizingFlexibleHeight; 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:ConversationInputToolbarBorderViewHeight]; _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.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)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)toggleDefaultKeyboard { // Primary language is nil for the emoji keyboard. if (!self.inputTextView.textInputMode.primaryLanguage) { // Stay on emoji keyboard after sending return; } // Otherwise, we want to toggle back to default keyboard if the user had the numeric keyboard present. // Momentarily switch to a non-default keyboard, else reloadInputViews // will not affect the displayed keyboard. In practice this isn't perceptable to the user. // The alternative would be to dismiss-and-pop the keyboard, but that can cause a more pronounced animation. self.inputTextView.keyboardType = UIKeyboardTypeNumbersAndPunctuation; [self.inputTextView reloadInputViews]; self.inputTextView.keyboardType = UIKeyboardTypeDefault; [self.inputTextView reloadInputViews]; } - (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); // Exactly 4 lines of text with default sizing. const CGFloat kMaxTextViewHeight = 98.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; self.textViewHeight = textViewHeight; self.toolbarHeight = textViewHeight + 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 autoPinBottomToSuperviewWithMargin: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 autoPinBottomToSuperviewWithMargin:0], [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 autoPinBottomToSuperviewWithMargin:0], [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 autoPinBottomToSuperviewWithMargin: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 autoPinBottomToSuperviewWithMargin:0], [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 { OWSAssertIsOnMainThread(); 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 { OWSAssertIsOnMainThread(); 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 { OWSAssertIsOnMainThread(); // 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 { OWSAssertIsOnMainThread(); 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 *)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]; [self invalidateIntrinsicContentSize]; } } } } #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]; } @end NS_ASSUME_NONNULL_END