2277 lines
84 KiB
Swift
2277 lines
84 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Photos
|
|
public import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
protocol ConversationInputToolbarDelegate: AnyObject {
|
|
|
|
func sendButtonPressed()
|
|
|
|
func sendSticker(_ sticker: StickerInfo)
|
|
|
|
func presentManageStickersView()
|
|
|
|
func updateToolbarHeight()
|
|
|
|
func isBlockedConversation() -> Bool
|
|
|
|
func isGroup() -> Bool
|
|
|
|
// MARK: Voice Memo
|
|
|
|
func voiceMemoGestureDidStart()
|
|
|
|
func voiceMemoGestureDidLock()
|
|
|
|
func voiceMemoGestureDidComplete()
|
|
|
|
func voiceMemoGestureDidCancel()
|
|
|
|
func voiceMemoGestureWasInterrupted()
|
|
|
|
func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft)
|
|
|
|
// MARK: Attachments
|
|
|
|
func cameraButtonPressed()
|
|
|
|
func photosButtonPressed()
|
|
|
|
func gifButtonPressed()
|
|
|
|
func fileButtonPressed()
|
|
|
|
func contactButtonPressed()
|
|
|
|
func locationButtonPressed()
|
|
|
|
func paymentButtonPressed()
|
|
|
|
func didSelectRecentPhoto(asset: PHAsset, attachment: SignalAttachment)
|
|
|
|
func showUnblockConversationUI(completion: ((Bool) -> Void)?)
|
|
}
|
|
|
|
public class ConversationInputToolbar: UIView, LinkPreviewViewDraftDelegate, QuotedReplyPreviewDelegate {
|
|
|
|
private var conversationStyle: ConversationStyle
|
|
private let spoilerState: SpoilerRenderState
|
|
|
|
private let mediaCache: CVMediaCache
|
|
|
|
private weak var inputToolbarDelegate: ConversationInputToolbarDelegate?
|
|
|
|
init(
|
|
conversationStyle: ConversationStyle,
|
|
spoilerState: SpoilerRenderState,
|
|
mediaCache: CVMediaCache,
|
|
messageDraft: MessageBody?,
|
|
quotedReplyDraft: DraftQuotedReplyModel?,
|
|
editTarget: TSOutgoingMessage?,
|
|
inputToolbarDelegate: ConversationInputToolbarDelegate,
|
|
inputTextViewDelegate: ConversationInputTextViewDelegate,
|
|
mentionDelegate: BodyRangesTextViewDelegate
|
|
) {
|
|
self.conversationStyle = conversationStyle
|
|
self.spoilerState = spoilerState
|
|
self.mediaCache = mediaCache
|
|
self.editTarget = editTarget
|
|
self.inputToolbarDelegate = inputToolbarDelegate
|
|
self.linkPreviewFetchState = LinkPreviewFetchState(
|
|
db: DependenciesBridge.shared.db,
|
|
linkPreviewFetcher: SUIEnvironment.shared.linkPreviewFetcher,
|
|
linkPreviewSettingStore: DependenciesBridge.shared.linkPreviewSettingStore
|
|
)
|
|
|
|
super.init(frame: .zero)
|
|
|
|
self.linkPreviewFetchState.onStateChange = { [weak self] in self?.updateLinkPreviewView() }
|
|
|
|
createContentsWithMessageDraft(
|
|
messageDraft,
|
|
quotedReplyDraft: quotedReplyDraft,
|
|
inputTextViewDelegate: inputTextViewDelegate,
|
|
mentionDelegate: mentionDelegate
|
|
)
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(applicationDidBecomeActive(notification:)),
|
|
name: .OWSApplicationDidBecomeActive,
|
|
object: nil
|
|
)
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(keyboardFrameDidChange(notification:)),
|
|
name: UIResponder.keyboardDidChangeFrameNotification,
|
|
object: nil
|
|
)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
// MARK: Layout
|
|
|
|
public override var intrinsicContentSize: CGSize {
|
|
// Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify
|
|
// an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout.
|
|
.zero
|
|
}
|
|
|
|
public override var frame: CGRect {
|
|
didSet {
|
|
guard oldValue.size.height != frame.size.height else { return }
|
|
|
|
inputToolbarDelegate?.updateToolbarHeight()
|
|
}
|
|
}
|
|
|
|
public override var bounds: CGRect {
|
|
didSet {
|
|
guard abs(oldValue.size.height - bounds.size.height) > 1 else { return }
|
|
|
|
// Compensate for autolayout frame/bounds changes when animating in/out the quoted reply view.
|
|
// This logic ensures the input toolbar stays pinned to the keyboard visually
|
|
if isAnimatingHeightChange && inputTextView.isFirstResponder {
|
|
var frame = frame
|
|
frame.origin.y = 0
|
|
// In this conditional, bounds change is captured in an animation block, which we don't want here.
|
|
UIView.performWithoutAnimation {
|
|
self.frame = frame
|
|
}
|
|
}
|
|
|
|
inputToolbarDelegate?.updateToolbarHeight()
|
|
}
|
|
}
|
|
|
|
func update(conversationStyle: ConversationStyle) {
|
|
AssertIsOnMainThread()
|
|
self.conversationStyle = conversationStyle
|
|
}
|
|
|
|
private var receivedSafeAreaInsets = UIEdgeInsets.zero
|
|
|
|
private enum LayoutMetrics {
|
|
static let minTextViewHeight: CGFloat = 36
|
|
static let maxTextViewHeight: CGFloat = 98
|
|
static let maxIPadTextViewHeight: CGFloat = 142
|
|
static let minToolbarItemHeight: CGFloat = 52
|
|
}
|
|
|
|
private lazy var inputTextView: ConversationInputTextView = {
|
|
let inputTextView = ConversationInputTextView()
|
|
inputTextView.textViewToolbarDelegate = self
|
|
inputTextView.font = .dynamicTypeBody
|
|
inputTextView.setContentHuggingLow()
|
|
inputTextView.setCompressionResistanceLow()
|
|
inputTextView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "inputTextView")
|
|
return inputTextView
|
|
}()
|
|
|
|
private lazy var addOrCancelButton: AddOrCancelButton = {
|
|
let button = AddOrCancelButton()
|
|
button.accessibilityLabel = OWSLocalizedString(
|
|
"ATTACHMENT_LABEL",
|
|
comment: "Accessibility label for attaching photos"
|
|
)
|
|
button.accessibilityHint = OWSLocalizedString(
|
|
"ATTACHMENT_HINT",
|
|
comment: "Accessibility hint describing what you can do with the attachment button"
|
|
)
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "attachmentButton")
|
|
button.addTarget(self, action: #selector(addOrCancelButtonPressed), for: .touchUpInside)
|
|
button.autoSetDimensions(to: CGSize(square: LayoutMetrics.minToolbarItemHeight))
|
|
button.setContentHuggingHorizontalHigh()
|
|
button.setCompressionResistanceHorizontalHigh()
|
|
return button
|
|
}()
|
|
|
|
private lazy var stickerButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.tintColor = Theme.primaryIconColor
|
|
button.accessibilityLabel = OWSLocalizedString(
|
|
"INPUT_TOOLBAR_STICKER_BUTTON_ACCESSIBILITY_LABEL",
|
|
comment: "accessibility label for the button which shows the sticker picker"
|
|
)
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "stickerButton")
|
|
button.setImage(UIImage(imageLiteralResourceName: "sticker"), for: .normal)
|
|
button.addTarget(self, action: #selector(stickerButtonPressed), for: .touchUpInside)
|
|
button.autoSetDimensions(to: CGSize(width: 40, height: LayoutMetrics.minTextViewHeight))
|
|
button.setContentHuggingHorizontalHigh()
|
|
button.setCompressionResistanceHorizontalHigh()
|
|
return button
|
|
}()
|
|
|
|
private lazy var keyboardButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.tintColor = Theme.primaryIconColor
|
|
button.accessibilityLabel = OWSLocalizedString(
|
|
"INPUT_TOOLBAR_KEYBOARD_BUTTON_ACCESSIBILITY_LABEL",
|
|
comment: "accessibility label for the button which shows the regular keyboard instead of sticker picker"
|
|
)
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "keyboardButton")
|
|
button.setImage(UIImage(imageLiteralResourceName: "keyboard"), for: .normal)
|
|
button.addTarget(self, action: #selector(keyboardButtonPressed), for: .touchUpInside)
|
|
button.autoSetDimensions(to: CGSize(width: 40, height: LayoutMetrics.minTextViewHeight))
|
|
button.setContentHuggingHorizontalHigh()
|
|
button.setCompressionResistanceHorizontalHigh()
|
|
return button
|
|
}()
|
|
|
|
private lazy var editMessageThumbnailView: UIImageView = {
|
|
let imageView = UIImageView()
|
|
imageView.layer.cornerRadius = 4
|
|
imageView.clipsToBounds = true
|
|
imageView.autoSetDimensions(to: .init(square: 20))
|
|
return imageView
|
|
}()
|
|
|
|
private lazy var editMessageLabelWrapper: UIView = {
|
|
let view = UIView.container()
|
|
|
|
let editIconView = UIImageView(image: Theme.iconImage(.contextMenuEdit))
|
|
editIconView.contentMode = .scaleAspectFit
|
|
editIconView.autoSetDimension(.height, toSize: 16.0)
|
|
editIconView.setContentHuggingHigh()
|
|
editIconView.tintColor = Theme.primaryTextColor
|
|
|
|
let editLabel = UILabel()
|
|
editLabel.text = OWSLocalizedString(
|
|
"INPUT_TOOLBAR_EDIT_MESSAGE_LABEL",
|
|
comment: "Label at the top of the input text when editing a message"
|
|
)
|
|
editLabel.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
|
|
editLabel.textColor = Theme.primaryTextColor
|
|
|
|
let stack = UIStackView(arrangedSubviews: [editIconView, editLabel, editMessageThumbnailView])
|
|
stack.axis = .horizontal
|
|
stack.alignment = .center
|
|
stack.distribution = .fill
|
|
|
|
view.addSubview(stack)
|
|
stack.autoPinEdgesToSuperviewEdges(with: .init(hMargin: 10, vMargin: 8))
|
|
|
|
view.setContentHuggingHorizontalLow()
|
|
view.setCompressionResistanceHorizontalLow()
|
|
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "editMessageWrapper")
|
|
return view
|
|
}()
|
|
|
|
private lazy var quotedReplyWrapper: UIView = {
|
|
let view = UIView.container()
|
|
view.setContentHuggingHorizontalLow()
|
|
view.setCompressionResistanceHorizontalLow()
|
|
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedReplyWrapper")
|
|
return view
|
|
}()
|
|
|
|
private lazy var linkPreviewWrapper: UIView = {
|
|
let view = UIView.container()
|
|
view.clipsToBounds = true
|
|
view.setContentHuggingHorizontalLow()
|
|
view.setCompressionResistanceHorizontalLow()
|
|
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "linkPreviewWrapper")
|
|
return view
|
|
}()
|
|
|
|
private lazy var voiceMemoContentView: UIView = {
|
|
let view = UIView.container()
|
|
view.isHidden = true
|
|
view.setContentHuggingHorizontalLow()
|
|
view.setCompressionResistanceHorizontalLow()
|
|
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "voiceMemoContentView")
|
|
return view
|
|
}()
|
|
|
|
private lazy var rightEdgeControlsView: RightEdgeControlsView = {
|
|
let view = RightEdgeControlsView()
|
|
view.sendButton.addTarget(self, action: #selector(sendButtonPressed), for: .touchUpInside)
|
|
view.cameraButton.addTarget(self, action: #selector(cameraButtonPressed), for: .touchUpInside)
|
|
// 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.
|
|
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceMemoLongPress(gesture:)))
|
|
longPressGestureRecognizer.minimumPressDuration = 0
|
|
view.voiceMemoButton.addGestureRecognizer(longPressGestureRecognizer)
|
|
return view
|
|
}()
|
|
|
|
private lazy var suggestedStickerView: StickerHorizontalListView = {
|
|
let suggestedStickerSize: CGFloat = 48
|
|
let suggestedStickerSpacing: CGFloat = 12
|
|
let stickerListContentInset = UIEdgeInsets(
|
|
hMargin: OWSTableViewController2.defaultHOuterMargin,
|
|
vMargin: suggestedStickerSpacing
|
|
)
|
|
let view = StickerHorizontalListView(cellSize: suggestedStickerSize, cellInset: 0, spacing: suggestedStickerSpacing)
|
|
view.backgroundColor = Theme.conversationButtonBackgroundColor
|
|
view.contentInset = stickerListContentInset
|
|
view.autoSetDimension(.height, toSize: suggestedStickerSize + stickerListContentInset.bottom + stickerListContentInset.top)
|
|
return view
|
|
}()
|
|
|
|
private let suggestedStickerWrapper = UIView.container()
|
|
|
|
private let messageContentView = UIView.container()
|
|
|
|
private let mainPanelView: UIView = {
|
|
let view = UIView()
|
|
view.layoutMargins = UIEdgeInsets(hMargin: OWSTableViewController2.defaultHOuterMargin - 16, vMargin: 0)
|
|
return view
|
|
}()
|
|
|
|
private let mainPanelWrapperView = UIView.container()
|
|
|
|
private var isConfigurationComplete = false
|
|
|
|
private var textViewHeight: CGFloat = 0
|
|
private var textViewHeightConstraint: NSLayoutConstraint?
|
|
class var heightChangeAnimationDuration: TimeInterval { 0.25 }
|
|
private(set) var isAnimatingHeightChange = false
|
|
|
|
private var layoutConstraints: [NSLayoutConstraint]?
|
|
|
|
private func createContentsWithMessageDraft(
|
|
_ messageDraft: MessageBody?,
|
|
quotedReplyDraft: DraftQuotedReplyModel?,
|
|
inputTextViewDelegate: ConversationInputTextViewDelegate,
|
|
mentionDelegate: BodyRangesTextViewDelegate
|
|
) {
|
|
// The input toolbar should *always* be laid out left-to-right, even when using
|
|
// a right-to-left language. The convention for messaging apps is for the send
|
|
// button to always be to the right of the input field, even in RTL layouts.
|
|
// This means, in most places you'll want to pin deliberately to left/right
|
|
// instead of leading/trailing. You'll also want to the semanticContentAttribute
|
|
// to ensure horizontal stack views layout left-to-right.
|
|
|
|
layoutMargins = .zero
|
|
autoresizingMask = .flexibleHeight
|
|
isUserInteractionEnabled = true
|
|
|
|
// NOTE: Don't set inputTextViewDelegate until configuration is complete.
|
|
inputTextView.mentionDelegate = mentionDelegate
|
|
inputTextView.inputTextViewDelegate = inputTextViewDelegate
|
|
|
|
textViewHeightConstraint = inputTextView.autoSetDimension(.height, toSize: LayoutMetrics.minTextViewHeight)
|
|
|
|
editMessageLabelWrapper.isHidden = !shouldShowEditUI
|
|
|
|
quotedReplyWrapper.isHidden = quotedReplyDraft == nil
|
|
self.quotedReplyDraft = quotedReplyDraft
|
|
|
|
// Vertical stack of message component views in the center: Link Preview, Reply Quote, Text Input View.
|
|
let messageContentVStack = UIStackView(arrangedSubviews: [
|
|
editMessageLabelWrapper,
|
|
quotedReplyWrapper,
|
|
linkPreviewWrapper,
|
|
inputTextView
|
|
])
|
|
messageContentVStack.axis = .vertical
|
|
messageContentVStack.alignment = .fill
|
|
messageContentVStack.setContentHuggingHorizontalLow()
|
|
messageContentVStack.setCompressionResistanceHorizontalLow()
|
|
|
|
// Voice Message UI is added to the same vertical stack, but not as arranged subview.
|
|
// The view is constrained to text input view's edges.
|
|
messageContentVStack.addSubview(voiceMemoContentView)
|
|
voiceMemoContentView.autoPinEdges(toEdgesOf: inputTextView)
|
|
|
|
// Wrap vertical stack into a view with rounded corners.
|
|
let vStackRoundingView = UIView.container()
|
|
vStackRoundingView.backgroundColor = UIColor.Signal.tertiaryFill
|
|
vStackRoundingView.layer.cornerRadius = 18
|
|
vStackRoundingView.clipsToBounds = true
|
|
vStackRoundingView.addSubview(messageContentVStack)
|
|
messageContentVStack.autoPinEdgesToSuperviewEdges()
|
|
messageContentView.addSubview(vStackRoundingView)
|
|
// This margin defines amount of padding above and below visible text input box.
|
|
let textViewVInset = 0.5 * (LayoutMetrics.minToolbarItemHeight - LayoutMetrics.minTextViewHeight)
|
|
vStackRoundingView.autoPinWidthToSuperview()
|
|
vStackRoundingView.autoPinHeightToSuperview(withMargin: textViewVInset)
|
|
|
|
// Sticker button: looks like is a part of the text input view,
|
|
// but is reality it located a couple levels up in the view hierarchy.
|
|
vStackRoundingView.addSubview(stickerButton)
|
|
vStackRoundingView.addSubview(keyboardButton)
|
|
stickerButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 4)
|
|
stickerButton.autoAlignAxis(.horizontal, toSameAxisOf: inputTextView)
|
|
keyboardButton.autoAlignAxis(.vertical, toSameAxisOf: stickerButton)
|
|
keyboardButton.autoAlignAxis(.horizontal, toSameAxisOf: stickerButton)
|
|
|
|
// Horizontal Stack: Attachment button, message components, Camera|VoiceNote|Send button.
|
|
//
|
|
// + Attachment button: pinned to the bottom left corner.
|
|
mainPanelView.addSubview(addOrCancelButton)
|
|
addOrCancelButton.autoPinEdge(toSuperviewMargin: .left)
|
|
addOrCancelButton.autoPinEdge(toSuperviewEdge: .bottom)
|
|
|
|
// Camera | Voice Message | Send: pinned to the bottom right corner.
|
|
mainPanelView.addSubview(rightEdgeControlsView)
|
|
rightEdgeControlsView.autoPinEdge(toSuperviewMargin: .right)
|
|
rightEdgeControlsView.autoPinEdge(toSuperviewEdge: .bottom)
|
|
|
|
// Message components view: pinned to attachment button on the left, Camera button on the right,
|
|
// taking entire superview's height.
|
|
mainPanelView.addSubview(messageContentView)
|
|
messageContentView.autoPinHeightToSuperview()
|
|
messageContentView.autoPinEdge(.right, to: .left, of: rightEdgeControlsView)
|
|
updateMessageContentViewLeftEdgeConstraint(isViewHidden: false)
|
|
|
|
// Put main panel view into a wrapper view that would also contain background view.
|
|
mainPanelWrapperView.addSubview(mainPanelView)
|
|
mainPanelView.autoPinEdgesToSuperviewEdges()
|
|
|
|
// "Suggested Stickers" panel is created as needed and will placed in a wrapper view to allow for slide in / slide out animation.
|
|
suggestedStickerWrapper.clipsToBounds = true
|
|
updateSuggestedStickersViewConstraint()
|
|
|
|
let outerVStack = UIStackView(arrangedSubviews: [ suggestedStickerWrapper, mainPanelWrapperView ] )
|
|
outerVStack.axis = .vertical
|
|
addSubview(outerVStack)
|
|
outerVStack.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
|
|
outerVStack.autoPinEdge(toSuperviewSafeArea: .bottom)
|
|
|
|
// When presenting or dismissing the keyboard, there may be a slight
|
|
// gap between the keyboard and the bottom of the input bar during
|
|
// the animation. Extend the background below the toolbar's bounds
|
|
// by this much to mask that extra space.
|
|
let backgroundExtension: CGFloat = 500
|
|
let extendedBackgroundView = UIView()
|
|
if UIAccessibility.isReduceTransparencyEnabled {
|
|
extendedBackgroundView.backgroundColor = Theme.toolbarBackgroundColor
|
|
} else {
|
|
extendedBackgroundView.backgroundColor = Theme.toolbarBackgroundColor.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
|
|
|
let blurEffectView = UIVisualEffectView(effect: Theme.barBlurEffect)
|
|
// Alter the visual effect view's tint to match our background color
|
|
// so the input bar, when over a solid color background matching `toolbarBackgroundColor`,
|
|
// exactly matches the background color. This is brittle, but there is no way to get
|
|
// this behavior from UIVisualEffectView otherwise.
|
|
if let tintingView = blurEffectView.subviews.first(where: {
|
|
String(describing: type(of: $0)) == "_UIVisualEffectSubview"
|
|
}) {
|
|
tintingView.backgroundColor = extendedBackgroundView.backgroundColor
|
|
}
|
|
extendedBackgroundView.addSubview(blurEffectView)
|
|
blurEffectView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
mainPanelWrapperView.insertSubview(extendedBackgroundView, at: 0)
|
|
extendedBackgroundView.autoPinWidthToSuperview()
|
|
extendedBackgroundView.autoPinEdge(toSuperviewEdge: .top)
|
|
extendedBackgroundView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -backgroundExtension)
|
|
|
|
// See comments on updateLayout(withSafeAreaInsets:).
|
|
messageContentVStack.insetsLayoutMarginsFromSafeArea = false
|
|
messageContentView.insetsLayoutMarginsFromSafeArea = false
|
|
mainPanelWrapperView.insetsLayoutMarginsFromSafeArea = false
|
|
outerVStack.insetsLayoutMarginsFromSafeArea = false
|
|
insetsLayoutMarginsFromSafeArea = false
|
|
|
|
messageContentVStack.preservesSuperviewLayoutMargins = false
|
|
messageContentView.preservesSuperviewLayoutMargins = false
|
|
mainPanelWrapperView.preservesSuperviewLayoutMargins = false
|
|
preservesSuperviewLayoutMargins = false
|
|
|
|
setMessageBody(messageDraft, animated: false, doLayout: false)
|
|
|
|
isConfigurationComplete = true
|
|
}
|
|
|
|
@discardableResult
|
|
class func setView(_ view: UIView, hidden isHidden: Bool, usingAnimator animator: UIViewPropertyAnimator?) -> Bool {
|
|
let viewAlpha: CGFloat = isHidden ? 0 : 1
|
|
|
|
guard viewAlpha != view.alpha else { return false }
|
|
|
|
let viewUpdateBlock = {
|
|
view.alpha = viewAlpha
|
|
view.transform = isHidden ? .scale(0.1) : .identity
|
|
}
|
|
if let animator {
|
|
animator.addAnimations(viewUpdateBlock)
|
|
} else {
|
|
viewUpdateBlock()
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func ensureButtonVisibility(withAnimation isAnimated: Bool, doLayout: Bool) {
|
|
|
|
var hasLayoutChanged = false
|
|
var rightEdgeControlsState = rightEdgeControlsView.state
|
|
|
|
// Voice Memo UI.
|
|
if isShowingVoiceMemoUI {
|
|
voiceMemoContentView.setIsHidden(false, animated: isAnimated)
|
|
|
|
// Send button would be visible if there's voice recording in progress in "locked" state.
|
|
let hideSendButton = voiceMemoRecordingState == .recordingHeld || voiceMemoRecordingState == .idle
|
|
rightEdgeControlsState = hideSendButton ? .hiddenSendButton : .sendButton
|
|
} else {
|
|
voiceMemoContentView.setIsHidden(true, animated: isAnimated)
|
|
|
|
// Show Send button instead of Camera and Voice Message buttons only when text input isn't empty.
|
|
let hasNonWhitespaceTextInput = !inputTextView.trimmedText.isEmpty || shouldShowEditUI
|
|
rightEdgeControlsState = hasNonWhitespaceTextInput ? .sendButton : .default
|
|
}
|
|
|
|
let animator: UIViewPropertyAnimator?
|
|
if isAnimated {
|
|
animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 0.645, springResponse: 0.25)
|
|
} else {
|
|
animator = nil
|
|
}
|
|
|
|
// Attachment Button
|
|
let hideAttachmentButton = isShowingVoiceMemoUI
|
|
if setAddOrCancelButtonHidden(hideAttachmentButton, usingAnimator: animator) {
|
|
hasLayoutChanged = true
|
|
}
|
|
|
|
// Attachment button has more complex animations and cannot be grouped with the rest.
|
|
let addOrCancelButtonAppearance: AddOrCancelButton.Appearance = {
|
|
if shouldShowEditUI {
|
|
return .close
|
|
} else {
|
|
return desiredKeyboardType == .attachment ? .close : .add
|
|
}
|
|
}()
|
|
addOrCancelButton.setAppearance(addOrCancelButtonAppearance, usingAnimator: animator)
|
|
|
|
// Show / hide Sticker or Keyboard buttons inside of the text input field.
|
|
// Either buttons are only visible if there's no any text input, including whitespace-only.
|
|
let hideStickerOrKeyboardButton = shouldShowEditUI || !inputTextView.untrimmedText.isEmpty || isShowingVoiceMemoUI || quotedReplyDraft != nil
|
|
let hideStickerButton = hideStickerOrKeyboardButton || desiredKeyboardType == .sticker
|
|
let hideKeyboardButton = hideStickerOrKeyboardButton || !hideStickerButton
|
|
ConversationInputToolbar.setView(stickerButton, hidden: hideStickerButton, usingAnimator: animator)
|
|
ConversationInputToolbar.setView(keyboardButton, hidden: hideKeyboardButton, usingAnimator: animator)
|
|
|
|
// Hide text input field if Voice Message UI is presented or make it visible otherwise.
|
|
// Do not change "isHidden" because that'll cause inputTextView to lose focus.
|
|
let inputTextViewAlpha: CGFloat = isShowingVoiceMemoUI ? 0 : 1
|
|
if let animator {
|
|
animator.addAnimations {
|
|
self.inputTextView.alpha = inputTextViewAlpha
|
|
}
|
|
} else {
|
|
inputTextView.alpha = inputTextViewAlpha
|
|
}
|
|
|
|
if rightEdgeControlsView.state != rightEdgeControlsState {
|
|
hasLayoutChanged = true
|
|
|
|
if let animator {
|
|
// `state` in implicitly animatable.
|
|
animator.addAnimations {
|
|
self.rightEdgeControlsView.state = rightEdgeControlsState
|
|
}
|
|
} else {
|
|
rightEdgeControlsView.state = rightEdgeControlsState
|
|
}
|
|
}
|
|
|
|
if let animator {
|
|
if doLayout && hasLayoutChanged {
|
|
animator.addAnimations {
|
|
self.mainPanelView.setNeedsLayout()
|
|
self.mainPanelView.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
animator.startAnimation()
|
|
} else {
|
|
if doLayout && hasLayoutChanged {
|
|
self.mainPanelView.setNeedsLayout()
|
|
self.mainPanelView.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
updateSuggestedStickers(animated: isAnimated)
|
|
}
|
|
|
|
private var messageContentViewLeftEdgeConstraint: NSLayoutConstraint?
|
|
|
|
private func updateMessageContentViewLeftEdgeConstraint(isViewHidden: Bool) {
|
|
if let messageContentViewLeftEdgeConstraint {
|
|
removeConstraint(messageContentViewLeftEdgeConstraint)
|
|
}
|
|
let constraint: NSLayoutConstraint
|
|
if isViewHidden {
|
|
constraint = messageContentView.leftAnchor.constraint(
|
|
equalTo: mainPanelView.layoutMarginsGuide.leftAnchor,
|
|
constant: 16
|
|
)
|
|
} else {
|
|
constraint = messageContentView.leftAnchor.constraint(equalTo: addOrCancelButton.rightAnchor)
|
|
}
|
|
addConstraint(constraint)
|
|
messageContentViewLeftEdgeConstraint = constraint
|
|
}
|
|
|
|
private func setAddOrCancelButtonHidden(_ isHidden: Bool, usingAnimator animator: UIViewPropertyAnimator?) -> Bool {
|
|
guard ConversationInputToolbar.setView(addOrCancelButton, hidden: isHidden, usingAnimator: animator) else { return false }
|
|
updateMessageContentViewLeftEdgeConstraint(isViewHidden: isHidden)
|
|
return true
|
|
}
|
|
|
|
func updateLayout(withSafeAreaInsets safeAreaInsets: UIEdgeInsets) -> Bool {
|
|
let insetsChanged = receivedSafeAreaInsets != safeAreaInsets
|
|
let needLayoutConstraints = layoutConstraints == nil
|
|
guard insetsChanged || needLayoutConstraints else {
|
|
return false
|
|
}
|
|
|
|
// iOS doesn't always update the safeAreaInsets correctly & in a timely
|
|
// way for the inputAccessoryView after a orientation change. The best
|
|
// workaround appears to be to use the safeAreaInsets from
|
|
// ConversationViewController's view. ConversationViewController updates
|
|
// this input toolbar using updateLayoutWithIsLandscape:.
|
|
|
|
receivedSafeAreaInsets = safeAreaInsets
|
|
return true
|
|
}
|
|
|
|
func scrollToBottom() {
|
|
inputTextView.scrollToBottom()
|
|
}
|
|
|
|
func updateFontSizes() {
|
|
inputTextView.font = .dynamicTypeBody
|
|
}
|
|
|
|
// MARK: Right Edge Buttons
|
|
|
|
private class RightEdgeControlsView: UIView {
|
|
|
|
enum State {
|
|
case `default`
|
|
case sendButton
|
|
case hiddenSendButton
|
|
}
|
|
private var _state: State = .default
|
|
var state: State {
|
|
get { _state }
|
|
set {
|
|
guard _state != newValue else { return }
|
|
_state = newValue
|
|
configureViewsForState(_state)
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
}
|
|
|
|
static let sendButtonHMargin: CGFloat = 4
|
|
static let cameraButtonHMargin: CGFloat = 8
|
|
|
|
lazy var sendButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.accessibilityLabel = MessageStrings.sendButton
|
|
button.ows_adjustsImageWhenDisabled = true
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "sendButton")
|
|
button.setImage(UIImage(imageLiteralResourceName: "send-blue-28"), for: .normal)
|
|
button.bounds.size = CGSize(width: 48, height: LayoutMetrics.minToolbarItemHeight)
|
|
return button
|
|
}()
|
|
|
|
lazy var cameraButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.tintColor = Theme.primaryIconColor
|
|
button.accessibilityLabel = OWSLocalizedString(
|
|
"CAMERA_BUTTON_LABEL",
|
|
comment: "Accessibility label for camera button."
|
|
)
|
|
button.accessibilityHint = OWSLocalizedString(
|
|
"CAMERA_BUTTON_HINT",
|
|
comment: "Accessibility hint describing what you can do with the camera button"
|
|
)
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cameraButton")
|
|
button.setImage(Theme.iconImage(.buttonCamera), for: .normal)
|
|
button.bounds.size = CGSize(width: 40, height: LayoutMetrics.minToolbarItemHeight)
|
|
return button
|
|
}()
|
|
|
|
lazy var voiceMemoButton: UIButton = {
|
|
let button = UIButton(type: .system)
|
|
button.tintColor = Theme.primaryIconColor
|
|
button.accessibilityLabel = OWSLocalizedString(
|
|
"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL",
|
|
comment: "accessibility label for the button which records voice memos"
|
|
)
|
|
button.accessibilityHint = OWSLocalizedString(
|
|
"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_HINT",
|
|
comment: "accessibility hint for the button which records voice memos"
|
|
)
|
|
button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "voiceMemoButton")
|
|
button.setImage(Theme.iconImage(.buttonMicrophone), for: .normal)
|
|
button.bounds.size = CGSize(width: 40, height: LayoutMetrics.minToolbarItemHeight)
|
|
return button
|
|
}()
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
for button in [ cameraButton, voiceMemoButton, sendButton ] {
|
|
button.setContentHuggingHorizontalHigh()
|
|
button.setCompressionResistanceHorizontalHigh()
|
|
addSubview(button)
|
|
}
|
|
configureViewsForState(state)
|
|
|
|
setContentHuggingHigh()
|
|
setCompressionResistanceHigh()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
sendButton.center = CGPoint(
|
|
x: bounds.maxX - Self.sendButtonHMargin - 0.5 * sendButton.bounds.width,
|
|
y: bounds.midY
|
|
)
|
|
|
|
switch state {
|
|
case .default:
|
|
cameraButton.center = CGPoint(
|
|
x: bounds.minX + Self.cameraButtonHMargin + 0.5 * cameraButton.bounds.width,
|
|
y: bounds.midY
|
|
)
|
|
voiceMemoButton.center = sendButton.center
|
|
|
|
case .sendButton, .hiddenSendButton:
|
|
cameraButton.center = sendButton.center
|
|
voiceMemoButton.center = sendButton.center
|
|
}
|
|
}
|
|
|
|
private func configureViewsForState(_ state: State) {
|
|
switch state {
|
|
case .default:
|
|
cameraButton.transform = .identity
|
|
cameraButton.alpha = 1
|
|
|
|
voiceMemoButton.transform = .identity
|
|
voiceMemoButton.alpha = 1
|
|
|
|
sendButton.transform = .scale(0.1)
|
|
sendButton.alpha = 0
|
|
|
|
case .sendButton, .hiddenSendButton:
|
|
cameraButton.transform = .scale(0.1)
|
|
cameraButton.alpha = 0
|
|
|
|
voiceMemoButton.transform = .scale(0.1)
|
|
voiceMemoButton.alpha = 0
|
|
|
|
sendButton.transform = .identity
|
|
sendButton.alpha = state == .hiddenSendButton ? 0 : 1
|
|
}
|
|
}
|
|
|
|
override var intrinsicContentSize: CGSize {
|
|
let width: CGFloat = {
|
|
switch state {
|
|
case .default: return cameraButton.width + voiceMemoButton.width + 2 * Self.cameraButtonHMargin
|
|
case .sendButton, .hiddenSendButton: return sendButton.width + 2 * Self.sendButtonHMargin
|
|
}
|
|
}()
|
|
return CGSize(width: width, height: LayoutMetrics.minToolbarItemHeight)
|
|
}
|
|
}
|
|
|
|
// MARK: Add/Cancel Button
|
|
|
|
private class AddOrCancelButton: UIButton {
|
|
|
|
private let roundedCornersBackground: UIView = {
|
|
let view = UIView()
|
|
view.backgroundColor = .init(rgbHex: 0x3B3B3B)
|
|
view.clipsToBounds = true
|
|
view.layer.cornerRadius = 14
|
|
view.isUserInteractionEnabled = false
|
|
return view
|
|
}()
|
|
|
|
private let iconImageView = UIImageView(image: UIImage(imageLiteralResourceName: "plus"))
|
|
|
|
private override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
|
|
addSubview(roundedCornersBackground)
|
|
roundedCornersBackground.autoCenterInSuperview()
|
|
roundedCornersBackground.autoSetDimensions(to: CGSize(square: 28))
|
|
updateImageColorAndBackground()
|
|
|
|
addSubview(iconImageView)
|
|
iconImageView.autoCenterInSuperview()
|
|
updateImageTransform()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override var isHighlighted: Bool {
|
|
didSet {
|
|
// When user releases their finger appearance change animations will be fired.
|
|
// We don't want changes performed by this method to interfere with animations.
|
|
guard !isAnimatingAppearance else { return }
|
|
|
|
// Mimic behavior of a standard system button.
|
|
let opacity: CGFloat = isHighlighted ? (Theme.isDarkThemeEnabled ? 0.4 : 0.2) : 1
|
|
switch appearance {
|
|
case .add:
|
|
iconImageView.alpha = opacity
|
|
|
|
case .close:
|
|
roundedCornersBackground.alpha = opacity
|
|
}
|
|
}
|
|
}
|
|
|
|
enum Appearance {
|
|
case add
|
|
case close
|
|
}
|
|
|
|
private var _appearance: Appearance = .add
|
|
private var isAnimatingAppearance = false
|
|
|
|
var appearance: Appearance {
|
|
get { _appearance }
|
|
set { setAppearance(newValue, usingAnimator: nil) }
|
|
}
|
|
|
|
func setAppearance(_ appearance: Appearance, usingAnimator animator: UIViewPropertyAnimator?) {
|
|
guard appearance != _appearance else { return }
|
|
|
|
_appearance = appearance
|
|
|
|
guard let animator else {
|
|
updateImageColorAndBackground()
|
|
updateImageTransform()
|
|
return
|
|
}
|
|
|
|
isAnimatingAppearance = true
|
|
animator.addAnimations({
|
|
self.updateImageColorAndBackground()
|
|
},
|
|
delayFactor: appearance == .add ? 0 : 0.2
|
|
)
|
|
animator.addAnimations {
|
|
self.updateImageTransform()
|
|
}
|
|
animator.addCompletion { _ in
|
|
self.isAnimatingAppearance = false
|
|
}
|
|
}
|
|
|
|
private func updateImageColorAndBackground() {
|
|
switch appearance {
|
|
case .add:
|
|
iconImageView.alpha = 1
|
|
iconImageView.tintColor = Theme.primaryIconColor
|
|
roundedCornersBackground.alpha = 0
|
|
roundedCornersBackground.transform = .scale(0.05)
|
|
|
|
case .close:
|
|
iconImageView.alpha = 1
|
|
iconImageView.tintColor = .white
|
|
roundedCornersBackground.alpha = 1
|
|
roundedCornersBackground.transform = .identity
|
|
}
|
|
}
|
|
|
|
private func updateImageTransform() {
|
|
switch appearance {
|
|
case .add:
|
|
iconImageView.transform = .identity
|
|
|
|
case .close:
|
|
iconImageView.transform = .rotate(1.5 * .halfPi)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Message Body
|
|
|
|
var hasUnsavedDraft: Bool {
|
|
let currentDraft = messageBodyForSending ?? .empty
|
|
|
|
if let editTarget {
|
|
let editTargetMessage = MessageBody(
|
|
text: editTarget.body ?? "",
|
|
ranges: editTarget.bodyRanges ?? .empty
|
|
)
|
|
|
|
return currentDraft != editTargetMessage
|
|
}
|
|
|
|
return !currentDraft.isEmpty
|
|
}
|
|
|
|
var messageBodyForSending: MessageBody? { inputTextView.messageBodyForSending }
|
|
|
|
func setMessageBody(_ messageBody: MessageBody?, animated: Bool, doLayout: Bool = true) {
|
|
inputTextView.setMessageBody(messageBody, txProvider: DependenciesBridge.shared.db.readTxProvider)
|
|
|
|
// It's important that we set the textViewHeight before
|
|
// doing any animation in `ensureButtonVisibility(withAnimation:doLayout)`
|
|
// Otherwise, the resultant keyboard frame posted in `keyboardWillChangeFrame`
|
|
// could reflect the inputTextView height *before* the new text was set.
|
|
//
|
|
// This bug was surfaced to the user as:
|
|
// - have a quoted reply draft in the input toolbar
|
|
// - type a multiline message
|
|
// - hit send
|
|
// - quoted reply preview and message text is cleared
|
|
// - input toolbar is shrunk to it's expected empty-text height
|
|
// - *but* the conversation's bottom content inset was too large. Specifically, it was
|
|
// still sized as if the input textview was multiple lines.
|
|
// Presumably this bug only surfaced when an animation coincides with more complicated layout
|
|
// changes (in this case while simultaneous with removing quoted reply subviews, hiding the
|
|
// wrapper view *and* changing the height of the input textView
|
|
ensureTextViewHeight()
|
|
updateInputLinkPreview()
|
|
|
|
if let text = messageBody?.text, !text.isEmpty {
|
|
clearDesiredKeyboard()
|
|
}
|
|
|
|
ensureButtonVisibility(withAnimation: animated, doLayout: doLayout)
|
|
}
|
|
|
|
func ensureTextViewHeight() {
|
|
updateHeightWithTextView(inputTextView)
|
|
}
|
|
|
|
func acceptAutocorrectSuggestion() {
|
|
inputTextView.acceptAutocorrectSuggestion()
|
|
}
|
|
|
|
func clearTextMessage(animated: Bool) {
|
|
editTarget = nil
|
|
setMessageBody(nil, animated: animated)
|
|
inputTextView.undoManager?.removeAllActions()
|
|
}
|
|
|
|
// MARK: Edit Message
|
|
|
|
var shouldShowEditUI: Bool { editTarget != nil }
|
|
|
|
var editTarget: TSOutgoingMessage? {
|
|
didSet {
|
|
let animateChanges = window != nil
|
|
|
|
// Show the 'editing' tag
|
|
if let editTarget = editTarget {
|
|
|
|
// Fetch the original text (including any oversized text attachments)
|
|
let componentState = SSKEnvironment.shared.databaseStorageRef.read { tx in
|
|
CVLoader.buildStandaloneComponentState(
|
|
interaction: editTarget,
|
|
spoilerState: SpoilerRenderState(),
|
|
transaction: tx)
|
|
}
|
|
|
|
let messageBody: MessageBody
|
|
let ranges = editTarget.bodyRanges ?? .empty
|
|
switch componentState?.bodyText?.displayableText?.fullTextValue {
|
|
case .attributedText(let string):
|
|
messageBody = MessageBody(text: string.string, ranges: ranges)
|
|
case .messageBody(let body):
|
|
messageBody = body.asMessageBodyForForwarding(preservingAllMentions: true)
|
|
case .text(let text):
|
|
messageBody = MessageBody(text: text, ranges: ranges)
|
|
case .none:
|
|
messageBody = MessageBody(text: "", ranges: .empty)
|
|
}
|
|
self.setMessageBody(messageBody, animated: true)
|
|
|
|
showEditMessageView(animated: animateChanges)
|
|
} else if oldValue != nil {
|
|
editThumbnail = nil
|
|
self.setMessageBody(nil, animated: true)
|
|
hideEditMessageView(animated: animateChanges)
|
|
}
|
|
}
|
|
}
|
|
|
|
var editThumbnail: UIImage? {
|
|
get { editMessageThumbnailView.image }
|
|
set { editMessageThumbnailView.image = newValue }
|
|
}
|
|
|
|
private func showEditMessageView(animated: Bool) {
|
|
toggleMessageComponentVisibility(hide: false, component: editMessageLabelWrapper, animated: animated)
|
|
rightEdgeControlsView.sendButton.isEnabled = false
|
|
}
|
|
|
|
private func hideEditMessageView(animated: Bool) {
|
|
owsAssertDebug(editTarget == nil)
|
|
toggleMessageComponentVisibility(hide: true, component: editMessageLabelWrapper, animated: animated)
|
|
rightEdgeControlsView.sendButton.isEnabled = true
|
|
}
|
|
|
|
// MARK: Quoted Reply
|
|
|
|
var quotedReplyDraft: DraftQuotedReplyModel? {
|
|
didSet {
|
|
guard oldValue != quotedReplyDraft else { return }
|
|
|
|
layer.removeAllAnimations()
|
|
|
|
let animateChanges = window != nil
|
|
if quotedReplyDraft != nil {
|
|
showQuotedReplyView(animated: animateChanges)
|
|
} else {
|
|
hideQuotedReplyView(animated: animateChanges)
|
|
}
|
|
// This would show / hide Stickers|Keyboard button.
|
|
ensureButtonVisibility(withAnimation: true, doLayout: false)
|
|
clearDesiredKeyboard()
|
|
}
|
|
}
|
|
|
|
private func showQuotedReplyView(animated: Bool) {
|
|
guard let quotedReplyDraft else {
|
|
owsFailDebug("quotedReply == nil")
|
|
return
|
|
}
|
|
|
|
let quotedMessagePreview = QuotedReplyPreview(
|
|
quotedReplyDraft: quotedReplyDraft,
|
|
conversationStyle: conversationStyle,
|
|
spoilerState: spoilerState
|
|
)
|
|
quotedMessagePreview.delegate = self
|
|
quotedMessagePreview.setContentHuggingHorizontalLow()
|
|
quotedMessagePreview.setCompressionResistanceHorizontalLow()
|
|
quotedMessagePreview.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedMessagePreview")
|
|
quotedReplyWrapper.addSubview(quotedMessagePreview)
|
|
quotedMessagePreview.autoPinEdgesToSuperviewEdges()
|
|
|
|
updateInputLinkPreview()
|
|
|
|
toggleMessageComponentVisibility(hide: false, component: quotedReplyWrapper, animated: animated)
|
|
}
|
|
|
|
private func hideQuotedReplyView(animated: Bool) {
|
|
owsAssertDebug(quotedReplyDraft == nil)
|
|
toggleMessageComponentVisibility(hide: true, component: quotedReplyWrapper, animated: animated) { _ in
|
|
self.quotedReplyWrapper.removeAllSubviews()
|
|
}
|
|
}
|
|
|
|
private func toggleMessageComponentVisibility(
|
|
hide: Bool,
|
|
component: UIView,
|
|
animated: Bool,
|
|
completion: ((Bool) -> Void)? = nil
|
|
) {
|
|
if animated, component.isHidden != hide {
|
|
isAnimatingHeightChange = true
|
|
|
|
UIView.animate(
|
|
withDuration: ConversationInputToolbar.heightChangeAnimationDuration,
|
|
animations: {
|
|
component.isHidden = hide
|
|
},
|
|
completion: { completed in
|
|
self.isAnimatingHeightChange = false
|
|
completion?(completed)
|
|
}
|
|
)
|
|
} else {
|
|
component.isHidden = hide
|
|
completion?(true)
|
|
}
|
|
}
|
|
|
|
var draftReply: ThreadReplyInfo? {
|
|
guard let quotedReplyDraft else { return nil }
|
|
guard
|
|
let originalMessageTimestamp = quotedReplyDraft.originalMessageTimestamp,
|
|
let aci = quotedReplyDraft.originalMessageAuthorAddress.aci
|
|
else {
|
|
return nil
|
|
}
|
|
return ThreadReplyInfo(timestamp: originalMessageTimestamp, author: aci)
|
|
}
|
|
|
|
func quotedReplyPreviewDidPressCancel(_ preview: QuotedReplyPreview) {
|
|
quotedReplyDraft = nil
|
|
}
|
|
|
|
// MARK: Link Preview
|
|
|
|
private let linkPreviewFetchState: LinkPreviewFetchState
|
|
|
|
private var linkPreviewView: LinkPreviewView?
|
|
|
|
private var isLinkPreviewHidden = true
|
|
|
|
private var linkPreviewConstraint: NSLayoutConstraint?
|
|
|
|
private func updateLinkPreviewConstraint() {
|
|
guard let linkPreviewView else {
|
|
owsFailDebug("linkPreviewView == nil")
|
|
return
|
|
}
|
|
if let linkPreviewConstraint {
|
|
removeConstraint(linkPreviewConstraint)
|
|
}
|
|
// To hide link preview I constrain both top and bottom edges of the linkPreviewWrapper
|
|
// to top edge of linkPreviewView, effectively making linkPreviewWrapper a zero height view.
|
|
// But since linkPreviewView keeps it size animating this change results in a nice slide in/out animation.
|
|
// To make link preview visible I constrain linkPreviewView to linkPreviewWrapper normally.
|
|
let linkPreviewContstraint: NSLayoutConstraint
|
|
if isLinkPreviewHidden {
|
|
linkPreviewContstraint = linkPreviewWrapper.bottomAnchor.constraint(equalTo: linkPreviewView.topAnchor)
|
|
} else {
|
|
linkPreviewContstraint = linkPreviewWrapper.bottomAnchor.constraint(equalTo: linkPreviewView.bottomAnchor)
|
|
}
|
|
addConstraint(linkPreviewContstraint)
|
|
self.linkPreviewConstraint = linkPreviewContstraint
|
|
}
|
|
|
|
var linkPreviewDraft: OWSLinkPreviewDraft? {
|
|
AssertIsOnMainThread()
|
|
|
|
return linkPreviewFetchState.linkPreviewDraftIfLoaded
|
|
}
|
|
|
|
private func updateInputLinkPreview() {
|
|
AssertIsOnMainThread()
|
|
|
|
let messageBody = messageBodyForSending
|
|
?? .init(text: "", ranges: .empty)
|
|
linkPreviewFetchState.update(messageBody, enableIfEmpty: true)
|
|
}
|
|
|
|
private func updateLinkPreviewView() {
|
|
let animateChanges = window != nil
|
|
|
|
switch linkPreviewFetchState.currentState {
|
|
case .none, .failed:
|
|
hideLinkPreviewView(animated: animateChanges)
|
|
case .loading:
|
|
ensureLinkPreviewView(withState: LinkPreviewLoading(linkType: .preview))
|
|
case .loaded(let linkPreviewDraft):
|
|
let state: LinkPreviewState
|
|
if let callLink = CallLink(url: linkPreviewDraft.url) {
|
|
state = LinkPreviewCallLink(previewType: .draft(linkPreviewDraft), callLink: callLink)
|
|
} else {
|
|
state = LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)
|
|
}
|
|
ensureLinkPreviewView(withState: state)
|
|
}
|
|
}
|
|
|
|
private func ensureLinkPreviewView(withState state: LinkPreviewState) {
|
|
AssertIsOnMainThread()
|
|
|
|
let linkPreviewView: LinkPreviewView
|
|
if let existingLinkPreviewView = self.linkPreviewView {
|
|
linkPreviewView = existingLinkPreviewView
|
|
} else {
|
|
linkPreviewView = LinkPreviewView(draftDelegate: self)
|
|
linkPreviewWrapper.addSubview(linkPreviewView)
|
|
// See comment in `updateLinkPreviewConstraint` why `bottom` isn't constrained.
|
|
linkPreviewView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
|
|
self.linkPreviewView = linkPreviewView
|
|
|
|
updateLinkPreviewConstraint()
|
|
}
|
|
|
|
linkPreviewView.configureForNonCVC(
|
|
state: state,
|
|
isDraft: true,
|
|
hasAsymmetricalRounding: quotedReplyDraft == nil
|
|
)
|
|
UIView.performWithoutAnimation {
|
|
self.mainPanelView.layoutIfNeeded()
|
|
}
|
|
|
|
guard isLinkPreviewHidden else {
|
|
return
|
|
}
|
|
|
|
isLinkPreviewHidden = false
|
|
|
|
let animateChanges = window != nil
|
|
guard animateChanges else {
|
|
updateLinkPreviewConstraint()
|
|
layoutIfNeeded()
|
|
return
|
|
}
|
|
|
|
isAnimatingHeightChange = true
|
|
let animator = UIViewPropertyAnimator(
|
|
duration: ConversationInputToolbar.heightChangeAnimationDuration,
|
|
springDamping: 0.9,
|
|
springResponse: 0.3
|
|
)
|
|
animator.addAnimations {
|
|
self.updateLinkPreviewConstraint()
|
|
self.layoutIfNeeded()
|
|
}
|
|
animator.addCompletion { _ in
|
|
self.isAnimatingHeightChange = false
|
|
}
|
|
animator.startAnimation()
|
|
}
|
|
|
|
private func hideLinkPreviewView(animated: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard !isLinkPreviewHidden else { return }
|
|
|
|
isLinkPreviewHidden = true
|
|
|
|
guard animated else {
|
|
updateLinkPreviewConstraint()
|
|
layoutIfNeeded()
|
|
return
|
|
}
|
|
|
|
isAnimatingHeightChange = true
|
|
let animator = UIViewPropertyAnimator(
|
|
duration: ConversationInputToolbar.heightChangeAnimationDuration,
|
|
springDamping: 0.9,
|
|
springResponse: 0.3
|
|
)
|
|
animator.addAnimations {
|
|
self.updateLinkPreviewConstraint()
|
|
self.layoutIfNeeded()
|
|
}
|
|
animator.addCompletion { _ in
|
|
self.isAnimatingHeightChange = false
|
|
}
|
|
animator.startAnimation()
|
|
}
|
|
|
|
// MARK: LinkPreviewViewDraftDelegate
|
|
|
|
public func linkPreviewDidCancel() {
|
|
AssertIsOnMainThread()
|
|
|
|
linkPreviewFetchState.disable()
|
|
}
|
|
|
|
// MARK: Stickers
|
|
|
|
private let suggestedStickerViewCache = StickerViewCache(maxSize: 12)
|
|
|
|
private var suggestedStickerEmoji: Character?
|
|
|
|
private var suggestedStickerInfos: [StickerInfo] = []
|
|
|
|
private var suggestedStickersViewConstraint: NSLayoutConstraint?
|
|
|
|
private func updateSuggestedStickersViewConstraint() {
|
|
if let suggestedStickersViewConstraint {
|
|
removeConstraint(suggestedStickersViewConstraint)
|
|
}
|
|
|
|
// suggestedStickerView is created lazily and isn't accessed until it is needed.
|
|
// Set wrapper's height to zero if it is not needed yet.
|
|
guard suggestedStickerWrapper.subviews.count > 0 else {
|
|
let zeroHeightConstraint = suggestedStickerWrapper.heightAnchor.constraint(equalToConstant: 0)
|
|
addConstraint(zeroHeightConstraint)
|
|
suggestedStickersViewConstraint = zeroHeightConstraint
|
|
return
|
|
}
|
|
|
|
// To hide suggested stickers panel I constrain both top and bottom edges of the `suggestedStickerView`
|
|
// to the top edge of its wrapper view, effectively making that wrapper view a zero height view.
|
|
// `suggestedStickerView` has a fixed height so animating this change results in a nice slide in/out animation.
|
|
// `suggestedStickerView` is made visible by constraining all of its edges to wrapper view normally.
|
|
let constraint: NSLayoutConstraint
|
|
if isSuggestedStickersViewHidden {
|
|
constraint = suggestedStickerWrapper.bottomAnchor.constraint(equalTo: suggestedStickerView.topAnchor)
|
|
} else {
|
|
constraint = suggestedStickerWrapper.bottomAnchor.constraint(equalTo: suggestedStickerView.bottomAnchor)
|
|
}
|
|
addConstraint(constraint)
|
|
suggestedStickersViewConstraint = constraint
|
|
}
|
|
|
|
private var isSuggestedStickersViewHidden = true
|
|
|
|
private func updateSuggestedStickers(animated: Bool) {
|
|
let suggestedStickerEmoji = StickerManager.suggestedStickerEmoji(chatBoxText: inputTextView.trimmedText)
|
|
|
|
if self.suggestedStickerEmoji == suggestedStickerEmoji {
|
|
return
|
|
}
|
|
self.suggestedStickerEmoji = suggestedStickerEmoji
|
|
|
|
let suggestedStickerInfos: [StickerInfo]
|
|
if let suggestedStickerEmoji {
|
|
suggestedStickerInfos = SSKEnvironment.shared.databaseStorageRef.read { tx in
|
|
return StickerManager.suggestedStickers(for: suggestedStickerEmoji, tx: tx).map { $0.info }
|
|
}
|
|
} else {
|
|
suggestedStickerInfos = []
|
|
}
|
|
|
|
if self.suggestedStickerInfos == suggestedStickerInfos {
|
|
return
|
|
}
|
|
self.suggestedStickerInfos = suggestedStickerInfos
|
|
|
|
guard !suggestedStickerInfos.isEmpty else {
|
|
hideSuggestedStickersView(animated: animated)
|
|
return
|
|
}
|
|
|
|
showSuggestedStickersView(animated: animated)
|
|
}
|
|
|
|
private func showSuggestedStickersView(animated: Bool) {
|
|
owsAssertDebug(!suggestedStickerInfos.isEmpty)
|
|
|
|
if suggestedStickerView.superview == nil {
|
|
suggestedStickerWrapper.addSubview(suggestedStickerView)
|
|
suggestedStickerView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
|
|
UIView.performWithoutAnimation {
|
|
suggestedStickerWrapper.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
suggestedStickerView.items = suggestedStickerInfos.map { stickerInfo in
|
|
StickerHorizontalListViewItemSticker(
|
|
stickerInfo: stickerInfo,
|
|
didSelectBlock: { [weak self] in
|
|
self?.didSelectSuggestedSticker(stickerInfo)
|
|
},
|
|
cache: suggestedStickerViewCache
|
|
)
|
|
}
|
|
|
|
guard isSuggestedStickersViewHidden else { return }
|
|
|
|
isSuggestedStickersViewHidden = false
|
|
|
|
UIView.performWithoutAnimation {
|
|
self.suggestedStickerView.layoutIfNeeded()
|
|
self.suggestedStickerView.contentOffset = CGPoint(
|
|
x: -self.suggestedStickerView.contentInset.left,
|
|
y: -self.suggestedStickerView.contentInset.top
|
|
)
|
|
}
|
|
|
|
guard animated else {
|
|
updateSuggestedStickersViewConstraint()
|
|
return
|
|
}
|
|
|
|
isAnimatingHeightChange = true
|
|
let animator = UIViewPropertyAnimator(
|
|
duration: ConversationInputToolbar.heightChangeAnimationDuration,
|
|
springDamping: 0.9,
|
|
springResponse: 0.3
|
|
)
|
|
animator.addAnimations {
|
|
self.updateSuggestedStickersViewConstraint()
|
|
self.layoutIfNeeded()
|
|
}
|
|
animator.addCompletion { _ in
|
|
self.isAnimatingHeightChange = false
|
|
}
|
|
animator.startAnimation()
|
|
}
|
|
|
|
private func hideSuggestedStickersView(animated: Bool) {
|
|
guard !isSuggestedStickersViewHidden else { return }
|
|
|
|
isSuggestedStickersViewHidden = true
|
|
|
|
guard animated else {
|
|
updateSuggestedStickersViewConstraint()
|
|
return
|
|
}
|
|
|
|
isAnimatingHeightChange = true
|
|
let animator = UIViewPropertyAnimator(
|
|
duration: ConversationInputToolbar.heightChangeAnimationDuration,
|
|
springDamping: 0.9,
|
|
springResponse: 0.3
|
|
)
|
|
animator.addAnimations {
|
|
self.updateSuggestedStickersViewConstraint()
|
|
self.layoutIfNeeded()
|
|
}
|
|
animator.addCompletion { _ in
|
|
self.isAnimatingHeightChange = false
|
|
}
|
|
animator.startAnimation()
|
|
}
|
|
|
|
private func didSelectSuggestedSticker(_ stickerInfo: StickerInfo) {
|
|
AssertIsOnMainThread()
|
|
|
|
clearTextMessage(animated: true)
|
|
inputToolbarDelegate?.sendSticker(stickerInfo)
|
|
}
|
|
|
|
// MARK: Voice Memo
|
|
|
|
private enum VoiceMemoRecordingState {
|
|
case idle
|
|
case recordingHeld
|
|
case recordingLocked
|
|
case draft
|
|
}
|
|
|
|
private var voiceMemoRecordingState: VoiceMemoRecordingState = .idle {
|
|
didSet {
|
|
guard oldValue != voiceMemoRecordingState else { return }
|
|
ensureButtonVisibility(withAnimation: true, doLayout: true)
|
|
}
|
|
}
|
|
private var voiceMemoGestureStartLocation: CGPoint?
|
|
|
|
private var isShowingVoiceMemoUI: Bool = false {
|
|
didSet {
|
|
guard isShowingVoiceMemoUI != oldValue else { return }
|
|
ensureButtonVisibility(withAnimation: true, doLayout: true)
|
|
}
|
|
}
|
|
|
|
var voiceMemoDraft: VoiceMessageInterruptedDraft?
|
|
private var voiceMemoStartTime: Date?
|
|
private var voiceMemoUpdateTimer: Timer?
|
|
private var voiceMemoTooltipView: UIView?
|
|
private var voiceMemoRecordingLabel: UILabel?
|
|
private var voiceMemoCancelLabel: UILabel?
|
|
private var voiceMemoRedRecordingCircle: UIView?
|
|
private var voiceMemoLockView: VoiceMemoLockView?
|
|
|
|
func showVoiceMemoUI() {
|
|
AssertIsOnMainThread()
|
|
|
|
isShowingVoiceMemoUI = true
|
|
|
|
removeVoiceMemoTooltip()
|
|
|
|
voiceMemoStartTime = Date()
|
|
|
|
voiceMemoRedRecordingCircle?.removeFromSuperview()
|
|
voiceMemoLockView?.removeFromSuperview()
|
|
|
|
voiceMemoContentView.removeAllSubviews()
|
|
|
|
let recordingLabel = UILabel()
|
|
recordingLabel.textAlignment = .left
|
|
recordingLabel.textColor = Theme.primaryTextColor
|
|
recordingLabel.font = .dynamicTypeBodyClamped.monospaced().medium()
|
|
recordingLabel.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "recordingLabel")
|
|
voiceMemoContentView.addSubview(recordingLabel)
|
|
self.voiceMemoRecordingLabel = recordingLabel
|
|
|
|
updateVoiceMemo()
|
|
|
|
let cancelArrowFontSize = CGFloat.scaleFromIPhone5To7Plus(18.4, 20)
|
|
let cancelString = NSMutableAttributedString(
|
|
string: "\u{F104}",
|
|
attributes: [
|
|
.font: UIFont.awesomeFont(ofSize: cancelArrowFontSize),
|
|
.foregroundColor: Theme.secondaryTextAndIconColor,
|
|
.baselineOffset: -1
|
|
]
|
|
)
|
|
cancelString.append(
|
|
NSAttributedString(
|
|
string: " ",
|
|
attributes: [
|
|
.font: UIFont.awesomeFont(ofSize: cancelArrowFontSize),
|
|
.foregroundColor: Theme.secondaryTextAndIconColor,
|
|
.baselineOffset: -1
|
|
]
|
|
)
|
|
)
|
|
cancelString.append(
|
|
NSAttributedString(
|
|
string: OWSLocalizedString("VOICE_MESSAGE_CANCEL_INSTRUCTIONS", comment: "Indicates how to cancel a voice message."),
|
|
attributes: [
|
|
.font: UIFont.dynamicTypeSubheadlineClamped,
|
|
.foregroundColor: Theme.secondaryTextAndIconColor
|
|
]
|
|
)
|
|
)
|
|
let cancelLabel = UILabel()
|
|
cancelLabel.textAlignment = .right
|
|
cancelLabel.attributedText = cancelString
|
|
voiceMemoContentView.addSubview(cancelLabel)
|
|
self.voiceMemoCancelLabel = cancelLabel
|
|
|
|
let redCircleView = CircleView(diameter: 80)
|
|
redCircleView.backgroundColor = .ows_accentRed
|
|
let whiteIconView = UIImageView(image: UIImage(imageLiteralResourceName: "mic-fill"))
|
|
whiteIconView.tintColor = .white
|
|
whiteIconView.autoSetDimensions(to: .square(36))
|
|
redCircleView.addSubview(whiteIconView)
|
|
whiteIconView.autoCenterInSuperview()
|
|
addSubview(redCircleView)
|
|
redCircleView.autoAlignAxis(.horizontal, toSameAxisOf: voiceMemoContentView)
|
|
redCircleView.autoPinEdge(toSuperviewEdge: .right, withInset: 12)
|
|
self.voiceMemoRedRecordingCircle = redCircleView
|
|
|
|
let imageView = UIImageView(image: UIImage(imageLiteralResourceName: "mic-fill"))
|
|
imageView.tintColor = .ows_accentRed
|
|
imageView.autoSetDimensions(to: .square(24))
|
|
voiceMemoContentView.addSubview(imageView)
|
|
imageView.autoVCenterInSuperview()
|
|
imageView.autoPinEdge(toSuperviewEdge: .left, withInset: 12)
|
|
|
|
recordingLabel.autoVCenterInSuperview()
|
|
recordingLabel.autoPinEdge(.left, to: .right, of: imageView, withOffset: 8)
|
|
|
|
cancelLabel.autoVCenterInSuperview()
|
|
cancelLabel.autoPinEdge(toSuperviewEdge: .right, withInset: 72)
|
|
cancelLabel.autoPinEdge(.left, to: .right, of: recordingLabel)
|
|
|
|
let voiceMemoLockView = VoiceMemoLockView()
|
|
insertSubview(voiceMemoLockView, belowSubview: redCircleView)
|
|
voiceMemoLockView.autoAlignAxis(.vertical, toSameAxisOf: redCircleView)
|
|
voiceMemoLockView.autoPinEdge(.bottom, to: .top, of: redCircleView)
|
|
voiceMemoLockView.setCompressionResistanceHigh()
|
|
self.voiceMemoLockView = voiceMemoLockView
|
|
|
|
voiceMemoLockView.transform = CGAffineTransform.scale(0)
|
|
voiceMemoLockView.layoutIfNeeded()
|
|
UIView.animate(withDuration: 0.2, delay: 1) {
|
|
voiceMemoLockView.transform = .identity
|
|
}
|
|
|
|
redCircleView.transform = CGAffineTransform.scale(0)
|
|
UIView.animate(withDuration: 0.2) {
|
|
redCircleView.transform = .identity
|
|
}
|
|
|
|
// Pulse the icon.
|
|
imageView.alpha = 1
|
|
UIView.animate(
|
|
withDuration: 0.5,
|
|
delay: 0.2,
|
|
options: [.repeat, .autoreverse, .curveEaseIn],
|
|
animations: {
|
|
imageView.alpha = 0
|
|
}
|
|
)
|
|
|
|
voiceMemoUpdateTimer?.invalidate()
|
|
voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
|
|
guard let self else {
|
|
timer.invalidate()
|
|
return
|
|
}
|
|
self.updateVoiceMemo()
|
|
}
|
|
}
|
|
|
|
func showVoiceMemoDraft(_ voiceMemoDraft: VoiceMessageInterruptedDraft) {
|
|
AssertIsOnMainThread()
|
|
|
|
isShowingVoiceMemoUI = true
|
|
|
|
self.voiceMemoDraft = voiceMemoDraft
|
|
voiceMemoRecordingState = .draft
|
|
|
|
removeVoiceMemoTooltip()
|
|
|
|
voiceMemoRedRecordingCircle?.removeFromSuperview()
|
|
voiceMemoLockView?.removeFromSuperview()
|
|
|
|
voiceMemoContentView.removeAllSubviews()
|
|
|
|
voiceMemoUpdateTimer?.invalidate()
|
|
voiceMemoUpdateTimer = nil
|
|
|
|
let draftView = VoiceMessageDraftView(
|
|
voiceMessageInterruptedDraft: voiceMemoDraft,
|
|
mediaCache: mediaCache,
|
|
deleteAction: { [weak self] in
|
|
SSKEnvironment.shared.databaseStorageRef.asyncWrite {
|
|
voiceMemoDraft.clearDraft(transaction: $0)
|
|
} completion: {
|
|
self?.hideVoiceMemoUI(animated: true)
|
|
}
|
|
}
|
|
)
|
|
voiceMemoContentView.addSubview(draftView)
|
|
draftView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
|
|
func hideVoiceMemoUI(animated: Bool) {
|
|
AssertIsOnMainThread()
|
|
|
|
isShowingVoiceMemoUI = false
|
|
|
|
voiceMemoContentView.removeAllSubviews()
|
|
|
|
voiceMemoRecordingState = .idle
|
|
voiceMemoDraft = nil
|
|
|
|
let oldVoiceMemoRedRecordingCircle = voiceMemoRedRecordingCircle
|
|
let oldVoiceMemoLockView = voiceMemoLockView
|
|
|
|
voiceMemoCancelLabel = nil
|
|
voiceMemoRedRecordingCircle = nil
|
|
voiceMemoLockView = nil
|
|
voiceMemoRecordingLabel = nil
|
|
|
|
voiceMemoUpdateTimer?.invalidate()
|
|
voiceMemoUpdateTimer = nil
|
|
|
|
voiceMemoDraft = nil
|
|
|
|
if animated {
|
|
UIView.animate(
|
|
withDuration: 0.2,
|
|
animations: {
|
|
oldVoiceMemoRedRecordingCircle?.alpha = 0
|
|
oldVoiceMemoLockView?.alpha = 0
|
|
},
|
|
completion: { _ in
|
|
oldVoiceMemoRedRecordingCircle?.removeFromSuperview()
|
|
oldVoiceMemoLockView?.removeFromSuperview()
|
|
}
|
|
)
|
|
} else {
|
|
oldVoiceMemoRedRecordingCircle?.removeFromSuperview()
|
|
oldVoiceMemoLockView?.removeFromSuperview()
|
|
}
|
|
}
|
|
|
|
func lockVoiceMemoUI() {
|
|
guard let voiceMemoRecordingLabel = voiceMemoRecordingLabel else {
|
|
owsFailDebug("voiceMemoRecordingLabel == nil")
|
|
return
|
|
}
|
|
|
|
ImpactHapticFeedback.impactOccurred(style: .medium)
|
|
|
|
let cancelButton = OWSButton(block: { [weak self] in
|
|
self?.inputToolbarDelegate?.voiceMemoGestureDidCancel()
|
|
})
|
|
cancelButton.alpha = 0
|
|
cancelButton.setTitle(CommonStrings.cancelButton, for: .normal)
|
|
cancelButton.setTitleColor(.ows_accentRed, for: .normal)
|
|
cancelButton.setTitleColor(.ows_accentRed.withAlphaComponent(0.4), for: .highlighted)
|
|
cancelButton.titleLabel?.textAlignment = .right
|
|
cancelButton.titleLabel?.font = .dynamicTypeBodyClamped.medium()
|
|
cancelButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cancelButton")
|
|
voiceMemoContentView.addSubview(cancelButton)
|
|
|
|
voiceMemoRecordingLabel.setContentHuggingHigh()
|
|
|
|
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
|
cancelButton.autoHCenterInSuperview()
|
|
}
|
|
cancelButton.autoPinEdge(toSuperviewMargin: .right, withInset: 40)
|
|
cancelButton.autoPinEdge(.left, to: .right, of: voiceMemoRecordingLabel, withOffset: 4, relation: .greaterThanOrEqual)
|
|
cancelButton.autoVCenterInSuperview()
|
|
|
|
voiceMemoCancelLabel?.removeFromSuperview()
|
|
voiceMemoContentView.layoutIfNeeded()
|
|
UIView.animate(
|
|
withDuration: 0.2,
|
|
animations: {
|
|
self.voiceMemoRedRecordingCircle?.alpha = 0
|
|
self.voiceMemoLockView?.alpha = 0
|
|
cancelButton.alpha = 1
|
|
},
|
|
completion: { _ in
|
|
self.voiceMemoRedRecordingCircle?.removeFromSuperview()
|
|
self.voiceMemoLockView?.removeFromSuperview()
|
|
UIAccessibility.post(notification: .layoutChanged, argument: nil)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func setVoiceMemoUICancelAlpha(_ cancelAlpha: CGFloat) {
|
|
AssertIsOnMainThread()
|
|
|
|
// Fade out the voice message views as the cancel gesture
|
|
// proceeds as feedback.
|
|
voiceMemoCancelLabel?.alpha = CGFloat.clamp01(1 - cancelAlpha)
|
|
}
|
|
|
|
private func updateVoiceMemo() {
|
|
AssertIsOnMainThread()
|
|
|
|
guard
|
|
let voiceMemoStartTime = voiceMemoStartTime,
|
|
let voiceMemoRecordingLabel = voiceMemoRecordingLabel
|
|
else {
|
|
return
|
|
}
|
|
|
|
let durationSeconds = abs(voiceMemoStartTime.timeIntervalSinceNow)
|
|
voiceMemoRecordingLabel.text = OWSFormat.formatDurationSeconds(Int(round(durationSeconds)))
|
|
voiceMemoRecordingLabel.sizeToFit()
|
|
}
|
|
|
|
func showVoiceMemoTooltip() {
|
|
guard voiceMemoTooltipView == nil else { return }
|
|
|
|
let tooltipView = VoiceMessageTooltip(
|
|
fromView: self,
|
|
widthReferenceView: self,
|
|
tailReferenceView: rightEdgeControlsView.voiceMemoButton) { [weak self] in
|
|
self?.removeVoiceMemoTooltip()
|
|
}
|
|
voiceMemoTooltipView = tooltipView
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
|
self?.removeVoiceMemoTooltip()
|
|
}
|
|
}
|
|
|
|
private func removeVoiceMemoTooltip() {
|
|
guard let voiceMemoTooltipView = voiceMemoTooltipView else { return }
|
|
|
|
self.voiceMemoTooltipView = nil
|
|
|
|
UIView.animate(
|
|
withDuration: 0.2,
|
|
animations: {
|
|
voiceMemoTooltipView.alpha = 0
|
|
},
|
|
completion: { _ in
|
|
voiceMemoTooltipView.removeFromSuperview()
|
|
}
|
|
)
|
|
}
|
|
|
|
@objc
|
|
private func handleVoiceMemoLongPress(gesture: UILongPressGestureRecognizer) {
|
|
switch gesture.state {
|
|
|
|
case .possible, .cancelled, .failed:
|
|
guard voiceMemoRecordingState != .idle else { return }
|
|
// Record a draft if we were actively recording.
|
|
voiceMemoRecordingState = .idle
|
|
inputToolbarDelegate?.voiceMemoGestureWasInterrupted()
|
|
|
|
case .began:
|
|
switch voiceMemoRecordingState {
|
|
case .idle: break
|
|
|
|
case .recordingHeld:
|
|
owsFailDebug("while recording held, shouldn't be possible to restart gesture.")
|
|
inputToolbarDelegate?.voiceMemoGestureDidCancel()
|
|
|
|
case .recordingLocked, .draft:
|
|
owsFailDebug("once locked, shouldn't be possible to interact with gesture.")
|
|
inputToolbarDelegate?.voiceMemoGestureDidCancel()
|
|
}
|
|
|
|
// Start voice message.
|
|
voiceMemoRecordingState = .recordingHeld
|
|
voiceMemoGestureStartLocation = gesture.location(in: self)
|
|
inputToolbarDelegate?.voiceMemoGestureDidStart()
|
|
|
|
case .changed:
|
|
guard isShowingVoiceMemoUI else { return }
|
|
guard let voiceMemoGestureStartLocation = voiceMemoGestureStartLocation else {
|
|
owsFailDebug("voiceMemoGestureStartLocation is nil")
|
|
return
|
|
}
|
|
|
|
// Check for "slide to cancel" gesture.
|
|
let location = gesture.location(in: 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.
|
|
let xOffset = abs(voiceMemoGestureStartLocation.x - location.x)
|
|
let yOffset = abs(voiceMemoGestureStartLocation.y - location.y)
|
|
|
|
// Require a certain threshold before we consider the user to be
|
|
// interacting with the lock ui, otherwise there's perceptible wobble
|
|
// of the lock slider even when the user isn't intended to interact with it.
|
|
let lockThresholdPoints: CGFloat = 20
|
|
let lockOffsetPoints: CGFloat = 80
|
|
let yOffsetBeyondThreshold = max(yOffset - lockThresholdPoints, 0)
|
|
let lockAlpha = yOffsetBeyondThreshold / lockOffsetPoints
|
|
let isLocked = lockAlpha >= 1
|
|
if isLocked {
|
|
switch voiceMemoRecordingState {
|
|
case .recordingHeld:
|
|
voiceMemoRecordingState = .recordingLocked
|
|
inputToolbarDelegate?.voiceMemoGestureDidLock()
|
|
setVoiceMemoUICancelAlpha(0)
|
|
|
|
case .recordingLocked, .draft:
|
|
// already locked
|
|
break
|
|
|
|
case .idle:
|
|
owsFailDebug("failure: unexpeceted idle state")
|
|
inputToolbarDelegate?.voiceMemoGestureDidCancel()
|
|
}
|
|
} else {
|
|
voiceMemoLockView?.update(ratioComplete: lockAlpha)
|
|
|
|
// The lower this value, the easier it is to cancel by accident.
|
|
// The higher this value, the harder it is to cancel.
|
|
let cancelOffsetPoints: CGFloat = 100
|
|
let cancelAlpha = xOffset / cancelOffsetPoints
|
|
let isCancelled = cancelAlpha >= 1
|
|
guard !isCancelled else {
|
|
voiceMemoRecordingState = .idle
|
|
inputToolbarDelegate?.voiceMemoGestureDidCancel()
|
|
return
|
|
}
|
|
|
|
setVoiceMemoUICancelAlpha(cancelAlpha)
|
|
|
|
if xOffset > yOffset {
|
|
voiceMemoRedRecordingCircle?.transform = CGAffineTransform(translationX: min(-xOffset, 0), y: 0)
|
|
} else if yOffset > xOffset {
|
|
voiceMemoRedRecordingCircle?.transform = CGAffineTransform(translationX: 0, y: min(-yOffset, 0))
|
|
} else {
|
|
voiceMemoRedRecordingCircle?.transform = .identity
|
|
}
|
|
}
|
|
|
|
case .ended:
|
|
switch voiceMemoRecordingState {
|
|
case .idle:
|
|
break
|
|
|
|
case .recordingHeld:
|
|
// End voice message.
|
|
voiceMemoRecordingState = .idle
|
|
inputToolbarDelegate?.voiceMemoGestureDidComplete()
|
|
|
|
case .recordingLocked, .draft:
|
|
// Continue recording.
|
|
break
|
|
}
|
|
|
|
@unknown default: break
|
|
}
|
|
}
|
|
|
|
// MARK: Keyboards
|
|
|
|
private(set) var isMeasuringKeyboardHeight = false
|
|
private var hasMeasuredKeyboardHeight = false
|
|
|
|
// Workaround for keyboard & chat flashing when switching between keyboards on iOS 17.
|
|
// When swithing keyboards sometimes! we get "keyboard will show" notification
|
|
// with keyboard frame being slightly (45 dp) shorter, immediately followed by another
|
|
// notification with previous (correct) keyboard frame.
|
|
//
|
|
// Because this does not always happen and because this does not happen on a Simulator
|
|
// I concluded this is an iOS 17 bug.
|
|
//
|
|
// In the future it might be better to implement different keyboard managing
|
|
// by making UIViewController a first responder and vending input bar as `inputView`.
|
|
private(set) var isSwitchingKeyboard = false
|
|
|
|
private enum KeyboardType {
|
|
case system
|
|
case sticker
|
|
case attachment
|
|
}
|
|
|
|
private var _desiredKeyboardType: KeyboardType = .system
|
|
|
|
private var desiredKeyboardType: KeyboardType {
|
|
get { _desiredKeyboardType }
|
|
set { setDesiredKeyboardType(newValue, animated: false) }
|
|
}
|
|
|
|
private var stickerKeyboard: StickerKeyboard?
|
|
|
|
private func getOrCreateStickerKeyboard() -> StickerKeyboard {
|
|
if let stickerKeyboard {
|
|
return stickerKeyboard
|
|
}
|
|
let stickerKeyboard = StickerKeyboard()
|
|
stickerKeyboard.delegate = self
|
|
stickerKeyboard.registerWithView(self)
|
|
self.stickerKeyboard = stickerKeyboard
|
|
return stickerKeyboard
|
|
}
|
|
|
|
func showStickerKeyboard() {
|
|
AssertIsOnMainThread()
|
|
guard desiredKeyboardType != .sticker else { return }
|
|
toggleKeyboardType(.sticker, animated: false)
|
|
}
|
|
|
|
private var _attachmentKeyboard: AttachmentKeyboard?
|
|
|
|
private var attachmentKeyboard: AttachmentKeyboard {
|
|
if let attachmentKeyboard = _attachmentKeyboard {
|
|
return attachmentKeyboard
|
|
}
|
|
let keyboard = AttachmentKeyboard(delegate: self)
|
|
keyboard.registerWithView(self)
|
|
_attachmentKeyboard = keyboard
|
|
return keyboard
|
|
}
|
|
|
|
private var attachmentKeyboardIfLoaded: AttachmentKeyboard? { _attachmentKeyboard }
|
|
|
|
func showAttachmentKeyboard() {
|
|
AssertIsOnMainThread()
|
|
guard desiredKeyboardType != .attachment else { return }
|
|
toggleKeyboardType(.attachment, animated: false)
|
|
}
|
|
|
|
private func toggleKeyboardType(_ keyboardType: KeyboardType, animated: Bool) {
|
|
guard let inputToolbarDelegate = inputToolbarDelegate else {
|
|
owsFailDebug("inputToolbarDelegate is nil")
|
|
return
|
|
}
|
|
|
|
if desiredKeyboardType == keyboardType {
|
|
setDesiredKeyboardType(.system, animated: animated)
|
|
} else {
|
|
// For switching to anything other than the system keyboard,
|
|
// make sure this conversation isn't blocked before presenting it.
|
|
if inputToolbarDelegate.isBlockedConversation() {
|
|
inputToolbarDelegate.showUnblockConversationUI { [weak self] isBlocked in
|
|
guard let self = self, !isBlocked else { return }
|
|
self.toggleKeyboardType(keyboardType, animated: animated)
|
|
}
|
|
return
|
|
}
|
|
|
|
setDesiredKeyboardType(keyboardType, animated: animated)
|
|
}
|
|
|
|
beginEditingMessage()
|
|
}
|
|
|
|
private func setDesiredKeyboardType(_ keyboardType: KeyboardType, animated: Bool) {
|
|
guard _desiredKeyboardType != keyboardType else { return }
|
|
|
|
_desiredKeyboardType = keyboardType
|
|
|
|
ensureButtonVisibility(withAnimation: animated, doLayout: true)
|
|
|
|
if isInputViewFirstResponder {
|
|
isSwitchingKeyboard = true
|
|
// If any keyboard is presented, make sure the correct
|
|
// keyboard is presented.
|
|
beginEditingMessage()
|
|
|
|
DispatchQueue.main.async {
|
|
self.isSwitchingKeyboard = false
|
|
}
|
|
} else {
|
|
// Make sure neither keyboard is presented.
|
|
endEditingMessage()
|
|
}
|
|
}
|
|
|
|
func clearDesiredKeyboard() {
|
|
AssertIsOnMainThread()
|
|
desiredKeyboardType = .system
|
|
}
|
|
|
|
private func restoreDesiredKeyboardIfNecessary() {
|
|
AssertIsOnMainThread()
|
|
if desiredKeyboardType != .system && !desiredFirstResponder.isFirstResponder {
|
|
desiredFirstResponder.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
private func cacheKeyboardIfNecessary() {
|
|
// Preload the keyboard if we're not showing it already, this
|
|
// allows us to calculate the appropriate initial height for
|
|
// our custom inputViews and in general to present it faster
|
|
// We disable animations so this preload is invisible to the
|
|
// user.
|
|
//
|
|
// We only measure the keyboard if the toolbar isn't hidden.
|
|
// If it's hidden, we're likely here from a peek interaction
|
|
// and don't want to show the keyboard. We'll measure it later.
|
|
guard !hasMeasuredKeyboardHeight && !inputTextView.isFirstResponder && !isHidden else { return }
|
|
|
|
// Flag that we're measuring the system keyboard's height, so
|
|
// even if though it won't be the first responder by the time
|
|
// the notifications fire, we'll still read its measurement
|
|
isMeasuringKeyboardHeight = true
|
|
|
|
UIView.setAnimationsEnabled(false)
|
|
|
|
_ = inputTextView.becomeFirstResponder()
|
|
_ = inputTextView.resignFirstResponder()
|
|
|
|
inputTextView.reloadMentionState()
|
|
|
|
UIView.setAnimationsEnabled(true)
|
|
}
|
|
|
|
var isInputViewFirstResponder: Bool {
|
|
return inputTextView.isFirstResponder
|
|
|| stickerKeyboard?.isFirstResponder ?? false
|
|
|| attachmentKeyboardIfLoaded?.isFirstResponder ?? false
|
|
}
|
|
|
|
private func ensureFirstResponderState() {
|
|
restoreDesiredKeyboardIfNecessary()
|
|
}
|
|
|
|
private var desiredFirstResponder: UIResponder {
|
|
switch desiredKeyboardType {
|
|
case .system: return inputTextView
|
|
case .sticker: return getOrCreateStickerKeyboard()
|
|
case .attachment: return attachmentKeyboard
|
|
}
|
|
}
|
|
|
|
func beginEditingMessage() {
|
|
guard !desiredFirstResponder.isFirstResponder else { return }
|
|
desiredFirstResponder.becomeFirstResponder()
|
|
}
|
|
|
|
func endEditingMessage() {
|
|
_ = inputTextView.resignFirstResponder()
|
|
_ = stickerKeyboard?.resignFirstResponder()
|
|
_ = attachmentKeyboardIfLoaded?.resignFirstResponder()
|
|
}
|
|
|
|
func viewDidAppear() {
|
|
ensureButtonVisibility(withAnimation: false, doLayout: false)
|
|
cacheKeyboardIfNecessary()
|
|
}
|
|
|
|
@objc
|
|
private func applicationDidBecomeActive(notification: Notification) {
|
|
AssertIsOnMainThread()
|
|
restoreDesiredKeyboardIfNecessary()
|
|
}
|
|
|
|
@objc
|
|
private func keyboardFrameDidChange(notification: Notification) {
|
|
guard let keyboardEndFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else {
|
|
owsFailDebug("keyboardEndFrame is nil")
|
|
return
|
|
}
|
|
|
|
guard inputTextView.isFirstResponder || isMeasuringKeyboardHeight else { return }
|
|
let newHeight = keyboardEndFrame.size.height - frame.size.height
|
|
guard newHeight > 0 else { return }
|
|
stickerKeyboard?.updateSystemKeyboardHeight(newHeight)
|
|
attachmentKeyboard.updateSystemKeyboardHeight(newHeight)
|
|
if isMeasuringKeyboardHeight {
|
|
isMeasuringKeyboardHeight = false
|
|
hasMeasuredKeyboardHeight = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Button Actions
|
|
|
|
extension ConversationInputToolbar {
|
|
|
|
@objc
|
|
private func cameraButtonPressed() {
|
|
guard let inputToolbarDelegate = inputToolbarDelegate else {
|
|
owsFailDebug("inputToolbarDelegate == nil")
|
|
return
|
|
}
|
|
ImpactHapticFeedback.impactOccurred(style: .light)
|
|
inputToolbarDelegate.cameraButtonPressed()
|
|
}
|
|
|
|
@objc
|
|
private func addOrCancelButtonPressed() {
|
|
ImpactHapticFeedback.impactOccurred(style: .light)
|
|
if shouldShowEditUI {
|
|
editTarget = nil
|
|
quotedReplyDraft = nil
|
|
clearTextMessage(animated: true)
|
|
} else {
|
|
toggleKeyboardType(.attachment, animated: true)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
private func sendButtonPressed() {
|
|
guard let inputToolbarDelegate = inputToolbarDelegate else {
|
|
owsFailDebug("inputToolbarDelegate == nil")
|
|
return
|
|
}
|
|
|
|
guard !isShowingVoiceMemoUI else {
|
|
voiceMemoRecordingState = .idle
|
|
|
|
guard let voiceMemoDraft = voiceMemoDraft else {
|
|
inputToolbarDelegate.voiceMemoGestureDidComplete()
|
|
return
|
|
}
|
|
|
|
inputToolbarDelegate.sendVoiceMemoDraft(voiceMemoDraft)
|
|
return
|
|
}
|
|
|
|
inputToolbarDelegate.sendButtonPressed()
|
|
}
|
|
|
|
@objc
|
|
private func stickerButtonPressed() {
|
|
ImpactHapticFeedback.impactOccurred(style: .light)
|
|
|
|
var hasInstalledStickerPacks: Bool = false
|
|
SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
hasInstalledStickerPacks = !StickerManager.installedStickerPacks(transaction: transaction).isEmpty
|
|
}
|
|
guard hasInstalledStickerPacks else {
|
|
presentManageStickersView()
|
|
return
|
|
}
|
|
toggleKeyboardType(.sticker, animated: true)
|
|
}
|
|
|
|
@objc
|
|
private func keyboardButtonPressed() {
|
|
ImpactHapticFeedback.impactOccurred(style: .light)
|
|
|
|
toggleKeyboardType(.system, animated: true)
|
|
}
|
|
}
|
|
|
|
extension ConversationInputToolbar: ConversationTextViewToolbarDelegate {
|
|
|
|
private func updateHeightWithTextView(_ textView: UITextView) {
|
|
|
|
let maxSize = CGSize(width: textView.width - textView.textContainerInset.totalWidth, height: CGFloat.greatestFiniteMagnitude)
|
|
var contentSize = textView.attributedText.boundingRect(with: maxSize, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).size
|
|
contentSize.height += textView.textContainerInset.top
|
|
contentSize.height += textView.textContainerInset.bottom
|
|
|
|
let newHeight = CGFloat.clamp(
|
|
contentSize.height,
|
|
min: LayoutMetrics.minTextViewHeight,
|
|
max: UIDevice.current.isIPad ? LayoutMetrics.maxIPadTextViewHeight : LayoutMetrics.maxTextViewHeight
|
|
)
|
|
|
|
guard newHeight != textViewHeight else { return }
|
|
|
|
guard let textViewHeightConstraint else {
|
|
owsFailDebug("textViewHeightConstraint == nil")
|
|
return
|
|
}
|
|
|
|
textViewHeight = newHeight
|
|
textViewHeightConstraint.constant = newHeight
|
|
|
|
if let superview, inputToolbarDelegate != nil {
|
|
isAnimatingHeightChange = true
|
|
|
|
let animator = UIViewPropertyAnimator(
|
|
duration: ConversationInputToolbar.heightChangeAnimationDuration,
|
|
springDamping: 1,
|
|
springResponse: 0.25
|
|
)
|
|
animator.addAnimations {
|
|
self.invalidateIntrinsicContentSize()
|
|
superview.layoutIfNeeded()
|
|
}
|
|
animator.addCompletion { _ in
|
|
self.isAnimatingHeightChange = false
|
|
}
|
|
animator.startAnimation()
|
|
} else {
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
}
|
|
|
|
func textViewDidChange(_ textView: UITextView) {
|
|
owsAssertDebug(inputToolbarDelegate != nil)
|
|
|
|
// Ignore change events during configuration.
|
|
guard isConfigurationComplete else { return }
|
|
|
|
updateHeightWithTextView(textView)
|
|
ensureButtonVisibility(withAnimation: true, doLayout: true)
|
|
updateInputLinkPreview()
|
|
|
|
if editTarget != nil {
|
|
rightEdgeControlsView.sendButton.isEnabled = textView.hasText
|
|
}
|
|
}
|
|
|
|
func textViewDidChangeSelection(_ textView: UITextView) { }
|
|
|
|
func textViewDidBecomeFirstResponder(_ textView: UITextView) {
|
|
setDesiredKeyboardType(.system, animated: true)
|
|
}
|
|
}
|
|
|
|
extension ConversationInputToolbar: StickerPickerDelegate {
|
|
public func didSelectSticker(stickerInfo: StickerInfo) {
|
|
AssertIsOnMainThread()
|
|
inputToolbarDelegate?.sendSticker(stickerInfo)
|
|
}
|
|
|
|
public var storyStickerConfiguration: SignalUI.StoryStickerConfiguration {
|
|
.hide
|
|
}
|
|
}
|
|
|
|
extension ConversationInputToolbar: StickerPacksToolbarDelegate {
|
|
public func presentManageStickersView() {
|
|
AssertIsOnMainThread()
|
|
inputToolbarDelegate?.presentManageStickersView()
|
|
}
|
|
}
|
|
|
|
extension ConversationInputToolbar: AttachmentKeyboardDelegate {
|
|
|
|
func didSelectRecentPhoto(asset: PHAsset, attachment: SignalAttachment) {
|
|
inputToolbarDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment)
|
|
}
|
|
|
|
func didTapPhotos() {
|
|
inputToolbarDelegate?.photosButtonPressed()
|
|
}
|
|
|
|
func didTapCamera() {
|
|
inputToolbarDelegate?.cameraButtonPressed()
|
|
}
|
|
|
|
func didTapGif() {
|
|
inputToolbarDelegate?.gifButtonPressed()
|
|
}
|
|
|
|
func didTapFile() {
|
|
inputToolbarDelegate?.fileButtonPressed()
|
|
}
|
|
|
|
func didTapContact() {
|
|
inputToolbarDelegate?.contactButtonPressed()
|
|
}
|
|
|
|
func didTapLocation() {
|
|
inputToolbarDelegate?.locationButtonPressed()
|
|
}
|
|
|
|
func didTapPayment() {
|
|
inputToolbarDelegate?.paymentButtonPressed()
|
|
}
|
|
|
|
var isGroup: Bool {
|
|
inputToolbarDelegate?.isGroup() ?? false
|
|
}
|
|
}
|