diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index fff97de3e5..4d489b8325 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -785,6 +785,7 @@ 88928A45263A2781009C9B30 /* state-change_confirm-up.caf in Resources */ = {isa = PBXBuildFile; fileRef = 88928A43263A2781009C9B30 /* state-change_confirm-up.caf */; }; 88928A71264099EC009C9B30 /* ConversationViewController+VoiceMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88928A70264099EC009C9B30 /* ConversationViewController+VoiceMessage.swift */; }; 88928A732640DC9B009C9B30 /* VoiceMessageModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88928A722640DC9B009C9B30 /* VoiceMessageModel.swift */; }; + 88928A7526418904009C9B30 /* VoiceMessageTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88928A7426418904009C9B30 /* VoiceMessageTooltip.swift */; }; 889C8A8D248831AC00C3D3D8 /* BlurTooltip.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889C8A8C248831AC00C3D3D8 /* BlurTooltip.swift */; }; 88A357B923639384009D6B9A /* MemberActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A357B823639384009D6B9A /* MemberActionSheet.swift */; }; 88A4CC09246CB0960082211F /* OnboardingTransferChoiceViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A4CC08246CB0960082211F /* OnboardingTransferChoiceViewController.swift */; }; @@ -1928,6 +1929,7 @@ 88928A5A263F0DEC009C9B30 /* hi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hi; path = translations/hi.lproj/InfoPlist.strings; sourceTree = ""; }; 88928A70264099EC009C9B30 /* ConversationViewController+VoiceMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+VoiceMessage.swift"; sourceTree = ""; }; 88928A722640DC9B009C9B30 /* VoiceMessageModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageModel.swift; sourceTree = ""; }; + 88928A7426418904009C9B30 /* VoiceMessageTooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageTooltip.swift; sourceTree = ""; }; 889C8A8C248831AC00C3D3D8 /* BlurTooltip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurTooltip.swift; sourceTree = ""; }; 88A357B823639384009D6B9A /* MemberActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberActionSheet.swift; sourceTree = ""; }; 88A358252363FF63009D6B9A /* mr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = mr; path = translations/mr.lproj/Localizable.strings; sourceTree = ""; }; @@ -3442,6 +3444,7 @@ 34B6A902218B3F62007C4606 /* TypingIndicatorView.swift */, 32AC5CE6255B51E900829BD8 /* JoinGroupCallPill.swift */, 4C043929220A9EC800BAEA63 /* VoiceNoteLock.swift */, + 88928A7426418904009C9B30 /* VoiceMessageTooltip.swift */, ); name = Views; path = views; @@ -5176,6 +5179,7 @@ 45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */, 88ABB8BD25352B8300229EAA /* GroupCallMemberSheet.swift in Sources */, 88A4CC0E246CD6CC0082211F /* OnboardingTransferProgressViewController.swift in Sources */, + 88928A7526418904009C9B30 /* VoiceMessageTooltip.swift in Sources */, 4C0C36F8226647FE0083F19A /* ThreadMapping.swift in Sources */, 34B14D8D24F02A9600CC3A9A /* GroupLinkViewController.swift in Sources */, 34D2CCE0206939B400CB1A14 /* DebugUIMessagesAssetLoader.m in Sources */, diff --git a/Signal/Images.xcassets/mic-outline-64.imageset/Contents.json b/Signal/Images.xcassets/mic-outline-64.imageset/Contents.json deleted file mode 100644 index c13e29483f..0000000000 --- a/Signal/Images.xcassets/mic-outline-64.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "mic-outline-64@1x.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "mic-outline-64@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "mic-outline-64@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@1x.png b/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@1x.png deleted file mode 100644 index eec5037a3c..0000000000 Binary files a/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@1x.png and /dev/null differ diff --git a/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@2x.png b/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@2x.png deleted file mode 100644 index 5a0970dd2d..0000000000 Binary files a/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@2x.png and /dev/null differ diff --git a/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@3x.png b/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@3x.png deleted file mode 100644 index ae66682fef..0000000000 Binary files a/Signal/Images.xcassets/mic-outline-64.imageset/mic-outline-64@3x.png and /dev/null differ diff --git a/Signal/Images.xcassets/mic-solid-36.imageset/Contents.json b/Signal/Images.xcassets/mic-solid-36.imageset/Contents.json new file mode 100644 index 0000000000..18b4a371c8 --- /dev/null +++ b/Signal/Images.xcassets/mic-solid-36.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "mic-solid-36.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/mic-solid-36.imageset/mic-solid-36.pdf b/Signal/Images.xcassets/mic-solid-36.imageset/mic-solid-36.pdf new file mode 100644 index 0000000000..b98c3613f5 --- /dev/null +++ b/Signal/Images.xcassets/mic-solid-36.imageset/mic-solid-36.pdf @@ -0,0 +1,94 @@ +%PDF-1.7 + +1 0 obj + << >> +endobj + +2 0 obj + << /Length 3 0 R >> +stream +/DeviceRGB CS +/DeviceRGB cs +q +1.000000 0.000000 -0.000000 1.000000 6.000000 1.500000 cm +1.000000 1.000000 1.000000 scn +12.000000 10.500000 m +10.209788 10.500000 8.492900 11.211159 7.227030 12.477030 c +5.961159 13.742901 5.250000 15.459789 5.250000 17.250000 c +5.250000 26.250000 l +5.250000 28.040211 5.961159 29.757101 7.227030 31.022970 c +8.492900 32.288841 10.209788 33.000000 12.000000 33.000000 c +12.000000 33.000000 l +13.790212 33.000000 15.507100 32.288841 16.772970 31.022970 c +18.038841 29.757101 18.750000 28.040211 18.750000 26.250000 c +18.750000 17.250000 l +18.750000 15.459789 18.038841 13.742901 16.772970 12.477030 c +15.507100 11.211159 13.790212 10.500000 12.000000 10.500000 c +12.000000 10.500000 l +h +24.000000 17.250000 m +21.750000 17.250000 l +21.750000 14.664139 20.722771 12.184189 18.894291 10.355709 c +17.065811 8.527229 14.585861 7.500000 12.000000 7.500000 c +9.414139 7.500000 6.934188 8.527229 5.105709 10.355709 c +3.277229 12.184189 2.250000 14.664139 2.250000 17.250000 c +0.000000 17.250000 l +0.004423 14.263853 1.120677 11.386356 3.131173 9.178417 c +5.141669 6.970476 7.902320 5.590326 10.875000 5.306999 c +10.875000 0.000000 l +13.125000 0.000000 l +13.125000 5.306999 l +16.097679 5.590326 18.858332 6.970476 20.868828 9.178417 c +22.879324 11.386356 23.995577 14.263853 24.000000 17.250000 c +h +f +n +Q + +endstream +endobj + +3 0 obj + 1326 +endobj + +4 0 obj + << /Annots [] + /Type /Page + /MediaBox [ 0.000000 0.000000 36.000000 36.000000 ] + /Resources 1 0 R + /Contents 2 0 R + /Parent 5 0 R + >> +endobj + +5 0 obj + << /Kids [ 4 0 R ] + /Count 1 + /Type /Pages + >> +endobj + +6 0 obj + << /Type /Catalog + /Pages 5 0 R + >> +endobj + +xref +0 7 +0000000000 65535 f +0000000010 00000 n +0000000034 00000 n +0000001416 00000 n +0000001439 00000 n +0000001612 00000 n +0000001686 00000 n +trailer +<< /ID [ (some) (id) ] + /Root 6 0 R + /Size 7 +>> +startxref +1745 +%%EOF \ No newline at end of file diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h index 74fe90190a..83085ad574 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.h @@ -117,6 +117,10 @@ NS_ASSUME_NONNULL_BEGIN - (void)cancelVoiceMemoIfNecessary; +- (void)showVoiceMemoTooltip; + +- (void)removeVoiceMemoTooltip; + #pragma mark - @property (nonatomic, nullable) OWSQuotedReplyModel *quotedReply; diff --git a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m index a3bb31370f..cee8a20c06 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationInputToolbar.m @@ -19,7 +19,7 @@ NS_ASSUME_NONNULL_BEGIN -typedef NS_CLOSED_ENUM(NSUInteger, VoiceMemoRecordingState){ +typedef NS_CLOSED_ENUM(NSUInteger, VoiceMemoRecordingState) { VoiceMemoRecordingState_Idle, VoiceMemoRecordingState_RecordingHeld, VoiceMemoRecordingState_RecordingLocked @@ -78,9 +78,10 @@ const CGFloat kMaxIPadTextViewHeight = 142; #pragma mark - Voice Memo Recording UI -@property (nonatomic, nullable) UIView *voiceMemoUI; @property (nonatomic, nullable) VoiceMemoLockView *voiceMemoLockView; -@property (nonatomic, nullable) UIView *voiceMemoContentView; +@property (nonatomic, readonly) UIView *voiceMemoContentViewLeftSpacer; +@property (nonatomic, readonly) UIView *voiceMemoContentViewRightSpacer; +@property (nonatomic, readonly) UIView *voiceMemoContentView; @property (nonatomic) NSDate *voiceMemoStartTime; @property (nonatomic, nullable) NSTimer *voiceMemoUpdateTimer; @property (nonatomic) UIGestureRecognizer *voiceMemoGestureRecognizer; @@ -90,6 +91,7 @@ const CGFloat kMaxIPadTextViewHeight = 142; @property (nonatomic, readonly) BOOL isRecordingVoiceMemo; @property (nonatomic) VoiceMemoRecordingState voiceMemoRecordingState; @property (nonatomic) CGPoint voiceMemoGestureStartLocation; +@property (nonatomic, nullable, weak) UIView *voiceMemoTooltip; @property (nonatomic, nullable) NSArray *layoutContraints; @property (nonatomic) UIEdgeInsets receivedSafeAreaInsets; @property (nonatomic, nullable) InputLinkPreview *inputLinkPreview; @@ -248,6 +250,8 @@ const CGFloat kMaxIPadTextViewHeight = 142; _voiceMemoButton = [UIButton buttonWithType:UIButtonTypeCustom]; self.voiceMemoButton.accessibilityLabel = NSLocalizedString(@"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL", @"accessibility label for the button which records voice memos"); + self.voiceMemoButton.accessibilityHint = NSLocalizedString(@"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_HINT", + @"accessibility hint for the button which records voice memos"); UIImage *micIcon = [Theme iconImage:ThemeIconMicButton]; [self.voiceMemoButton setTemplateImage:micIcon tintColor:Theme.primaryIconColor]; [self.voiceMemoButton autoSetDimensionsToSize:CGSizeMake(40, kMinToolbarItemHeight)]; @@ -292,6 +296,23 @@ const CGFloat kMaxIPadTextViewHeight = 142; self.linkPreviewWrapper.backgroundColor = Theme.conversationInputBackgroundColor; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _linkPreviewWrapper); + _voiceMemoContentView = [UIView containerView]; + self.voiceMemoContentView.hidden = YES; + [self.voiceMemoContentView setContentHuggingHorizontalLow]; + [self.voiceMemoContentView setCompressionResistanceHorizontalLow]; + [self.voiceMemoContentView autoSetDimension:ALDimensionHeight toSize:kMinTextViewHeight]; + self.voiceMemoContentView.backgroundColor = Theme.conversationInputBackgroundColor; + SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _voiceMemoContentView); + + _voiceMemoContentViewLeftSpacer = [UIView containerView]; + self.voiceMemoContentViewLeftSpacer.hidden = YES; + [self.voiceMemoContentViewLeftSpacer autoSetDimension:ALDimensionHeight toSize:kMinToolbarItemHeight]; + [self.voiceMemoContentViewLeftSpacer autoSetDimension:ALDimensionWidth toSize:16]; + _voiceMemoContentViewRightSpacer = [UIView containerView]; + self.voiceMemoContentViewRightSpacer.hidden = YES; + [self.voiceMemoContentViewRightSpacer autoSetDimension:ALDimensionHeight toSize:kMinToolbarItemHeight]; + [self.voiceMemoContentViewRightSpacer autoSetDimension:ALDimensionWidth toSize:16]; + // V Stack UIStackView *vStack = [[UIStackView alloc] initWithArrangedSubviews:@[ self.quotedReplyWrapper, self.linkPreviewWrapper, self.inputTextView ]]; @@ -300,6 +321,9 @@ const CGFloat kMaxIPadTextViewHeight = 142; [vStack setContentHuggingHorizontalLow]; [vStack setCompressionResistanceHorizontalLow]; + [vStack addSubview:self.voiceMemoContentView]; + [self.voiceMemoContentView autoPinEdgesToEdgesOfView:self.inputTextView]; + for (UIView *button in @[ self.cameraButton, self.attachmentButton, self.stickerButton, self.voiceMemoButton, self.sendButton ]) { [button setContentHuggingHorizontalHigh]; @@ -326,6 +350,7 @@ const CGFloat kMaxIPadTextViewHeight = 142; // Media Stack UIStackView *mediaAndSendStack = [[UIStackView alloc] initWithArrangedSubviews:@[ + self.voiceMemoContentViewRightSpacer, self.sendButton, self.cameraButton, self.voiceMemoButton, @@ -339,6 +364,7 @@ const CGFloat kMaxIPadTextViewHeight = 142; // H Stack UIStackView *hStack = [[UIStackView alloc] initWithArrangedSubviews:@[ + self.voiceMemoContentViewLeftSpacer, self.attachmentButton, vStackRoundingOffsetView, mediaAndSendStack, @@ -608,18 +634,42 @@ const CGFloat kMaxIPadTextViewHeight = 142; // NOTE: We use untrimmedText, so that the sticker button disappears // even if the user just enters whitespace. BOOL hasTextInput = self.inputTextView.untrimmedText.length > 0; + BOOL isRecordingVoiceMemo = self.isRecordingVoiceMemo; // We used trimmed text for determining all the other button visibility. BOOL hasNonWhitespaceTextInput = self.inputTextView.trimmedText.length > 0; ensureViewHiddenState(self.attachmentButton, NO); - if (hasNonWhitespaceTextInput) { + if (isRecordingVoiceMemo) { + BOOL hideSendButton = self.voiceMemoRecordingState == VoiceMemoRecordingState_RecordingHeld; + ensureViewHiddenState(self.linkPreviewWrapper, YES); + ensureViewHiddenState(self.quotedReplyWrapper, YES); + ensureViewHiddenState(self.voiceMemoContentView, NO); + ensureViewHiddenState(self.voiceMemoContentViewLeftSpacer, NO); + ensureViewHiddenState(self.voiceMemoContentViewRightSpacer, !hideSendButton); + ensureViewHiddenState(self.cameraButton, YES); + ensureViewHiddenState(self.voiceMemoButton, YES); + ensureViewHiddenState(self.sendButton, hideSendButton); + ensureViewHiddenState(self.attachmentButton, YES); + } else if (hasNonWhitespaceTextInput) { + ensureViewHiddenState(self.linkPreviewWrapper, NO); + ensureViewHiddenState(self.quotedReplyWrapper, NO); + ensureViewHiddenState(self.voiceMemoContentView, YES); + ensureViewHiddenState(self.voiceMemoContentViewLeftSpacer, YES); + ensureViewHiddenState(self.voiceMemoContentViewRightSpacer, YES); ensureViewHiddenState(self.cameraButton, YES); ensureViewHiddenState(self.voiceMemoButton, YES); ensureViewHiddenState(self.sendButton, NO); + ensureViewHiddenState(self.attachmentButton, NO); } else { + ensureViewHiddenState(self.linkPreviewWrapper, NO); + ensureViewHiddenState(self.quotedReplyWrapper, NO); + ensureViewHiddenState(self.voiceMemoContentView, YES); + ensureViewHiddenState(self.voiceMemoContentViewLeftSpacer, YES); + ensureViewHiddenState(self.voiceMemoContentViewRightSpacer, YES); ensureViewHiddenState(self.cameraButton, NO); ensureViewHiddenState(self.voiceMemoButton, NO); ensureViewHiddenState(self.sendButton, YES); + ensureViewHiddenState(self.attachmentButton, NO); } // If the layout has changed, update the layout @@ -632,7 +682,7 @@ const CGFloat kMaxIPadTextViewHeight = 142; } void (^updateBlock)(void) = ^{ - BOOL hideStickerButton = hasTextInput || self.quotedReply != nil; + BOOL hideStickerButton = hasTextInput || isRecordingVoiceMemo || self.quotedReply != nil; ensureViewHiddenState(self.stickerButton, hideStickerButton); if (!hideStickerButton) { self.stickerButton.imageView.tintColor @@ -783,6 +833,14 @@ const CGFloat kMaxIPadTextViewHeight = 142; } } +- (void)setVoiceMemoRecordingState:(VoiceMemoRecordingState)voiceMemoRecordingState +{ + if (voiceMemoRecordingState != _voiceMemoRecordingState) { + _voiceMemoRecordingState = voiceMemoRecordingState; + [self ensureButtonVisibilityWithIsAnimated:YES doLayout:YES]; + } +} + #pragma mark - Voice Memo - (BOOL)isRecordingVoiceMemo @@ -800,37 +858,24 @@ const CGFloat kMaxIPadTextViewHeight = 142; { OWSAssertIsOnMainThread(); + [self removeVoiceMemoTooltip]; + self.voiceMemoStartTime = [NSDate date]; - [self.voiceMemoUI removeFromSuperview]; + [self.voiceMemoRedRecordingCircle removeFromSuperview]; [self.voiceMemoLockView removeFromSuperview]; - self.voiceMemoUI = [UIView new]; - self.voiceMemoUI.backgroundColor = Theme.toolbarBackgroundColor; - [self addSubview:self.voiceMemoUI]; - [self.voiceMemoUI autoPinEdgesToSuperviewEdges]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _voiceMemoUI); - - self.voiceMemoContentView = [UIView new]; - [self.voiceMemoUI addSubview:self.voiceMemoContentView]; - [self.voiceMemoContentView autoPinEdgesToSuperviewMargins]; + [self.voiceMemoContentView removeAllSubviews]; self.recordingLabel = [UILabel new]; - self.recordingLabel.textColor = UIColor.ows_accentRedColor; - self.recordingLabel.font = [UIFont ows_semiboldFontWithSize:14.f]; + self.recordingLabel.textColor = Theme.primaryTextColor; + self.recordingLabel.font = UIFont.ows_dynamicTypeBodyClampedFont.ows_medium.ows_monospaced; [self.voiceMemoContentView addSubview:self.recordingLabel]; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _recordingLabel); - VoiceMemoLockView *voiceMemoLockView = [VoiceMemoLockView new]; - self.voiceMemoLockView = voiceMemoLockView; - [self addSubview:voiceMemoLockView]; - [voiceMemoLockView autoPinEdgeToSuperviewMargin:ALEdgeRight]; - [voiceMemoLockView autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:self.voiceMemoContentView]; - [voiceMemoLockView setCompressionResistanceHigh]; - [self updateVoiceMemo]; - UIImage *icon = [UIImage imageNamed:@"mic-outline-24"]; + UIImage *icon = [UIImage imageNamed:@"mic-solid-24"]; OWSAssertDebug(icon); UIImageView *imageView = [[UIImageView alloc] initWithImage:[icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]]; @@ -840,70 +885,59 @@ const CGFloat kMaxIPadTextViewHeight = 142; NSMutableAttributedString *cancelString = [NSMutableAttributedString new]; const CGFloat cancelArrowFontSize = ScaleFromIPhone5To7Plus(18.4, 20.f); - const CGFloat cancelFontSize = ScaleFromIPhone5To7Plus(14.f, 16.f); NSString *arrowHead = @"\uf104"; [cancelString append:arrowHead attributes:@{ NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : UIColor.ows_accentRedColor, + NSForegroundColorAttributeName : Theme.secondaryTextAndIconColor, NSBaselineOffsetAttributeName : @(-1.f), }]; [cancelString append:@" " attributes:@{ NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : UIColor.ows_accentRedColor, - NSBaselineOffsetAttributeName : @(-1.f), - }]; - [cancelString append:NSLocalizedString(@"VOICE_MESSAGE_CANCEL_INSTRUCTIONS", - @"Indicates how to cancel a voice message.") - attributes:@{ - NSFontAttributeName : [UIFont ows_semiboldFontWithSize:cancelFontSize], - NSForegroundColorAttributeName : UIColor.ows_accentRedColor, - }]; - [cancelString append:@" " - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : UIColor.ows_accentRedColor, - NSBaselineOffsetAttributeName : @(-1.f), - }]; - [cancelString append:arrowHead - attributes:@{ - NSFontAttributeName : [UIFont ows_fontAwesomeFont:cancelArrowFontSize], - NSForegroundColorAttributeName : UIColor.ows_accentRedColor, + NSForegroundColorAttributeName : Theme.secondaryTextAndIconColor, NSBaselineOffsetAttributeName : @(-1.f), }]; + [cancelString + append:NSLocalizedString(@"VOICE_MESSAGE_CANCEL_INSTRUCTIONS", @"Indicates how to cancel a voice message.") + attributes:@{ + NSFontAttributeName : [UIFont ows_dynamicTypeSubheadlineClampedFont], + NSForegroundColorAttributeName : Theme.secondaryTextAndIconColor, + }]; UILabel *cancelLabel = [UILabel new]; + cancelLabel.textAlignment = NSTextAlignmentRight; self.voiceMemoCancelLabel = cancelLabel; cancelLabel.attributedText = cancelString; [self.voiceMemoContentView addSubview:cancelLabel]; - const CGFloat kRedCircleSize = 100.f; + const CGFloat kRedCircleSize = 80.f; UIView *redCircleView = [[OWSCircleView alloc] initWithDiameter:kRedCircleSize]; self.voiceMemoRedRecordingCircle = redCircleView; redCircleView.backgroundColor = UIColor.ows_accentRedColor; - [self.voiceMemoContentView addSubview:redCircleView]; - [redCircleView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.voiceMemoButton]; - [redCircleView autoAlignAxis:ALAxisVertical toSameAxisOfView:self.voiceMemoButton]; + [self addSubview:redCircleView]; + [redCircleView autoAlignAxis:ALAxisHorizontal toSameAxisOfView:self.voiceMemoContentView]; + [redCircleView autoPinEdgeToSuperviewEdge:ALEdgeRight withInset:12]; - UIImage *whiteIcon = [UIImage imageNamed:@"mic-outline-64"]; + UIImage *whiteIcon = [UIImage imageNamed:@"mic-solid-36"]; OWSAssertDebug(whiteIcon); UIImageView *whiteIconView = [[UIImageView alloc] initWithImage:whiteIcon]; [redCircleView addSubview:whiteIconView]; [whiteIconView autoCenterInSuperview]; [imageView autoVCenterInSuperview]; - [imageView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:10.f]; + [imageView autoPinEdgeToSuperviewEdge:ALEdgeLeft withInset:12.f]; [self.recordingLabel autoVCenterInSuperview]; - [self.recordingLabel autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:imageView withOffset:5.f]; + [self.recordingLabel autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:imageView withOffset:8.f]; [cancelLabel autoVCenterInSuperview]; - [cancelLabel autoHCenterInSuperview]; - [self.voiceMemoUI layoutIfNeeded]; + [cancelLabel autoPinEdgeToSuperviewEdge:ALEdgeRight withInset:72.f]; + [cancelLabel autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.recordingLabel]; - // 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; + VoiceMemoLockView *voiceMemoLockView = [VoiceMemoLockView new]; + self.voiceMemoLockView = voiceMemoLockView; + [self addSubview:voiceMemoLockView]; + [voiceMemoLockView autoAlignAxis:ALAxisVertical toSameAxisOfView:redCircleView]; + [voiceMemoLockView autoPinEdge:ALEdgeBottom toEdge:ALEdgeTop ofView:redCircleView]; + [voiceMemoLockView setCompressionResistanceHigh]; voiceMemoLockView.transform = CGAffineTransformMakeScale(0.0, 0.0); [voiceMemoLockView layoutIfNeeded]; @@ -915,36 +949,18 @@ const CGFloat kMaxIPadTextViewHeight = 142; } completion:nil]; - [UIView animateWithDuration:0.35f - delay:0.f - options:UIViewAnimationOptionCurveEaseOut - animations:^{ - cancelLabel.frame = cancelLabelEndFrame; - } - completion:nil]; + redCircleView.transform = CGAffineTransformMakeScale(0.0, 0.0); + [UIView animateWithDuration:0.1f animations:^{ redCircleView.transform = CGAffineTransformIdentity; }]; // Pulse the icon. - imageView.layer.opacity = 1.f; + imageView.alpha = 1.f; [UIView animateWithDuration:0.5f delay:0.2f options:UIViewAnimationOptionRepeat | UIViewAnimationOptionAutoreverse | UIViewAnimationOptionCurveEaseIn - animations:^{ - imageView.layer.opacity = 0.f; - } + animations:^{ imageView.alpha = 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 @@ -960,33 +976,29 @@ const CGFloat kMaxIPadTextViewHeight = 142; self.voiceMemoRecordingState = VoiceMemoRecordingState_Idle; - UIView *oldVoiceMemoUI = self.voiceMemoUI; + UIView *oldVoiceMemoRedRecordingCircle = self.voiceMemoRedRecordingCircle; UIView *oldVoiceMemoLockView = self.voiceMemoLockView; - self.voiceMemoUI = nil; self.voiceMemoCancelLabel = nil; self.voiceMemoRedRecordingCircle = nil; - self.voiceMemoContentView = nil; self.voiceMemoLockView = nil; self.recordingLabel = nil; [self.voiceMemoUpdateTimer invalidate]; self.voiceMemoUpdateTimer = nil; - [oldVoiceMemoUI.layer removeAllAnimations]; - if (animated) { - [UIView animateWithDuration:0.35f + [UIView animateWithDuration:0.2f animations:^{ - oldVoiceMemoUI.layer.opacity = 0.f; - oldVoiceMemoLockView.layer.opacity = 0.f; + oldVoiceMemoRedRecordingCircle.alpha = 0.f; + oldVoiceMemoLockView.alpha = 0.f; } completion:^(BOOL finished) { - [oldVoiceMemoUI removeFromSuperview]; + [oldVoiceMemoRedRecordingCircle removeFromSuperview]; [oldVoiceMemoLockView removeFromSuperview]; }]; } else { - [oldVoiceMemoUI removeFromSuperview]; + [oldVoiceMemoRedRecordingCircle removeFromSuperview]; [oldVoiceMemoLockView removeFromSuperview]; } } @@ -995,18 +1007,7 @@ const CGFloat kMaxIPadTextViewHeight = 142; { __weak __typeof(self) weakSelf = self; - UIButton *sendVoiceMemoButton = [[OWSButton alloc] initWithBlock:^{ - [weakSelf.inputToolbarDelegate voiceMemoGestureDidComplete]; - }]; - [sendVoiceMemoButton setTitle:MessageStrings.sendButton forState:UIControlStateNormal]; - [sendVoiceMemoButton setTitleColor:UIColor.ows_accentBlueColor forState:UIControlStateNormal]; - sendVoiceMemoButton.alpha = 0; - [self.voiceMemoContentView addSubview:sendVoiceMemoButton]; - [sendVoiceMemoButton autoPinEdgeToSuperviewMargin:ALEdgeRight withInset:10.f]; - [sendVoiceMemoButton autoVCenterInSuperview]; - [sendVoiceMemoButton setCompressionResistanceHigh]; - [sendVoiceMemoButton setContentHuggingHigh]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, sendVoiceMemoButton); + [ImpactHapticFeedback impactOccuredWithStyle:UIImpactFeedbackStyleMedium]; UIButton *cancelButton = [[OWSButton alloc] initWithBlock:^{ [weakSelf.inputToolbarDelegate voiceMemoGestureDidCancel]; @@ -1014,7 +1015,8 @@ const CGFloat kMaxIPadTextViewHeight = 142; [cancelButton setTitle:CommonStrings.cancelButton forState:UIControlStateNormal]; [cancelButton setTitleColor:UIColor.ows_accentRedColor forState:UIControlStateNormal]; cancelButton.alpha = 0; - cancelButton.titleLabel.textAlignment = NSTextAlignmentCenter; + cancelButton.titleLabel.textAlignment = NSTextAlignmentRight; + cancelButton.titleLabel.font = UIFont.ows_dynamicTypeBodyClampedFont.ows_medium; SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, cancelButton); [self.voiceMemoContentView addSubview:cancelButton]; @@ -1025,29 +1027,23 @@ const CGFloat kMaxIPadTextViewHeight = 142; forConstraints:^{ [cancelButton autoHCenterInSuperview]; }]; + [cancelButton autoPinEdgeToSuperviewMargin:ALEdgeRight withInset:20]; [cancelButton autoPinEdge:ALEdgeLeft toEdge:ALEdgeRight ofView:self.recordingLabel withOffset:4 relation:NSLayoutRelationGreaterThanOrEqual]; - [cancelButton autoPinEdge:ALEdgeLeft - toEdge:ALEdgeRight - ofView:sendVoiceMemoButton - withOffset:-4 - relation:NSLayoutRelationLessThanOrEqual]; [cancelButton autoVCenterInSuperview]; + [self.voiceMemoCancelLabel removeFromSuperview]; [self.voiceMemoContentView layoutIfNeeded]; - [UIView animateWithDuration:0.35 + [UIView animateWithDuration:0.2f animations:^{ - self.voiceMemoCancelLabel.alpha = 0; self.voiceMemoRedRecordingCircle.alpha = 0; - self.voiceMemoLockView.transform = CGAffineTransformMakeScale(0, 0); + self.voiceMemoLockView.alpha = 0; cancelButton.alpha = 1.0; - sendVoiceMemoButton.alpha = 1.0; } completion:^(BOOL finished) { - [self.voiceMemoCancelLabel removeFromSuperview]; [self.voiceMemoRedRecordingCircle removeFromSuperview]; [self.voiceMemoLockView removeFromSuperview]; }]; @@ -1059,7 +1055,7 @@ const CGFloat kMaxIPadTextViewHeight = 142; // 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)); + self.voiceMemoCancelLabel.alpha = MAX(0.f, MIN(1.f, 1.f - (float)cancelAlpha)); } - (void)updateVoiceMemo @@ -1078,13 +1074,46 @@ const CGFloat kMaxIPadTextViewHeight = 142; } } +- (void)showVoiceMemoTooltip +{ + if (self.voiceMemoTooltip) { + return; + } + + __weak ConversationInputToolbar *weakSelf = self; + UIView *tooltip = [VoiceMessageTooltip presentFromView:self + widthReferenceView:self + tailReferenceView:self.voiceMemoButton + wasTappedBlock:^{ [weakSelf removeVoiceMemoTooltip]; }]; + self.voiceMemoTooltip = tooltip; + + const CGFloat tooltipDurationSeconds = 3.f; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(tooltipDurationSeconds * NSEC_PER_SEC)), + dispatch_get_main_queue(), + ^{ [weakSelf removeVoiceMemoTooltip]; }); +} + +- (void)removeVoiceMemoTooltip +{ + UIView *voiceMemoTooltip = self.voiceMemoTooltip; + self.voiceMemoTooltip = nil; + + [UIView animateWithDuration:0.2 + animations:^{ voiceMemoTooltip.alpha = 0; } + completion:^(BOOL finished) { [voiceMemoTooltip removeFromSuperview]; }]; +} + #pragma mark - Event Handlers - (void)sendButtonPressed { OWSAssertDebug(self.inputToolbarDelegate); - [self.inputToolbarDelegate sendButtonPressed]; + if (self.isRecordingVoiceMemo) { + [self.inputToolbarDelegate voiceMemoGestureDidComplete]; + } else { + [self.inputToolbarDelegate sendButtonPressed]; + } } - (void)cameraButtonPressed diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+VoiceMessage.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+VoiceMessage.swift index 7bfd8bbc82..5d71ededb8 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+VoiceMessage.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+VoiceMessage.swift @@ -12,7 +12,6 @@ extension ConversationViewController { // Cancel any ongoing audio playback. cvAudioPlayer.stopAll() - SelectionHapticFeedback().selectionChanged() inputToolbar?.showVoiceMemoUI() let voiceMessageModel = VoiceMessageModel(thread: thread) @@ -38,6 +37,8 @@ extension ConversationViewController { return owsFailDebug("Unexpectedly missing voice message model") } + ImpactHapticFeedback.impactOccured(style: .light) + do { try voiceMessageModel.startRecording() } catch { @@ -50,7 +51,13 @@ extension ConversationViewController { func cancelRecordingVoiceMessage() { AssertIsOnMainThread() - viewState.currentVoiceMessageModel?.stopRecording() + defer { viewState.currentVoiceMessageModel = nil } + guard let voiceMessageModel = viewState.currentVoiceMessageModel else { return } + + voiceMessageModel.stopRecording() + + NotificationHapticFeedback().notificationOccurred(.warning) + clearVoiceMessageDraft() viewState.currentVoiceMessageModel = nil inputToolbar?.cancelVoiceMemoIfNecessary() @@ -66,17 +73,14 @@ extension ConversationViewController { let duration = voiceMessageModel.stopRecording() - SelectionHapticFeedback().selectionChanged() - guard duration >= Self.minimumVoiceMessageDuration else { - presentToast(text: NSLocalizedString( - "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", - comment: "Message for the alert indicating the 'voice message' needs to be held to be held down to record." - )) + inputToolbar?.showVoiceMemoTooltip() cancelRecordingVoiceMessage() return } + ImpactHapticFeedback.impactOccured(style: .medium) + if sendImmediately { inputToolbar?.hideVoiceMemoUI(true) sendVoiceMessageModel(voiceMessageModel) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.h b/Signal/src/ViewControllers/ConversationView/ConversationViewController.h index 05a346c4d3..1a7c896f49 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.h @@ -92,7 +92,8 @@ typedef NS_CLOSED_ENUM(NSUInteger, ConversationUIMode) { @property (nonatomic, readonly) ConversationViewLayout *layout; @property (nonatomic, readonly) CVViewState *viewState; -- (void)tryToSendAttachments:(NSArray *)attachments messageBody:(MessageBody *_Nullable)messageBody NS_SWIFT_NAME(tryToSendAttachments(_:messageBody:)); +- (void)tryToSendAttachments:(NSArray *)attachments + messageBody:(MessageBody *_Nullable)messageBody NS_SWIFT_NAME(tryToSendAttachments(_:messageBody:)); - (void)updateBarButtonItems; - (void)ensureBannerState; diff --git a/Signal/src/views/VoiceMessageTooltip.swift b/Signal/src/views/VoiceMessageTooltip.swift new file mode 100644 index 0000000000..529f2b1747 --- /dev/null +++ b/Signal/src/views/VoiceMessageTooltip.swift @@ -0,0 +1,33 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public class VoiceMessageTooltip: TooltipView { + @objc + public class func present(fromView: UIView, + widthReferenceView: UIView, + tailReferenceView: UIView, + wasTappedBlock: (() -> Void)?) -> VoiceMessageTooltip { + return VoiceMessageTooltip(fromView: fromView, widthReferenceView: widthReferenceView, tailReferenceView: tailReferenceView, wasTappedBlock: wasTappedBlock) + } + + public override func bubbleContentView() -> UIView { + let label = UILabel() + label.text = NSLocalizedString( + "VOICE_MESSAGE_TOO_SHORT_TOOLTIP", + comment: "Message for the tooltip indicating the 'voice message' needs to be held to be held down to record." + ) + label.font = UIFont.ows_dynamicTypeBodyClamped + label.textColor = Theme.primaryTextColor + + return horizontalStack(forSubviews: [label]) + } + + public override var bubbleColor: UIColor { Theme.backgroundColor } + public override var bubbleHSpacing: CGFloat { 8 } + + public override var tailDirection: TooltipView.TailDirection { .down } +} diff --git a/Signal/src/views/VoiceNoteLock.swift b/Signal/src/views/VoiceNoteLock.swift index 594a764a34..3e7632ff42 100644 --- a/Signal/src/views/VoiceNoteLock.swift +++ b/Signal/src/views/VoiceNoteLock.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation @@ -9,8 +9,8 @@ public class VoiceMemoLockView: UIView { private var offsetConstraint: NSLayoutConstraint! - private let offsetFromToolbar: CGFloat = 40 - private let backgroundViewInitialHeight: CGFloat = 80 + private let offsetFromToolbar: CGFloat = 42 + private let backgroundViewInitialHeight: CGFloat = 72 private var chevronTravel: CGFloat { return -1 * (backgroundViewInitialHeight - 50) } @@ -29,7 +29,7 @@ public class VoiceMemoLockView: UIView { // we anchor the top so that the bottom "slides up" to meet it as the user slides the lock backgroundView.autoPinEdge(.top, to: .bottom, of: self, withOffset: -offsetFromToolbar - backgroundViewInitialHeight) - backgroundView.layoutMargins = UIEdgeInsets(top: 6, leading: 6, bottom: 6, trailing: 6) + backgroundView.layoutMargins = UIEdgeInsets(top: 8, leading: 8, bottom: 8, trailing: 8) lockIconView.autoPinEdges(toSuperviewMarginsExcludingEdge: .bottom) chevronView.autoPinEdges(toSuperviewMarginsExcludingEdge: .top) @@ -49,9 +49,9 @@ public class VoiceMemoLockView: UIView { // MARK: - Subviews private lazy var lockIconView: UIImageView = { - let imageTemplate = #imageLiteral(resourceName: "ic_lock_outline").withRenderingMode(.alwaysTemplate) + let imageTemplate = #imageLiteral(resourceName: "lock-solid-24").withRenderingMode(.alwaysTemplate) let imageView = UIImageView(image: imageTemplate) - imageView.tintColor = .ows_accentRed + imageView.tintColor = Theme.isDarkThemeEnabled ? .ows_gray15 : .ows_gray75 imageView.autoSetDimensions(to: CGSize(square: 24)) return imageView }() @@ -59,7 +59,7 @@ public class VoiceMemoLockView: UIView { private lazy var chevronView: UIView = { let label = UILabel() label.text = "\u{2303}" - label.textColor = .ows_accentRed + label.textColor = Theme.ternaryTextColor label.textAlignment = .center return label }() @@ -67,13 +67,18 @@ public class VoiceMemoLockView: UIView { private lazy var backgroundView: UIView = { let view = UIView() - let width: CGFloat = 36 + let width: CGFloat = 40 view.autoSetDimension(.width, toSize: width) - view.backgroundColor = Theme.scrollButtonBackgroundColor + view.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray02 view.layer.cornerRadius = width / 2 view.layer.borderColor = Theme.washColor.cgColor view.layer.borderWidth = CGHairlineWidth() + view.layer.shadowOffset = CGSize(width: 0, height: 4) + view.layer.shadowRadius = 12 + view.layer.shadowOpacity = 0.3 + view.layer.shadowColor = UIColor.black.cgColor + return view }() diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 063ef8f2d0..b5a18f50fc 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -2590,6 +2590,9 @@ /* accessibility label for the button which shows the sticker picker */ "INPUT_TOOLBAR_STICKER_BUTTON_ACCESSIBILITY_LABEL" = "Stickers"; +/* accessibility hint for the button which records voice memos */ +"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_HINT" = "Double tap and hold to record. Release to send or slide left to cancel. Double tap and slide up to lock recording."; + /* accessibility label for the button which records voice memos */ "INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL" = "Voice Message"; @@ -5858,16 +5861,13 @@ "VIEW_ONCE_OUTGOING_TOAST" = "Outgoing view-once media files are automatically removed after they are sent."; /* Indicates how to cancel a voice message. */ -"VOICE_MESSAGE_CANCEL_INSTRUCTIONS" = "Slide to Cancel"; +"VOICE_MESSAGE_CANCEL_INSTRUCTIONS" = "Slide to cancel"; /* Filename for voice messages. */ "VOICE_MESSAGE_FILE_NAME" = "Voice Message"; -/* Message for the alert indicating the 'voice message' needs to be held to be held down to record. */ -"VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE" = "Tap and hold to record a voice message."; - -/* Title for the alert indicating the 'voice message' needs to be held to be held down to record. */ -"VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE" = "Voice Message"; +/* Message for the tooltip indicating the 'voice message' needs to be held to be held down to record. */ +"VOICE_MESSAGE_TOO_SHORT_TOOLTIP" = "Press and hold to record."; /* 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."; diff --git a/SignalMessaging/environment/OWSAudioSession.swift b/SignalMessaging/environment/OWSAudioSession.swift index fe28cd40b1..6ef3068565 100644 --- a/SignalMessaging/environment/OWSAudioSession.swift +++ b/SignalMessaging/environment/OWSAudioSession.swift @@ -144,6 +144,9 @@ public class OWSAudioSession: NSObject { } else if aggregateBehaviors.contains(.playAndRecord) { assert(avAudioSession.recordPermission == .granted) try avAudioSession.setCategory(.record) + if #available(iOS 13, *) { + try avAudioSession.setAllowHapticsAndSystemSoundsDuringRecording(true) + } } else if aggregateBehaviors.contains(.audioMessagePlayback) { if self.device.proximityState { Logger.debug("proximityState: true")