diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 07f7d960c..bda67a89b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -564,6 +564,7 @@ C364535C252467900045C478 /* AudioUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = C364535B252467900045C478 /* AudioUtilities.swift */; }; C374EEE225DA26740073A857 /* LinkPreviewModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEE125DA26740073A857 /* LinkPreviewModal.swift */; }; C374EEEB25DA3CA70073A857 /* ConversationTitleViewV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEEA25DA3CA70073A857 /* ConversationTitleViewV2.swift */; }; + C374EEF425DB31D40073A857 /* VoiceMessageOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */; }; C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */; }; C37F5385255B94F6002AEA92 /* SelectRecipientViewController.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF34E255B6DC8007E1867 /* SelectRecipientViewController.h */; settings = {ATTRIBUTES = (Public, ); }; }; C37F5396255B95BD002AEA92 /* OWSAnyTouchGestureRecognizer.h in Headers */ = {isa = PBXBuildFile; fileRef = C38EF302255B6DBE007E1867 /* OWSAnyTouchGestureRecognizer.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1608,6 +1609,7 @@ C364535B252467900045C478 /* AudioUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioUtilities.swift; sourceTree = ""; }; C374EEE125DA26740073A857 /* LinkPreviewModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewModal.swift; sourceTree = ""; }; C374EEEA25DA3CA70073A857 /* ConversationTitleViewV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTitleViewV2.swift; sourceTree = ""; }; + C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageOverlay.swift; sourceTree = ""; }; C379DCF3256735770002D4EB /* VisibleMessage+Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VisibleMessage+Attachment.swift"; sourceTree = ""; }; C379DCFD25673DBC0002D4EB /* TSAttachmentPointer+Conversion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TSAttachmentPointer+Conversion.swift"; sourceTree = ""; }; C37F53E8255BA9BB002AEA92 /* Environment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Environment.h; sourceTree = ""; }; @@ -2289,6 +2291,7 @@ B8269D2825C7A4B400488AB4 /* InputView.swift */, B8269D3225C7A8C600488AB4 /* InputViewButton.swift */, B8269D3C25C7B34D00488AB4 /* InputTextView.swift */, + C374EEF325DB31D40073A857 /* VoiceMessageOverlay.swift */, ); path = "Input View"; sourceTree = ""; @@ -5014,6 +5017,7 @@ 34129B8621EF877A005457A8 /* LinkPreviewView.swift in Sources */, 34386A54207D271D009F5D9C /* NeverClearView.swift in Sources */, 451166C01FD86B98000739BA /* AccountManager.swift in Sources */, + C374EEF425DB31D40073A857 /* VoiceMessageOverlay.swift in Sources */, B83F2B88240CB75A000A54AB /* UIImage+Scaling.swift in Sources */, 3430FE181F7751D4000EC51B /* GiphyAPI.swift in Sources */, 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */, diff --git a/Session/Conversations V2/ConversationVC.swift b/Session/Conversations V2/ConversationVC.swift index fe4673686..c3f52f0d3 100644 --- a/Session/Conversations V2/ConversationVC.swift +++ b/Session/Conversations V2/ConversationVC.swift @@ -3,6 +3,7 @@ // • Tapping replies // • Mentions // • Remaining send logic +// • Recording voice messages // • Slight paging glitch // • Scrolling bug // • Scroll button bug diff --git a/Session/Conversations V2/Input View/InputTextView.swift b/Session/Conversations V2/Input View/InputTextView.swift index 11a139e2b..e87681f1f 100644 --- a/Session/Conversations V2/Input View/InputTextView.swift +++ b/Session/Conversations V2/Input View/InputTextView.swift @@ -52,6 +52,7 @@ public final class InputTextView : UITextView, UITextViewDelegate { // MARK: Updating public func textViewDidChange(_ textView: UITextView) { + defer { snDelegate.inputTextViewDidChangeContent(self) } placeholderLabel.isHidden = !text.isEmpty let width = frame.width let height = frame.height @@ -69,4 +70,5 @@ public final class InputTextView : UITextView, UITextViewDelegate { protocol InputTextViewDelegate { func inputTextViewDidChangeSize(_ inputTextView: InputTextView) + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) } diff --git a/Session/Conversations V2/Input View/InputView.swift b/Session/Conversations V2/Input View/InputView.swift index 87cb30968..83a721289 100644 --- a/Session/Conversations V2/Input View/InputView.swift +++ b/Session/Conversations V2/Input View/InputView.swift @@ -3,6 +3,7 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, private let delegate: InputViewDelegate var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? + private var voiceMessageOverlay: VoiceMessageOverlay? private lazy var linkPreviewView: LinkPreviewViewV2 = { let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset @@ -21,7 +22,12 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, private lazy var libraryButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_camera_roll_black"), delegate: self) private lazy var gifButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_gif_black"), delegate: self) private lazy var documentButton = InputViewButton(icon: #imageLiteral(resourceName: "actionsheet_document_black"), delegate: self) - private lazy var sendButton = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) + private lazy var voiceMessageButton = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self) + private lazy var sendButton: InputViewButton = { + let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self) + result.alpha = 0 + return result + }() private lazy var inputTextView = InputTextView(delegate: self) @@ -94,11 +100,21 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin(.bottom, to: .bottom, of: self, withInset: -2) + // Voice message button + let voiceMessageButtonContainer = container(for: voiceMessageButton) + addSubview(voiceMessageButtonContainer) + voiceMessageButtonContainer.center(in: sendButton) } // MARK: Updating func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { invalidateIntrinsicContentSize() + } + + func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + let hasText = !text.isEmpty + sendButton.alpha = hasText ? 1 : 0 + voiceMessageButton.alpha = hasText ? 0 : 1 autoGenerateLinkPreviewIfPossible() } @@ -174,6 +190,10 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, if inputViewButton == sendButton { delegate.handleSendButtonTapped() } } + func handleInputViewButtonLongPressed(_ inputViewButton: InputViewButton) { + if inputViewButton == voiceMessageButton { showVoiceMessageUI() } + } + func handleQuoteViewCancelButtonTapped() { delegate.handleQuoteViewCancelButtonTapped() } @@ -190,6 +210,21 @@ final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, linkPreviewInfo = nil additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } } + + @objc private func showVoiceMessageUI() { + voiceMessageOverlay?.removeFromSuperview() + let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self) + let voiceMessageOverlay = VoiceMessageOverlay(voiceMessageButtonFrame: voiceMessageButtonFrame) + voiceMessageOverlay.alpha = 0 + addSubview(voiceMessageOverlay) + voiceMessageOverlay.pin(to: self) + self.voiceMessageOverlay = voiceMessageOverlay + voiceMessageOverlay.animate() + let allOtherViews = [ cameraButton, libraryButton, gifButton, documentButton, sendButton, inputTextView, additionalContentContainer ] + UIView.animate(withDuration: 0.25) { + allOtherViews.forEach { $0.alpha = 0 } + } + } } // MARK: Delegate diff --git a/Session/Conversations V2/Input View/InputViewButton.swift b/Session/Conversations V2/Input View/InputViewButton.swift index de99787ae..c8c7bd82b 100644 --- a/Session/Conversations V2/Input View/InputViewButton.swift +++ b/Session/Conversations V2/Input View/InputViewButton.swift @@ -5,10 +5,13 @@ final class InputViewButton : UIView { private let delegate: InputViewButtonDelegate private lazy var widthConstraint = set(.width, to: InputViewButton.size) private lazy var heightConstraint = set(.height, to: InputViewButton.size) + private var longPressTimer: Timer? + private var isLongPress = false // MARK: Settings static let size = CGFloat(40) static let expandedSize = CGFloat(48) + static let iconSize: CGFloat = 20 // MARK: Lifecycle init(icon: UIImage, isSendButton: Bool = false, delegate: InputViewButtonDelegate) { @@ -37,8 +40,9 @@ final class InputViewButton : UIView { let tint = isSendButton ? UIColor.black : Colors.text let iconImageView = UIImageView(image: icon.withTint(tint)) iconImageView.contentMode = .scaleAspectFit - iconImageView.set(.width, to: 20) - iconImageView.set(.height, to: 20) + let iconSize = InputViewButton.iconSize + iconImageView.set(.width, to: iconSize) + iconImageView.set(.height, to: iconSize) addSubview(iconImageView) iconImageView.center(in: self) } @@ -71,15 +75,30 @@ final class InputViewButton : UIView { override func touchesBegan(_ touches: Set, with event: UIEvent?) { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() expand() + invalidateLongPressIfNeeded() + longPressTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false, block: { [weak self] _ in + guard let self = self else { return } + self.isLongPress = true + self.delegate.handleInputViewButtonLongPressed(self) + }) } override func touchesEnded(_ touches: Set, with event: UIEvent?) { collapse() - delegate.handleInputViewButtonTapped(self) + if !isLongPress { + delegate.handleInputViewButtonTapped(self) + } + invalidateLongPressIfNeeded() } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { collapse() + invalidateLongPressIfNeeded() + } + + private func invalidateLongPressIfNeeded() { + longPressTimer?.invalidate() + isLongPress = false } } @@ -87,4 +106,5 @@ final class InputViewButton : UIView { protocol InputViewButtonDelegate { func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) + func handleInputViewButtonLongPressed(_ inputViewButton: InputViewButton) } diff --git a/Session/Conversations V2/Input View/VoiceMessageOverlay.swift b/Session/Conversations V2/Input View/VoiceMessageOverlay.swift new file mode 100644 index 000000000..cea893b85 --- /dev/null +++ b/Session/Conversations V2/Input View/VoiceMessageOverlay.swift @@ -0,0 +1,123 @@ + +final class VoiceMessageOverlay : UIView { + private let voiceMessageButtonFrame: CGRect + private lazy var slideToCancelStackViewRightConstraint = slideToCancelStackView.pin(.right, to: .right, of: self) + private lazy var slideToCancelStackViewCenterHorizontalConstraint = slideToCancelStackView.center(.horizontal, in: self) + + // MARK: UI Components + private lazy var slideToCancelStackView: UIStackView = { + let result = UIStackView() + result.axis = .horizontal + result.spacing = Values.smallSpacing + result.alpha = 0 + result.alignment = .center + return result + }() + + private lazy var durationStackView: UIStackView = { + let result = UIStackView() + result.axis = .horizontal + result.spacing = 4 + result.alpha = 0 + result.alignment = .center + return result + }() + + private lazy var durationLabel: UILabel = { + let result = UILabel() + result.textColor = Colors.destructive + result.font = .boldSystemFont(ofSize: Values.smallFontSize) + result.text = "00:12" + return result + }() + + // MARK: Settings + private static let circleSize: CGFloat = 100 + private static let iconSize: CGFloat = 28 + private static let chevronSize: CGFloat = 20 + + // MARK: Lifecycle + init(voiceMessageButtonFrame: CGRect) { + self.voiceMessageButtonFrame = voiceMessageButtonFrame + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(voiceMessageButtonFrame:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(voiceMessageButtonFrame:) instead.") + } + + private func setUpViewHierarchy() { + let iconSize = VoiceMessageOverlay.iconSize + // Icon + let iconImageView = UIImageView() + iconImageView.image = UIImage(named: "Microphone")!.withTint(.white) + iconImageView.contentMode = .scaleAspectFit + iconImageView.set(.width, to: iconSize) + iconImageView.set(.height, to: iconSize) + addSubview(iconImageView) + let voiceMessageButtonCenter = voiceMessageButtonFrame.center + iconImageView.pin(.left, to: .left, of: self, withInset: voiceMessageButtonCenter.x - iconSize / 2) + iconImageView.pin(.top, to: .top, of: self, withInset: voiceMessageButtonCenter.y - iconSize / 2) + // Circle + let circleView = UIView() + circleView.backgroundColor = Colors.destructive + let circleSize = VoiceMessageOverlay.circleSize + circleView.set(.width, to: circleSize) + circleView.set(.height, to: circleSize) + circleView.layer.cornerRadius = circleSize / 2 + circleView.layer.masksToBounds = true + insertSubview(circleView, at: 0) + circleView.center(in: iconImageView) + // Slide to cancel stack view + let chevronSize = VoiceMessageOverlay.chevronSize + let chevronLeft1 = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(Colors.destructive)) + chevronLeft1.contentMode = .scaleAspectFit + chevronLeft1.set(.width, to: chevronSize) + chevronLeft1.set(.height, to: chevronSize) + slideToCancelStackView.addArrangedSubview(chevronLeft1) + let slideToCancelLabel = UILabel() + slideToCancelLabel.text = "Slide to cancel" + slideToCancelLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) + slideToCancelLabel.textColor = Colors.destructive + slideToCancelStackView.addArrangedSubview(slideToCancelLabel) + let chevronLeft2 = UIImageView(image: UIImage(named: "small_chevron_left")!.withTint(Colors.destructive)) + chevronLeft2.contentMode = .scaleAspectFit + chevronLeft2.set(.width, to: chevronSize) + chevronLeft2.set(.height, to: chevronSize) + slideToCancelStackView.addArrangedSubview(chevronLeft2) + addSubview(slideToCancelStackView) + slideToCancelStackViewRightConstraint.isActive = true + slideToCancelStackView.center(.vertical, in: iconImageView) + // Duration stack view + let microphoneImageView = UIImageView() + microphoneImageView.image = UIImage(named: "Microphone")!.withTint(Colors.destructive) + microphoneImageView.contentMode = .scaleAspectFit + microphoneImageView.set(.width, to: iconSize) + microphoneImageView.set(.height, to: iconSize) + durationStackView.addArrangedSubview(microphoneImageView) + durationStackView.addArrangedSubview(durationLabel) + addSubview(durationStackView) + durationStackView.pin(.left, to: .left, of: self, withInset: Values.largeSpacing) + durationStackView.center(.vertical, in: iconImageView) + } + + // MARK: Animation + func animate() { + UIView.animate(withDuration: 0.15, animations: { + self.alpha = 1 + }, completion: { _ in + self.slideToCancelStackViewRightConstraint.isActive = false + self.slideToCancelStackViewCenterHorizontalConstraint.isActive = true + UIView.animate(withDuration: 0.15) { + self.slideToCancelStackView.alpha = 1 + self.durationStackView.alpha = 1 + self.layoutIfNeeded() + } + }) + } +}