2017-10-10 22:13:54 +02:00
|
|
|
//
|
2018-01-02 22:55:40 +01:00
|
|
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
2017-10-10 22:13:54 +02:00
|
|
|
//
|
|
|
|
|
|
|
|
#import "ConversationInputToolbar.h"
|
|
|
|
#import "ConversationInputTextView.h"
|
2018-02-07 18:44:09 +01:00
|
|
|
#import "Environment.h"
|
|
|
|
#import "OWSContactsManager.h"
|
2017-10-24 15:41:03 +02:00
|
|
|
#import "OWSMath.h"
|
2017-10-16 17:55:19 +02:00
|
|
|
#import "Signal-Swift.h"
|
2017-10-10 22:13:54 +02:00
|
|
|
#import "UIColor+OWS.h"
|
|
|
|
#import "UIFont+OWS.h"
|
|
|
|
#import "UIView+OWS.h"
|
|
|
|
#import "ViewControllerUtils.h"
|
2017-12-01 23:10:14 +01:00
|
|
|
#import <SignalMessaging/OWSFormat.h>
|
2017-10-10 22:13:54 +02:00
|
|
|
#import <SignalServiceKit/NSTimer+OWS.h>
|
2018-02-07 18:44:09 +01:00
|
|
|
#import <SignalServiceKit/TSQuotedMessage.h>
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
NS_ASSUME_NONNULL_BEGIN
|
|
|
|
|
2017-10-13 17:17:55 +02:00
|
|
|
static void *kConversationInputTextViewObservingContext = &kConversationInputTextViewObservingContext;
|
2018-02-07 18:44:09 +01:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
const CGFloat kMinTextViewHeight = 36;
|
|
|
|
const CGFloat kMaxTextViewHeight = 98;
|
|
|
|
|
2018-02-07 18:44:09 +01:00
|
|
|
#pragma mark -
|
|
|
|
|
2018-07-03 06:35:14 +02:00
|
|
|
@interface ConversationInputToolbar () <ConversationTextViewToolbarDelegate, QuotedReplyPreviewDelegate>
|
2017-10-17 16:07:57 +02:00
|
|
|
|
2018-06-28 19:28:14 +02:00
|
|
|
@property (nonatomic, readonly) ConversationStyle *conversationStyle;
|
|
|
|
|
2017-10-17 16:07:57 +02:00
|
|
|
@property (nonatomic, readonly) ConversationInputTextView *inputTextView;
|
2018-07-03 04:04:15 +02:00
|
|
|
@property (nonatomic, readonly) UIStackView *contentRows;
|
|
|
|
@property (nonatomic, readonly) UIStackView *composeRow;
|
2017-10-17 16:07:57 +02:00
|
|
|
@property (nonatomic, readonly) UIButton *attachmentButton;
|
|
|
|
@property (nonatomic, readonly) UIButton *sendButton;
|
|
|
|
@property (nonatomic, readonly) UIButton *voiceMemoButton;
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-01-02 22:55:40 +01:00
|
|
|
@property (nonatomic) CGFloat textViewHeight;
|
2018-07-03 04:04:15 +02:00
|
|
|
@property (nonatomic, readonly) NSLayoutConstraint *textViewHeightConstraint;
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-02-07 18:44:09 +01:00
|
|
|
#pragma mark -
|
|
|
|
|
2018-07-02 22:35:30 +02:00
|
|
|
@property (nonatomic, nullable) UIView *quotedMessagePreview;
|
2018-02-07 18:44:09 +01:00
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
#pragma mark - Voice Memo Recording UI
|
|
|
|
|
|
|
|
@property (nonatomic, nullable) UIView *voiceMemoUI;
|
2017-10-17 16:07:57 +02:00
|
|
|
@property (nonatomic, nullable) UIView *voiceMemoContentView;
|
2017-10-10 22:13:54 +02:00
|
|
|
@property (nonatomic) NSDate *voiceMemoStartTime;
|
|
|
|
@property (nonatomic, nullable) NSTimer *voiceMemoUpdateTimer;
|
2017-10-17 16:07:57 +02:00
|
|
|
@property (nonatomic, nullable) UILabel *recordingLabel;
|
2017-10-10 22:13:54 +02:00
|
|
|
@property (nonatomic) BOOL isRecordingVoiceMemo;
|
|
|
|
@property (nonatomic) CGPoint voiceMemoGestureStartLocation;
|
|
|
|
|
|
|
|
@end
|
|
|
|
|
2018-02-07 18:44:09 +01:00
|
|
|
#pragma mark -
|
|
|
|
|
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
#pragma mark -
|
|
|
|
|
|
|
|
@implementation ConversationInputToolbar
|
|
|
|
|
2018-06-28 19:28:14 +02:00
|
|
|
- (instancetype)initWithConversationStyle:(ConversationStyle *)conversationStyle
|
2017-10-10 22:13:54 +02:00
|
|
|
{
|
2018-06-30 00:49:35 +02:00
|
|
|
self = [super initWithFrame:CGRectZero];
|
|
|
|
|
2018-06-28 19:28:14 +02:00
|
|
|
_conversationStyle = conversationStyle;
|
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
if (self) {
|
|
|
|
[self createContents];
|
|
|
|
}
|
2018-06-28 19:28:14 +02:00
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
return self;
|
|
|
|
}
|
|
|
|
|
2018-01-02 22:55:40 +01:00
|
|
|
- (CGSize)intrinsicContentSize
|
|
|
|
{
|
2018-07-03 04:04:15 +02:00
|
|
|
// Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify
|
|
|
|
// an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout.
|
|
|
|
return CGSizeZero;
|
2018-01-02 22:55:40 +01:00
|
|
|
}
|
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
- (void)createContents
|
|
|
|
{
|
2017-10-13 17:17:55 +02:00
|
|
|
self.layoutMargins = UIEdgeInsetsZero;
|
|
|
|
|
2018-06-30 01:15:35 +02:00
|
|
|
if (UIAccessibilityIsReduceTransparencyEnabled()) {
|
2018-07-13 15:50:49 +02:00
|
|
|
self.backgroundColor = Theme.toolbarBackgroundColor;
|
2018-06-30 01:15:35 +02:00
|
|
|
} else {
|
2018-07-13 00:44:52 +02:00
|
|
|
CGFloat alpha = OWSNavigationBar.backgroundBlurMutingFactor;
|
2018-07-13 15:50:49 +02:00
|
|
|
self.backgroundColor = [Theme.toolbarBackgroundColor colorWithAlphaComponent:alpha];
|
2018-07-03 02:45:55 +02:00
|
|
|
|
2018-08-10 01:24:51 +02:00
|
|
|
UIVisualEffectView *blurEffectView = [[UIVisualEffectView alloc] initWithEffect:Theme.barBlurEffect];
|
2018-06-30 01:15:35 +02:00
|
|
|
[self addSubview:blurEffectView];
|
|
|
|
[blurEffectView autoPinEdgesToSuperviewEdges];
|
|
|
|
}
|
|
|
|
|
2018-01-02 22:55:40 +01:00
|
|
|
self.autoresizingMask = UIViewAutoresizingFlexibleHeight;
|
2017-11-03 21:17:21 +01:00
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
_inputTextView = [ConversationInputTextView new];
|
2018-07-03 04:04:15 +02:00
|
|
|
self.inputTextView.layer.cornerRadius = kMinTextViewHeight / 2.0f;
|
2017-10-17 16:07:57 +02:00
|
|
|
self.inputTextView.textViewToolbarDelegate = self;
|
2017-10-16 18:29:22 +02:00
|
|
|
self.inputTextView.font = [UIFont ows_dynamicTypeBodyFont];
|
2018-07-03 06:35:14 +02:00
|
|
|
[self.inputTextView setContentHuggingHorizontalLow];
|
2018-07-03 04:04:15 +02:00
|
|
|
|
|
|
|
_textViewHeightConstraint = [self.inputTextView autoSetDimension:ALDimensionHeight toSize:kMinTextViewHeight];
|
2017-10-13 17:17:55 +02:00
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
_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];
|
2018-07-05 21:30:48 +02:00
|
|
|
UIImage *attachmentImage = [UIImage imageNamed:@"ic_circled_plus"];
|
2018-07-03 00:53:24 +02:00
|
|
|
[self.attachmentButton setImage:[attachmentImage imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]
|
|
|
|
forState:UIControlStateNormal];
|
2018-07-13 15:50:49 +02:00
|
|
|
self.attachmentButton.tintColor = Theme.navbarIconColor;
|
2018-07-03 06:28:05 +02:00
|
|
|
[self.attachmentButton autoSetDimensionsToSize:CGSizeMake(40, kMinTextViewHeight)];
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
_sendButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
|
|
|
[self.sendButton
|
|
|
|
setTitle:NSLocalizedString(@"SEND_BUTTON_TITLE", @"Label for the send button in the conversation view.")
|
|
|
|
forState:UIControlStateNormal];
|
2018-07-03 02:17:45 +02:00
|
|
|
[self.sendButton setTitleColor:UIColor.ows_signalBlueColor forState:UIControlStateNormal];
|
2017-10-10 22:13:54 +02:00
|
|
|
self.sendButton.titleLabel.textAlignment = NSTextAlignmentCenter;
|
2018-07-03 02:17:45 +02:00
|
|
|
self.sendButton.titleLabel.font = [UIFont ows_mediumFontWithSize:17.f];
|
2018-07-03 06:28:05 +02:00
|
|
|
self.sendButton.contentEdgeInsets = UIEdgeInsetsMake(0, 4, 0, 4);
|
|
|
|
[self.sendButton autoSetDimension:ALDimensionHeight toSize:kMinTextViewHeight];
|
2017-10-10 22:13:54 +02:00
|
|
|
[self.sendButton addTarget:self action:@selector(sendButtonPressed) forControlEvents:UIControlEventTouchUpInside];
|
|
|
|
|
|
|
|
UIImage *voiceMemoIcon = [UIImage imageNamed:@"voice-memo-button"];
|
|
|
|
OWSAssert(voiceMemoIcon);
|
2017-10-17 16:07:57 +02:00
|
|
|
_voiceMemoButton = [UIButton buttonWithType:UIButtonTypeCustom];
|
2017-10-10 22:13:54 +02:00
|
|
|
[self.voiceMemoButton setImage:[voiceMemoIcon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]
|
|
|
|
forState:UIControlStateNormal];
|
2018-07-13 15:50:49 +02:00
|
|
|
self.voiceMemoButton.imageView.tintColor = Theme.navbarIconColor;
|
2018-07-03 06:28:05 +02:00
|
|
|
[self.voiceMemoButton autoSetDimensionsToSize:CGSizeMake(40, kMinTextViewHeight)];
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2017-10-13 17:17:55 +02:00
|
|
|
// 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.
|
2017-10-10 22:13:54 +02:00
|
|
|
UILongPressGestureRecognizer *longPressGestureRecognizer =
|
|
|
|
[[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
|
|
|
longPressGestureRecognizer.minimumPressDuration = 0;
|
2018-07-03 06:28:05 +02:00
|
|
|
[self.voiceMemoButton addGestureRecognizer:longPressGestureRecognizer];
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
self.userInteractionEnabled = YES;
|
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
_composeRow = [[UIStackView alloc]
|
|
|
|
initWithArrangedSubviews:@[ self.attachmentButton, self.inputTextView, self.voiceMemoButton, self.sendButton ]];
|
2018-07-03 06:28:05 +02:00
|
|
|
self.composeRow.axis = UILayoutConstraintAxisHorizontal;
|
|
|
|
self.composeRow.layoutMarginsRelativeArrangement = YES;
|
|
|
|
self.composeRow.layoutMargins = UIEdgeInsetsMake(6, 6, 6, 6);
|
|
|
|
self.composeRow.alignment = UIStackViewAlignmentBottom;
|
|
|
|
self.composeRow.spacing = 8;
|
2018-07-03 04:04:15 +02:00
|
|
|
|
2018-07-03 06:28:05 +02:00
|
|
|
_contentRows = [[UIStackView alloc] initWithArrangedSubviews:@[ self.composeRow ]];
|
|
|
|
self.contentRows.axis = UILayoutConstraintAxisVertical;
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-07-03 06:28:05 +02:00
|
|
|
[self addSubview:self.contentRows];
|
|
|
|
[self.contentRows autoPinEdgesToSuperviewEdges];
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
[self ensureShouldShowVoiceMemoButtonAnimated:NO];
|
2017-10-10 22:13:54 +02:00
|
|
|
}
|
|
|
|
|
2017-10-16 18:29:22 +02:00
|
|
|
- (void)updateFontSizes
|
|
|
|
{
|
|
|
|
self.inputTextView.font = [UIFont ows_dynamicTypeBodyFont];
|
|
|
|
}
|
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
- (void)setInputTextViewDelegate:(id<ConversationInputTextViewDelegate>)value
|
|
|
|
{
|
|
|
|
OWSAssert(self.inputTextView);
|
|
|
|
OWSAssert(value);
|
|
|
|
|
|
|
|
self.inputTextView.inputTextViewDelegate = value;
|
|
|
|
}
|
|
|
|
|
|
|
|
- (NSString *)messageText
|
|
|
|
{
|
|
|
|
OWSAssert(self.inputTextView);
|
|
|
|
|
|
|
|
return self.inputTextView.trimmedText;
|
|
|
|
}
|
|
|
|
|
2018-07-03 06:42:32 +02:00
|
|
|
- (void)setMessageText:(NSString *_Nullable)value animated:(BOOL)isAnimated
|
2017-10-10 22:13:54 +02:00
|
|
|
{
|
|
|
|
OWSAssert(self.inputTextView);
|
|
|
|
|
|
|
|
self.inputTextView.text = value;
|
|
|
|
|
2018-07-03 06:42:32 +02:00
|
|
|
[self ensureShouldShowVoiceMemoButtonAnimated:isAnimated];
|
|
|
|
[self updateHeightWithTextView:self.inputTextView];
|
2017-10-10 22:13:54 +02:00
|
|
|
}
|
|
|
|
|
2018-07-03 06:42:32 +02:00
|
|
|
- (void)clearTextMessageAnimated:(BOOL)isAnimated
|
2017-10-10 22:13:54 +02:00
|
|
|
{
|
2018-07-03 06:42:32 +02:00
|
|
|
[self setMessageText:nil animated:isAnimated];
|
2017-10-10 22:13:54 +02:00
|
|
|
[self.inputTextView.undoManager removeAllActions];
|
|
|
|
}
|
|
|
|
|
2018-01-08 23:48:26 +01:00
|
|
|
- (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];
|
|
|
|
}
|
|
|
|
|
2018-04-09 15:11:56 +02:00
|
|
|
- (void)setQuotedReply:(nullable OWSQuotedReplyModel *)quotedReply
|
2018-02-07 18:44:09 +01:00
|
|
|
{
|
2018-04-09 15:11:56 +02:00
|
|
|
if (quotedReply == _quotedReply) {
|
2018-04-04 04:56:45 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-04-04 04:31:13 +02:00
|
|
|
if (self.quotedMessagePreview) {
|
2018-04-04 04:56:45 +02:00
|
|
|
[self clearQuotedMessagePreview];
|
2018-04-04 04:31:13 +02:00
|
|
|
}
|
2018-04-04 03:59:19 +02:00
|
|
|
OWSAssert(self.quotedMessagePreview == nil);
|
2018-02-07 18:44:09 +01:00
|
|
|
|
2018-04-09 15:11:56 +02:00
|
|
|
_quotedReply = quotedReply;
|
2018-04-04 04:56:45 +02:00
|
|
|
|
2018-04-09 15:11:56 +02:00
|
|
|
if (!quotedReply) {
|
2018-04-04 04:56:45 +02:00
|
|
|
[self clearQuotedMessagePreview];
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-07-02 22:35:30 +02:00
|
|
|
QuotedReplyPreview *quotedMessagePreview =
|
|
|
|
[[QuotedReplyPreview alloc] initWithQuotedReply:quotedReply conversationStyle:self.conversationStyle];
|
|
|
|
quotedMessagePreview.delegate = self;
|
|
|
|
|
|
|
|
UIView *wrapper = [UIView containerView];
|
|
|
|
wrapper.layoutMargins = UIEdgeInsetsMake(self.quotedMessageTopMargin, 0, 0, 0);
|
|
|
|
[wrapper addSubview:quotedMessagePreview];
|
2018-07-14 00:26:58 +02:00
|
|
|
[quotedMessagePreview ows_autoPinToSuperviewMargins];
|
2018-04-04 03:22:02 +02:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
[self.contentRows insertArrangedSubview:wrapper atIndex:0];
|
|
|
|
|
2018-07-02 22:35:30 +02:00
|
|
|
self.quotedMessagePreview = wrapper;
|
2018-07-02 21:58:28 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (CGFloat)quotedMessageTopMargin
|
|
|
|
{
|
|
|
|
return 5.f;
|
2018-02-07 18:44:09 +01:00
|
|
|
}
|
|
|
|
|
2018-04-04 04:56:45 +02:00
|
|
|
- (void)clearQuotedMessagePreview
|
2018-02-07 18:44:09 +01:00
|
|
|
{
|
2018-04-04 03:59:19 +02:00
|
|
|
if (self.quotedMessagePreview) {
|
2018-07-03 04:04:15 +02:00
|
|
|
[self.contentRows removeArrangedSubview:self.quotedMessagePreview];
|
2018-04-04 03:59:19 +02:00
|
|
|
[self.quotedMessagePreview removeFromSuperview];
|
|
|
|
self.quotedMessagePreview = nil;
|
2018-04-04 03:22:02 +02:00
|
|
|
}
|
2018-02-07 18:44:09 +01:00
|
|
|
}
|
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
- (void)beginEditingTextMessage
|
|
|
|
{
|
|
|
|
[self.inputTextView becomeFirstResponder];
|
|
|
|
}
|
|
|
|
|
|
|
|
- (void)endEditingTextMessage
|
|
|
|
{
|
|
|
|
[self.inputTextView resignFirstResponder];
|
|
|
|
}
|
|
|
|
|
2018-01-19 21:00:41 +01:00
|
|
|
- (BOOL)isInputTextViewFirstResponder
|
|
|
|
{
|
|
|
|
return self.inputTextView.isFirstResponder;
|
|
|
|
}
|
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
- (void)ensureShouldShowVoiceMemoButtonAnimated:(BOOL)isAnimated
|
2017-10-10 22:13:54 +02:00
|
|
|
{
|
2018-07-03 04:04:15 +02:00
|
|
|
void (^updateBlock)(void) = ^{
|
|
|
|
if (self.inputTextView.trimmedText.length > 0) {
|
|
|
|
if (!self.voiceMemoButton.isHidden) {
|
|
|
|
self.voiceMemoButton.hidden = YES;
|
|
|
|
}
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
if (self.sendButton.isHidden) {
|
|
|
|
self.sendButton.hidden = NO;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if (self.voiceMemoButton.isHidden) {
|
|
|
|
self.voiceMemoButton.hidden = NO;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!self.sendButton.isHidden) {
|
|
|
|
self.sendButton.hidden = YES;
|
|
|
|
}
|
|
|
|
}
|
2017-10-23 20:57:01 +02:00
|
|
|
[self layoutIfNeeded];
|
2018-07-03 04:04:15 +02:00
|
|
|
};
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
if (isAnimated) {
|
|
|
|
[UIView animateWithDuration:0.1 animations:updateBlock];
|
|
|
|
} else {
|
|
|
|
updateBlock();
|
|
|
|
}
|
2017-10-10 22:13:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (void)handleLongPress:(UIGestureRecognizer *)sender
|
|
|
|
{
|
|
|
|
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 - Voice Memo
|
|
|
|
|
|
|
|
- (void)showVoiceMemoUI
|
|
|
|
{
|
2017-12-19 17:38:25 +01:00
|
|
|
OWSAssertIsOnMainThread();
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
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];
|
2018-07-14 00:26:58 +02:00
|
|
|
[self.voiceMemoContentView ows_autoPinToSuperviewEdges];
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
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);
|
2018-06-29 23:00:22 +02:00
|
|
|
NSString *arrowHead = (CurrentAppContext().isRTL ? @"\uf105" : @"\uf104");
|
2017-10-10 22:13:54 +02:00
|
|
|
[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];
|
2018-04-02 21:31:32 +02:00
|
|
|
[imageView autoPinLeadingToSuperviewMarginWithInset:10.f];
|
2017-10-10 22:13:54 +02:00
|
|
|
[self.recordingLabel autoVCenterInSuperview];
|
2018-04-02 21:31:32 +02:00
|
|
|
[self.recordingLabel autoPinLeadingToTrailingEdgeOfView:imageView offset:5.f];
|
2017-10-10 22:13:54 +02:00
|
|
|
[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
|
2018-06-29 23:00:22 +02:00
|
|
|
= (CurrentAppContext().isRTL ? -self.voiceMemoUI.bounds.size.width : self.voiceMemoUI.bounds.size.width);
|
2017-10-10 22:13:54 +02:00
|
|
|
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
|
|
|
|
{
|
2017-12-19 17:38:25 +01:00
|
|
|
OWSAssertIsOnMainThread();
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
UIView *oldVoiceMemoUI = self.voiceMemoUI;
|
|
|
|
self.voiceMemoUI = nil;
|
2017-10-17 16:07:57 +02:00
|
|
|
self.voiceMemoContentView = nil;
|
|
|
|
self.recordingLabel = nil;
|
2017-10-10 22:13:54 +02:00
|
|
|
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
|
|
|
|
{
|
2017-12-19 17:38:25 +01:00
|
|
|
OWSAssertIsOnMainThread();
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
// 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
|
|
|
|
{
|
2017-12-19 17:38:25 +01:00
|
|
|
OWSAssertIsOnMainThread();
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
NSTimeInterval durationSeconds = fabs([self.voiceMemoStartTime timeIntervalSinceNow]);
|
2017-12-01 23:10:14 +01:00
|
|
|
self.recordingLabel.text = [OWSFormat formatDurationSeconds:(long)round(durationSeconds)];
|
2017-10-10 22:13:54 +02:00
|
|
|
[self.recordingLabel sizeToFit];
|
|
|
|
}
|
|
|
|
|
|
|
|
- (void)cancelVoiceMemoIfNecessary
|
|
|
|
{
|
|
|
|
if (self.isRecordingVoiceMemo) {
|
|
|
|
self.isRecordingVoiceMemo = NO;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2017-10-13 17:17:55 +02:00
|
|
|
#pragma mark - Event Handlers
|
2017-10-10 22:13:54 +02:00
|
|
|
|
|
|
|
- (void)sendButtonPressed
|
|
|
|
{
|
|
|
|
OWSAssert(self.inputToolbarDelegate);
|
|
|
|
|
2018-01-16 23:55:53 +01:00
|
|
|
[self.inputToolbarDelegate sendButtonPressed];
|
2017-10-10 22:13:54 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
- (void)attachmentButtonPressed
|
|
|
|
{
|
|
|
|
OWSAssert(self.inputToolbarDelegate);
|
|
|
|
|
|
|
|
[self.inputToolbarDelegate attachmentButtonPressed];
|
|
|
|
}
|
|
|
|
|
2017-10-17 16:07:57 +02:00
|
|
|
#pragma mark - ConversationTextViewToolbarDelegate
|
2017-10-10 22:13:54 +02:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
- (void)textViewDidChange:(UITextView *)textView
|
2017-10-10 22:13:54 +02:00
|
|
|
{
|
|
|
|
OWSAssert(self.inputToolbarDelegate);
|
2018-07-03 04:04:15 +02:00
|
|
|
[self ensureShouldShowVoiceMemoButtonAnimated:YES];
|
|
|
|
[self updateHeightWithTextView:textView];
|
2017-10-10 22:13:54 +02:00
|
|
|
}
|
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
- (void)updateHeightWithTextView:(UITextView *)textView
|
2017-10-13 17:17:55 +02:00
|
|
|
{
|
2018-07-03 04:04:15 +02:00
|
|
|
// compute new height assuming width is unchanged
|
|
|
|
CGSize currentSize = textView.frame.size;
|
|
|
|
CGFloat newHeight = [self clampedHeightWithTextView:textView fixedWidth:currentSize.width];
|
2017-10-13 17:17:55 +02:00
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
if (newHeight != self.textViewHeight) {
|
|
|
|
self.textViewHeight = newHeight;
|
|
|
|
OWSAssert(self.textViewHeightConstraint);
|
|
|
|
self.textViewHeightConstraint.constant = newHeight;
|
|
|
|
[self invalidateIntrinsicContentSize];
|
2017-10-13 17:17:55 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2018-07-03 04:04:15 +02:00
|
|
|
- (CGFloat)clampedHeightWithTextView:(UITextView *)textView fixedWidth:(CGFloat)fixedWidth
|
2017-10-13 17:17:55 +02:00
|
|
|
{
|
2018-07-03 04:04:15 +02:00
|
|
|
CGSize fixedWidthSize = CGSizeMake(fixedWidth, CGFLOAT_MAX);
|
|
|
|
CGSize contentSize = [textView sizeThatFits:fixedWidthSize];
|
|
|
|
|
|
|
|
return CGFloatClamp(contentSize.height, kMinTextViewHeight, kMaxTextViewHeight);
|
2017-10-13 17:17:55 +02:00
|
|
|
}
|
|
|
|
|
2018-04-04 03:59:19 +02:00
|
|
|
#pragma mark QuotedReplyPreviewViewDelegate
|
2018-04-04 03:22:02 +02:00
|
|
|
|
2018-04-04 03:59:19 +02:00
|
|
|
- (void)quotedReplyPreviewDidPressCancel:(QuotedReplyPreview *)preview
|
2018-04-04 03:22:02 +02:00
|
|
|
{
|
2018-04-09 15:11:56 +02:00
|
|
|
self.quotedReply = nil;
|
2018-04-04 03:22:02 +02:00
|
|
|
}
|
|
|
|
|
2017-10-10 22:13:54 +02:00
|
|
|
@end
|
|
|
|
|
|
|
|
NS_ASSUME_NONNULL_END
|