Signal-iOS/Signal/ConversationView/ConversationInputToolbar.swift
Igor Solomennikov 345ddca4d0
Initial round of iOS 26 appearance updates to chat input bar.
• new appearance for controls.
• new look for quoted message and link preview panels.
2025-10-23 19:45:31 -07:00

2841 lines
110 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
// Older iOS versions (<16.0) only have proper `keyboardLayoutGuide` on UIVC's root view,
// but might as well request root view for all iOS versions.
func viewForKeyboardLayoutGuide() -> UIView
// 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 pollButtonPressed()
func didSelectRecentPhoto(asset: PHAsset, attachment: SignalAttachment)
func showUnblockConversationUI(completion: ((Bool) -> Void)?)
}
public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate {
private let spoilerState: SpoilerRenderState
private let mediaCache: CVMediaCache
private weak var inputToolbarDelegate: ConversationInputToolbarDelegate?
init(
spoilerState: SpoilerRenderState,
mediaCache: CVMediaCache,
messageDraft: MessageBody?,
quotedReplyDraft: DraftQuotedReplyModel?,
editTarget: TSOutgoingMessage?,
inputToolbarDelegate: ConversationInputToolbarDelegate,
inputTextViewDelegate: ConversationInputTextViewDelegate,
bodyRangesTextViewDelegate: BodyRangesTextViewDelegate
) {
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() }
setupContentView()
createContentsWithMessageDraft(
messageDraft,
quotedReplyDraft: quotedReplyDraft,
inputTextViewDelegate: inputTextViewDelegate,
bodyRangesTextViewDelegate: bodyRangesTextViewDelegate
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(notification:)),
name: .OWSApplicationDidBecomeActive,
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 }
inputToolbarDelegate?.updateToolbarHeight()
}
}
private enum LayoutMetrics {
static let initialToolbarHeight: CGFloat = 52
static let initialTextBoxHeight: CGFloat = 40
static let minTextViewHeight: CGFloat = 35
static let maxTextViewHeight: CGFloat = 98
static let maxTextViewHeightIpad: CGFloat = 142
}
private var iOS26Layout = false
private enum Buttons {
private static func compactButton(
buttonImage: UIImage,
primaryAction: UIAction?,
accessibilityLabel: String?,
accessibilityIdentifier: String?
) -> UIButton {
let button = UIButton(
configuration: .plain(),
primaryAction: primaryAction
)
button.configuration?.image = buttonImage
if #available(iOS 26, *) {
button.configuration?.baseForegroundColor = .Signal.label
} else {
button.configuration?.baseForegroundColor = Theme.primaryIconColor
}
button.accessibilityLabel = accessibilityLabel
button.accessibilityIdentifier = accessibilityIdentifier
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: LayoutMetrics.initialTextBoxHeight),
button.heightAnchor.constraint(equalToConstant: LayoutMetrics.initialTextBoxHeight),
])
return button
}
static func stickerButton(
primaryAction: UIAction,
accessibilityIdentifier: String?
) -> UIButton {
return compactButton(
buttonImage: UIImage(imageLiteralResourceName: "sticker"),
primaryAction: primaryAction,
accessibilityLabel: OWSLocalizedString(
"INPUT_TOOLBAR_STICKER_BUTTON_ACCESSIBILITY_LABEL",
comment: "accessibility label for the button which shows the sticker picker"
),
accessibilityIdentifier: accessibilityIdentifier
)
}
static func keyboardButton(
primaryAction: UIAction,
accessibilityIdentifier: String?
) -> UIButton {
return compactButton(
buttonImage: UIImage(imageLiteralResourceName: "keyboard"),
primaryAction: primaryAction,
accessibilityLabel: OWSLocalizedString(
"INPUT_TOOLBAR_KEYBOARD_BUTTON_ACCESSIBILITY_LABEL",
comment: "accessibility label for the button which shows the regular keyboard instead of sticker picker"
),
accessibilityIdentifier: accessibilityIdentifier
)
}
static func cameraButton(
primaryAction: UIAction?,
accessibilityIdentifier: String?
) -> UIButton {
let button = compactButton(
buttonImage: Theme.iconImage(.buttonCamera),
primaryAction: primaryAction,
accessibilityLabel: OWSLocalizedString(
"CAMERA_BUTTON_LABEL",
comment: "Accessibility label for camera button."
),
accessibilityIdentifier: accessibilityIdentifier
)
button.accessibilityHint = OWSLocalizedString(
"CAMERA_BUTTON_HINT",
comment: "Accessibility hint describing what you can do with the camera button"
)
return button
}
static func voiceNoteButton(
primaryAction: UIAction?,
accessibilityIdentifier: String?
) -> UIButton {
let button = compactButton(
buttonImage: Theme.iconImage(.buttonMicrophone),
primaryAction: primaryAction,
accessibilityLabel: OWSLocalizedString(
"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL",
comment: "accessibility label for the button which records voice memos"
),
accessibilityIdentifier: accessibilityIdentifier
)
button.accessibilityHint = OWSLocalizedString(
"INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_HINT",
comment: "accessibility hint for the button which records voice memos"
)
return button
}
#if compiler(>=6.2)
@available(iOS 26.0, *)
static func sendButton(
primaryAction: UIAction?,
accessibilityIdentifier: String?
) -> UIButton {
let buttonSize = LayoutMetrics.initialTextBoxHeight
let button = UIButton(
configuration: .prominentGlass(),
primaryAction: primaryAction
)
button.tintColor = .Signal.accent
button.configuration?.image = Theme.iconImage(.arrowUp)
button.configuration?.baseForegroundColor = .white
button.configuration?.cornerStyle = .capsule
button.accessibilityLabel = MessageStrings.sendButton
button.accessibilityIdentifier = accessibilityIdentifier
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
button.heightAnchor.constraint(equalToConstant: buttonSize),
])
return button
}
@available(iOS 26.0, *)
static func addAttachmentButton(
primaryAction: UIAction?,
accessibilityIdentifier: String?
) -> UIButton {
let buttonSize = LayoutMetrics.initialTextBoxHeight
let button = AttachmentButton(
configuration: .glass(),
primaryAction: primaryAction
)
button.tintColor = .white
button.configuration?.image = UIImage(imageLiteralResourceName: "plus")
button.configuration?.baseForegroundColor = .Signal.label
button.configuration?.cornerStyle = .capsule
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 = accessibilityIdentifier
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
button.heightAnchor.constraint(equalToConstant: buttonSize),
])
return button
}
@available(iOS 26.0, *)
static func deleteVoiceMemoDraftButton(
primaryAction: UIAction?,
accessibilityIdentifier: String?
) -> UIButton {
let buttonSize = LayoutMetrics.initialTextBoxHeight
let button = UIButton(
configuration: .prominentGlass(),
primaryAction: primaryAction
)
button.tintColor = .Signal.red
button.configuration?.image = UIImage(imageLiteralResourceName: "trash-fill")
button.configuration?.baseForegroundColor = .white
button.configuration?.cornerStyle = .capsule
button.accessibilityIdentifier = accessibilityIdentifier
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
button.heightAnchor.constraint(equalToConstant: buttonSize),
])
return button
}
#endif
}
private lazy var inputTextView: ConversationInputTextView = {
let inputTextView = ConversationInputTextView()
inputTextView.textViewToolbarDelegate = self
inputTextView.font = .dynamicTypeBody
inputTextView.semanticContentAttribute = .forceLeftToRight
inputTextView.setContentHuggingVerticalHigh()
inputTextView.setCompressionResistanceLow()
inputTextView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "inputTextView")
return inputTextView
}()
private lazy var leadingEdgeControl: UIView = {
#if compiler(>=6.2)
guard #unavailable(iOS 26.0) else {
return Buttons.addAttachmentButton(
primaryAction: UIAction { [weak self] _ in
self?.addOrCancelButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "attachmentButton")
)
}
#endif
let button = AttachmentButtonLegacy()
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)
return button
}()
private lazy var stickerButton = Buttons.stickerButton(
primaryAction: UIAction { [weak self] _ in
self?.stickerButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "stickerButton")
)
private lazy var keyboardButton = Buttons.keyboardButton(
primaryAction: UIAction { [weak self] _ in
self?.keyboardButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "keyboardButton")
)
private lazy var cameraButton = Buttons.cameraButton(
primaryAction: UIAction { [weak self] _ in
self?.cameraButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "cameraButton")
)
private lazy var voiceNoteButton: UIButton = {
let button = Buttons.voiceNoteButton(
primaryAction: nil,
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "voiceNoteButton")
)
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceMemoLongPress(gesture:)))
longPressGestureRecognizer.minimumPressDuration = 0
button.addGestureRecognizer(longPressGestureRecognizer)
return button
}()
private lazy var trailingEdgeControl: UIView = {
#if compiler(>=6.2)
guard #unavailable(iOS 26.0) else {
return Buttons.sendButton(
primaryAction: UIAction { [weak self] _ in
self?.sendButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "sendButton")
)
}
#endif
let view = RightEdgeControlsView(
sendButtonAction: UIAction { [weak self] _ in
self?.sendButtonPressed()
},
cameraButtonAction: UIAction { [weak self] _ in
self?.cameraButtonPressed()
}
)
let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceMemoLongPress(gesture:)))
longPressGestureRecognizer.minimumPressDuration = 0
view.voiceMemoButton.addGestureRecognizer(longPressGestureRecognizer)
return view
}()
private lazy var editMessageThumbnailView: UIImageView = {
let imageView = UIImageView()
imageView.layer.cornerRadius = 4
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private lazy var editMessageLabelWrapper: UIView = {
let editIconView = UIImageView(image: Theme.iconImage(.compose16))
editIconView.contentMode = .scaleAspectFit
editIconView.setContentHuggingHigh()
editIconView.tintColor = .Signal.label
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 = .Signal.label
let stackView = UIStackView(arrangedSubviews: [editIconView, editLabel, editMessageThumbnailView])
stackView.axis = .horizontal
stackView.alignment = .center
stackView.distribution = .fill
stackView.spacing = 4
stackView.translatesAutoresizingMaskIntoConstraints = false
let view = UIView()
view.directionalLayoutMargins = .init(top: 12, leading: 12, bottom: 4, trailing: 8)
view.addSubview(stackView)
view.addConstraints([
editLabel.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), // align using textLabel, not stackView
stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
editMessageThumbnailView.widthAnchor.constraint(equalToConstant: 20),
editMessageThumbnailView.heightAnchor.constraint(equalToConstant: 20),
])
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "editMessageWrapper")
return view
}()
private lazy var quotedReplyWrapper: UIView = {
let view = UIView.container()
view.directionalLayoutMargins = .init(top: 6, leading: 6, bottom: 0, trailing: 6)
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedReplyWrapper")
return view
}()
private lazy var linkPreviewWrapper: UIView = {
let view = UIView.container()
view.clipsToBounds = true
view.directionalLayoutMargins = .init(top: 6, leading: 6, bottom: 0, trailing: 6)
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "linkPreviewWrapper")
return view
}()
private lazy var voiceMemoContentView: UIView = {
let view = UIView.container()
view.isHidden = true
view.semanticContentAttribute = .forceLeftToRight
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "voiceMemoContentView")
return view
}()
// Whole-width container that contains (+) button, text input part and Send button.
private let contentView = UIView()
// Occupies central part of the `contentView`. That's where text input field, link preview etc live in.
private let messageContentView = UIView()
private var isConfigurationComplete = false
private func setupContentView() {
// 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 you'll need to set the appropriate `semanticContentAttribute`
// to ensure horizontal stack views layout left-to-right.
semanticContentAttribute = .forceLeftToRight
autoresizingMask = .flexibleHeight
// "Suggested Stickers" horizontal list view will be placed in a wrapper view to allow for slide in / slide out animation.
updateSuggestedStickersPanelConstraints()
// On iOS 26 embed eveything into a `glass container effect` view.
var outerVStackSuperview: UIView = self
#if compiler(>=6.2)
if #available(iOS 26, *) {
let glassContainerView = UIVisualEffectView(effect: UIGlassContainerEffect())
addSubview(glassContainerView)
glassContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
glassContainerView.topAnchor.constraint(equalTo: topAnchor),
glassContainerView.leadingAnchor.constraint(equalTo: leadingAnchor),
glassContainerView.trailingAnchor.constraint(equalTo: trailingAnchor),
glassContainerView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
outerVStackSuperview = glassContainerView.contentView
iOS26Layout = true
}
#endif
let contentViewWrapperView = UIView.container()
// Outermost vertical stack:
// [ Suggested Stickers Panel ]
// [ Message Creation Input Box and Buttons ]
let outerVStack = UIStackView(arrangedSubviews: [ suggestedStickersPanel, contentViewWrapperView ] )
outerVStack.axis = .vertical
outerVStackSuperview.addSubview(outerVStack)
outerVStack.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
outerVStack.topAnchor.constraint(equalTo: outerVStackSuperview.topAnchor),
outerVStack.leadingAnchor.constraint(equalTo: outerVStackSuperview.leadingAnchor),
outerVStack.trailingAnchor.constraint(equalTo: outerVStackSuperview.trailingAnchor),
outerVStack.bottomAnchor.constraint(equalTo: outerVStackSuperview.safeAreaLayoutGuide.bottomAnchor),
])
// Background needed on pre-iOS 26 devices.
if !iOS26Layout {
// 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.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
blurEffectView.topAnchor.constraint(equalTo: extendedBackgroundView.topAnchor),
blurEffectView.leadingAnchor.constraint(equalTo: extendedBackgroundView.leadingAnchor),
blurEffectView.trailingAnchor.constraint(equalTo: extendedBackgroundView.trailingAnchor),
blurEffectView.bottomAnchor.constraint(equalTo: extendedBackgroundView.bottomAnchor),
])
}
contentViewWrapperView.addSubview(extendedBackgroundView)
extendedBackgroundView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
extendedBackgroundView.topAnchor.constraint(equalTo: contentViewWrapperView.topAnchor),
extendedBackgroundView.leadingAnchor.constraint(equalTo: contentViewWrapperView.leadingAnchor),
extendedBackgroundView.trailingAnchor.constraint(equalTo: contentViewWrapperView.trailingAnchor),
extendedBackgroundView.bottomAnchor.constraint(equalTo: contentViewWrapperView.bottomAnchor, constant: backgroundExtension)
])
}
// Set up content view.
contentView.semanticContentAttribute = .forceLeftToRight
contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(
hMargin: OWSTableViewController2.defaultHOuterMargin - 16,
vMargin: iOS26Layout ? 6 : 0
)
contentView.translatesAutoresizingMaskIntoConstraints = false
contentViewWrapperView.addSubview(contentView)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: contentViewWrapperView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: contentViewWrapperView.safeAreaLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: contentViewWrapperView.safeAreaLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: contentViewWrapperView.bottomAnchor),
])
}
private func createContentsWithMessageDraft(
_ messageDraft: MessageBody?,
quotedReplyDraft: DraftQuotedReplyModel?,
inputTextViewDelegate: ConversationInputTextViewDelegate,
bodyRangesTextViewDelegate: BodyRangesTextViewDelegate
) {
// 1. Set initial parameters.
// NOTE: Don't set inputTextViewDelegate until configuration is complete.
inputTextView.bodyRangesDelegate = bodyRangesTextViewDelegate
inputTextView.inputTextViewDelegate = inputTextViewDelegate
textViewHeightConstraint = inputTextView.autoSetDimension(.height, toSize: LayoutMetrics.minTextViewHeight)
editMessageLabelWrapper.isHidden = !shouldShowEditUI
quotedReplyWrapper.isHidden = quotedReplyDraft == nil
self.quotedReplyDraft = quotedReplyDraft
// 2. Prepare content displayed in the central part of the toolbar.
// This container allows to vertically center short text views in standard sized box.
let inputTextViewContainer = UIView.container()
inputTextViewContainer.semanticContentAttribute = .forceLeftToRight
inputTextViewContainer.addSubview(inputTextView)
inputTextView.translatesAutoresizingMaskIntoConstraints = false
inputTextViewContainer.addConstraints([
inputTextViewContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: LayoutMetrics.initialTextBoxHeight),
inputTextView.topAnchor.constraint(greaterThanOrEqualTo: inputTextViewContainer.topAnchor),
inputTextView.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
inputTextView.leadingAnchor.constraint(equalTo: inputTextViewContainer.leadingAnchor),
inputTextView.trailingAnchor.constraint(equalTo: inputTextViewContainer.trailingAnchor),
])
// Vertical stack of message component views in the center
// | edit message |
// | Link Preview |
// | Reply Quote |
// | Text Input |
let messageComponentsView = UIStackView(arrangedSubviews: [
editMessageLabelWrapper,
quotedReplyWrapper,
linkPreviewWrapper,
inputTextViewContainer,
])
messageComponentsView.axis = .vertical
messageComponentsView.alignment = .fill
// 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.
messageComponentsView.addSubview(voiceMemoContentView)
voiceMemoContentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
voiceMemoContentView.topAnchor.constraint(equalTo: inputTextViewContainer.topAnchor),
voiceMemoContentView.leadingAnchor.constraint(equalTo: inputTextViewContainer.leadingAnchor),
voiceMemoContentView.trailingAnchor.constraint(equalTo: inputTextViewContainer.trailingAnchor),
voiceMemoContentView.bottomAnchor.constraint(equalTo: inputTextViewContainer.bottomAnchor),
])
// Rounded rect background for the text input field:
// Liquid Glass on iOS 26, gray-ish on earlier iOS versions.
#if compiler(>=6.2)
let backgroundView: UIView
if #available(iOS 26, *) {
let glassEffectView = UIVisualEffectView(effect: UIGlassEffect(style: .regular))
glassEffectView.cornerConfiguration = .uniformCorners(radius: 20)
glassEffectView.contentView.addSubview(messageComponentsView)
backgroundView = glassEffectView
} else {
backgroundView = UIView()
backgroundView.backgroundColor = UIColor.Signal.tertiaryFill
backgroundView.layer.cornerRadius = 20
messageContentView.addSubview(messageComponentsView)
}
#else
let backgroundView = UIView()
backgroundView.backgroundColor = UIColor.Signal.tertiaryFill
backgroundView.layer.cornerRadius = 20
messageContentView.addSubview(messageComponentsView)
#endif
let vMargin = 0.5 * (LayoutMetrics.initialToolbarHeight - LayoutMetrics.initialTextBoxHeight)
let hMargin: CGFloat = iOS26Layout ? 12 : 0 // iOS 26 needs space between leading/trailing buttons and text view background.
messageContentView.directionalLayoutMargins = .init(hMargin: hMargin, vMargin: vMargin)
messageContentView.semanticContentAttribute = .forceLeftToRight
messageContentView.insertSubview(backgroundView, at: 0)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
messageComponentsView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// Background view is inset from the edges of the central part of the `contentView` - `messageContentView`
backgroundView.topAnchor.constraint(equalTo: messageContentView.layoutMarginsGuide.topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: messageContentView.layoutMarginsGuide.leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: messageContentView.layoutMarginsGuide.trailingAnchor),
backgroundView.bottomAnchor.constraint(equalTo: messageContentView.layoutMarginsGuide.bottomAnchor),
// Message components stack is constrained to background view's edges.
messageComponentsView.topAnchor.constraint(equalTo: backgroundView.topAnchor),
messageComponentsView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor),
messageComponentsView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor),
messageComponentsView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor),
])
// iOS 26 has three in-field buttons: Sticker/Keyboard, Camera, Voice Note.
// iOS 15-18 only have Sticker/Keyboard.
if iOS26Layout {
inputTextView.inFieldButtonsAreaWidth = 3 * LayoutMetrics.initialTextBoxHeight
messageContentView.addSubview(stickerButton)
messageContentView.addSubview(keyboardButton)
messageContentView.addSubview(cameraButton)
messageContentView.addSubview(voiceNoteButton)
stickerButton.translatesAutoresizingMaskIntoConstraints = false
keyboardButton.translatesAutoresizingMaskIntoConstraints = false
cameraButton.translatesAutoresizingMaskIntoConstraints = false
voiceNoteButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
voiceNoteButton.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor, constant: -4),
cameraButton.trailingAnchor.constraint(equalTo: voiceNoteButton.leadingAnchor),
stickerButton.trailingAnchor.constraint(equalTo: cameraButton.leadingAnchor),
keyboardButton.trailingAnchor.constraint(equalTo: cameraButton.leadingAnchor),
voiceNoteButton.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
cameraButton.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
stickerButton.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
keyboardButton.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
])
} else {
inputTextView.inFieldButtonsAreaWidth = 1 * LayoutMetrics.initialTextBoxHeight
messageContentView.addSubview(stickerButton)
messageContentView.addSubview(keyboardButton)
stickerButton.translatesAutoresizingMaskIntoConstraints = false
keyboardButton.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stickerButton.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
keyboardButton.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
stickerButton.trailingAnchor.constraint(equalTo: messageContentView.trailingAnchor, constant: -4),
keyboardButton.trailingAnchor.constraint(equalTo: messageContentView.trailingAnchor, constant: -4),
])
}
// 3. Configure horizontal layout: Attachment button, message components, Camera|VoiceNote|Send button.
leadingEdgeControl.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(leadingEdgeControl)
messageContentView.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(messageContentView)
trailingEdgeControl.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(trailingEdgeControl)
let outerHMargin: CGFloat = iOS26Layout ? 16 : 0
NSLayoutConstraint.activate([
// + Attachment button: pinned to the bottom left corner.
leadingEdgeControl.leadingAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.leadingAnchor,
constant: outerHMargin
),
leadingEdgeControl.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
// Message components view: pinned to attachment button on the left, Camera button on the right,
// taking entire superview's height.
messageContentView.topAnchor.constraint(equalTo: contentView.topAnchor),
messageContentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
// Camera | Voice Message | Send: pinned to the bottom right corner.
trailingEdgeControl.trailingAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.trailingAnchor,
constant: -outerHMargin
),
trailingEdgeControl.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor),
])
updateMessageContentViewLeadingEdgeConstraint(isLeadingEdgeControlHidden: false)
if iOS26Layout {
setSendButtonHidden(true, usingAnimator: nil)
} else {
messageContentView.trailingAnchor.constraint(equalTo: trailingEdgeControl.leadingAnchor).isActive = true
}
// 4. Finish.
setMessageBody(messageDraft, animated: false, doLayout: false)
isConfigurationComplete = true
}
@discardableResult
class func setView(_ view: UIView, hidden isHidden: Bool, usingAnimator animator: UIViewPropertyAnimator?) -> Bool {
// Nothing to do if the view isn't a part of the view hierarchy.
if isHidden, view.superview == nil { return false }
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 leadingEdgeControlState: LeadingEdgeControlState = {
if isShowingVoiceMemoUI {
return voiceMemoRecordingState == .draft ? .deleteVoiceMemoDraft : .none
}
return .addAttachment
}()
if setLeadingEdgeControlState(leadingEdgeControlState, usingAnimator: animator) {
hasLayoutChanged = true
}
// Attachment button has more complex animations and cannot be grouped with the rest.
if let attachmentButton = leadingEdgeControl as? AttachmentButtonProtocol {
let buttonState: AttachmentButtonState = {
if shouldShowEditUI {
return .close
} else {
return desiredKeyboardType == .attachment ? .close : .add
}
}()
attachmentButton.setButtonState(buttonState, usingAnimator: animator)
}
// Show / hide Sticker or Keyboard buttons inside of the text input field.
// Show / hide Camera and Voice Note buttons inside of the text input field on iOS 26.
// In-field buttons are only visible if there's no any text input, including whitespace-only.
let hideTextFieldButtons = shouldShowEditUI || !inputTextView.untrimmedText.isEmpty || isShowingVoiceMemoUI || quotedReplyDraft != nil
let hideStickerButton = hideTextFieldButtons || desiredKeyboardType == .sticker
let hideKeyboardButton = hideTextFieldButtons || !hideStickerButton
ConversationInputToolbar.setView(stickerButton, hidden: hideStickerButton, usingAnimator: animator)
ConversationInputToolbar.setView(keyboardButton, hidden: hideKeyboardButton, usingAnimator: animator)
if iOS26Layout {
ConversationInputToolbar.setView(cameraButton, hidden: hideTextFieldButtons, usingAnimator: animator)
ConversationInputToolbar.setView(voiceNoteButton, hidden: hideTextFieldButtons, 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
}
// Pre-iOS 26: update trailing edge controls.
if let rightEdgeControlsView = trailingEdgeControl as? RightEdgeControlsView {
if rightEdgeControlsView.state != rightEdgeControlsState {
hasLayoutChanged = true
if let animator {
// `state` in implicitly animatable.
animator.addAnimations {
rightEdgeControlsView.state = rightEdgeControlsState
}
} else {
rightEdgeControlsView.state = rightEdgeControlsState
}
}
}
// iOS 26+: show / hide Send button.
if iOS26Layout {
let hideSendButton = rightEdgeControlsState != .sendButton
if setSendButtonHidden(hideSendButton, usingAnimator: animator) {
hasLayoutChanged = true
}
}
if let animator {
if doLayout && hasLayoutChanged {
animator.addAnimations {
self.contentView.setNeedsLayout()
self.contentView.layoutIfNeeded()
}
}
animator.startAnimation()
} else {
if doLayout && hasLayoutChanged {
self.contentView.setNeedsLayout()
self.contentView.layoutIfNeeded()
}
}
updateSuggestedStickers(animated: isAnimated)
}
private var messageContentViewLeadingEdgeConstraint: NSLayoutConstraint?
private func updateMessageContentViewLeadingEdgeConstraint(isLeadingEdgeControlHidden: Bool) {
if let messageContentViewLeadingEdgeConstraint {
removeConstraint(messageContentViewLeadingEdgeConstraint)
}
let constraint: NSLayoutConstraint
if isLeadingEdgeControlHidden {
constraint = messageContentView.leadingAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.leadingAnchor,
constant: iOS26Layout ? 4 : 16
)
} else {
constraint = messageContentView.leadingAnchor.constraint(equalTo: leadingEdgeControl.trailingAnchor)
}
addConstraint(constraint)
messageContentViewLeadingEdgeConstraint = constraint
}
private var messageContentViewTrailingEdgeConstraint: NSLayoutConstraint?
private func updateMessageContentViewTrailingEdgeConstraint(isTrailingEdgeControlHidden: Bool) {
guard iOS26Layout else { return }
if let messageContentViewTrailingEdgeConstraint {
removeConstraint(messageContentViewTrailingEdgeConstraint)
}
let constraint: NSLayoutConstraint
if isTrailingEdgeControlHidden {
constraint = messageContentView.trailingAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.trailingAnchor,
constant: -4
)
} else {
constraint = messageContentView.trailingAnchor.constraint(equalTo: trailingEdgeControl.leadingAnchor)
}
addConstraint(constraint)
messageContentViewTrailingEdgeConstraint = constraint
}
private enum LeadingEdgeControlState {
case none
case addAttachment
case deleteVoiceMemoDraft
}
@discardableResult
private func setLeadingEdgeControlState(
_ controlState: LeadingEdgeControlState,
usingAnimator animator: UIViewPropertyAnimator?
) -> Bool {
var voiceMemoButtonUpdated = false
if controlState == .deleteVoiceMemoDraft, voiceMemoDeleteButton.superview == nil {
contentView.addSubview(voiceMemoDeleteButton)
voiceMemoDeleteButton.translatesAutoresizingMaskIntoConstraints = false
contentView.addConstraints([
voiceMemoDeleteButton.topAnchor.constraint(equalTo: leadingEdgeControl.topAnchor),
voiceMemoDeleteButton.leadingAnchor.constraint(equalTo: leadingEdgeControl.leadingAnchor),
voiceMemoDeleteButton.trailingAnchor.constraint(equalTo: leadingEdgeControl.trailingAnchor),
voiceMemoDeleteButton.bottomAnchor.constraint(equalTo: leadingEdgeControl.bottomAnchor),
])
voiceMemoButtonUpdated = true
} else if controlState != .deleteVoiceMemoDraft, voiceMemoDeleteButton.superview != nil {
voiceMemoDeleteButton.removeFromSuperview()
voiceMemoButtonUpdated = true
}
let attachmentButtonUpdated = ConversationInputToolbar.setView(leadingEdgeControl, hidden: controlState != .addAttachment, usingAnimator: animator)
guard attachmentButtonUpdated || voiceMemoButtonUpdated else {
return false
}
updateMessageContentViewLeadingEdgeConstraint(isLeadingEdgeControlHidden: controlState == .none)
return true
}
@discardableResult
private func setSendButtonHidden(_ isHidden: Bool, usingAnimator animator: UIViewPropertyAnimator?) -> Bool {
// Only on iOS 26 trailing edge control (Send button) can get hidden.
guard let sendButton = trailingEdgeControl as? UIButton else { return false }
guard ConversationInputToolbar.setView(sendButton, hidden: isHidden, usingAnimator: animator) else { return false }
updateMessageContentViewTrailingEdgeConstraint(isTrailingEdgeControlHidden: isHidden)
return true
}
func scrollToBottom() {
inputTextView.scrollToBottom()
}
func updateFontSizes() {
inputTextView.font = .dynamicTypeBody
}
// MARK: Right Edge Buttons
@available(iOS, deprecated: 26.0)
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.initialToolbarHeight)
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.initialToolbarHeight)
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.initialToolbarHeight)
return button
}()
init(
sendButtonAction: UIAction,
cameraButtonAction: UIAction
) {
super.init(frame: .zero)
sendButton.addAction(sendButtonAction, for: .primaryActionTriggered)
cameraButton.addAction(cameraButtonAction, for: .primaryActionTriggered)
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.initialToolbarHeight)
}
}
// MARK: Add/Cancel Button
private enum AttachmentButtonState {
case add
case close
}
private protocol AttachmentButtonProtocol where Self: UIButton {
var buttonState: AttachmentButtonState { get set }
func setButtonState(_ buttonState: AttachmentButtonState, usingAnimator animator: UIViewPropertyAnimator?)
}
@available(iOS, deprecated: 26.0)
private class AttachmentButtonLegacy: UIButton, AttachmentButtonProtocol {
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()
// Button is larger but the same visually to allow easier taps.
translatesAutoresizingMaskIntoConstraints = false
addConstraints([
widthAnchor.constraint(equalToConstant: LayoutMetrics.initialToolbarHeight),
heightAnchor.constraint(equalToConstant: LayoutMetrics.initialToolbarHeight),
])
}
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 !isAnimatingStateChange else { return }
// Mimic behavior of a standard system button.
let opacity: CGFloat = isHighlighted ? (Theme.isDarkThemeEnabled ? 0.4 : 0.2) : 1
switch buttonState {
case .add:
iconImageView.alpha = opacity
case .close:
roundedCornersBackground.alpha = opacity
}
}
}
private var _buttonState: AttachmentButtonState = .add
private var isAnimatingStateChange = false
var buttonState: AttachmentButtonState {
get { _buttonState }
set { setButtonState(newValue, usingAnimator: nil) }
}
func setButtonState(_ buttonState: AttachmentButtonState, usingAnimator animator: UIViewPropertyAnimator?) {
guard buttonState != _buttonState else { return }
_buttonState = buttonState
guard let animator else {
updateImageColorAndBackground()
updateImageTransform()
return
}
isAnimatingStateChange = true
animator.addAnimations({
self.updateImageColorAndBackground()
},
delayFactor: buttonState == .add ? 0 : 0.2
)
animator.addAnimations {
self.updateImageTransform()
}
animator.addCompletion { _ in
self.isAnimatingStateChange = false
}
}
private func updateImageColorAndBackground() {
switch buttonState {
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 buttonState {
case .add:
iconImageView.transform = .identity
case .close:
iconImageView.transform = .rotate(1.5 * .halfPi)
}
}
}
#if compiler(>=6.2)
@available(iOS 26.0, *)
private class AttachmentButton: UIButton, AttachmentButtonProtocol {
private var _buttonState: AttachmentButtonState = .add
var buttonState: AttachmentButtonState {
get { _buttonState }
set { setButtonState(newValue, usingAnimator: nil) }
}
func setButtonState(_ buttonState: AttachmentButtonState, usingAnimator animator: UIViewPropertyAnimator?) {
guard buttonState != _buttonState else { return }
_buttonState = buttonState
guard let animator else {
updateTransform()
return
}
animator.addAnimations {
self.updateTransform()
}
}
private func updateTransform() {
switch buttonState {
case .add:
transform = .identity
case .close:
transform = .rotate(1.5 * .halfPi)
}
}
}
#endif
// MARK: Message Body
private var textViewHeight: CGFloat = 0
private var textViewHeightConstraint: NSLayoutConstraint?
class var heightChangeAnimationDuration: TimeInterval { 0.25 }
private(set) var isAnimatingHeightChange = false
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)
setSendButtonEnabled(false)
}
private func hideEditMessageView(animated: Bool) {
owsAssertDebug(editTarget == nil)
toggleMessageComponentVisibility(hide: true, component: editMessageLabelWrapper, animated: animated)
setSendButtonEnabled(true)
}
private func setSendButtonEnabled(_ enabled: Bool) {
if let rightEdgeControlsView = trailingEdgeControl as? RightEdgeControlsView {
rightEdgeControlsView.sendButton.isEnabled = enabled
} else if let sendButton = trailingEdgeControl as? UIButton {
sendButton.isEnabled = enabled
}
}
// 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
}
quotedReplyWrapper.removeAllSubviews()
let quotedMessagePreview = QuotedReplyPreview(
quotedReplyDraft: quotedReplyDraft,
spoilerState: spoilerState
)
quotedMessagePreview.delegate = self
quotedMessagePreview.setContentHuggingHorizontalLow()
quotedMessagePreview.setCompressionResistanceHorizontalLow()
quotedMessagePreview.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedMessagePreview")
quotedReplyWrapper.addSubview(quotedMessagePreview)
quotedMessagePreview.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
quotedMessagePreview.topAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.topAnchor),
quotedMessagePreview.leadingAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.leadingAnchor),
quotedMessagePreview.trailingAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.trailingAnchor),
quotedMessagePreview.bottomAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.bottomAnchor),
])
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: OutgoingLinkPreviewView?
private var isLinkPreviewHidden = true
private var linkPreviewConstraints = [NSLayoutConstraint]()
private func updateLinkPreviewConstraint() {
guard let linkPreviewView else {
owsFailDebug("linkPreviewView == nil")
return
}
removeConstraints(linkPreviewConstraints)
// 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.
if isLinkPreviewHidden {
linkPreviewConstraints = [
linkPreviewView.topAnchor.constraint(equalTo: linkPreviewWrapper.topAnchor),
linkPreviewView.topAnchor.constraint(equalTo: linkPreviewWrapper.bottomAnchor),
]
} else {
linkPreviewConstraints = [
linkPreviewView.topAnchor.constraint(equalTo: linkPreviewWrapper.layoutMarginsGuide.topAnchor),
linkPreviewView.bottomAnchor.constraint(equalTo: linkPreviewWrapper.layoutMarginsGuide.bottomAnchor),
]
}
addConstraints(linkPreviewConstraints)
}
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)
default:
ensureLinkPreviewView(withState: linkPreviewFetchState.currentState)
}
}
private func ensureLinkPreviewView(withState state: LinkPreviewFetchState.State) {
AssertIsOnMainThread()
let linkPreviewView: OutgoingLinkPreviewView
if let existingLinkPreviewView = self.linkPreviewView {
linkPreviewView = existingLinkPreviewView
linkPreviewView.configure(withState: state)
} else {
linkPreviewView = OutgoingLinkPreviewView(state: state)
linkPreviewView.cancelButton.addAction(
UIAction { [weak self] _ in
self?.didTapDeleteLinkPreview()
},
for: .primaryActionTriggered
)
linkPreviewWrapper.addSubview(linkPreviewView)
// See comment in `updateLinkPreviewConstraint` why vertical constraints aren't here.
linkPreviewView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
linkPreviewView.leadingAnchor.constraint(equalTo: linkPreviewWrapper.layoutMarginsGuide.leadingAnchor),
linkPreviewView.trailingAnchor.constraint(equalTo: linkPreviewWrapper.layoutMarginsGuide.trailingAnchor),
])
self.linkPreviewView = linkPreviewView
updateLinkPreviewConstraint()
}
UIView.performWithoutAnimation {
self.contentView.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
self.linkPreviewView?.resetContent()
}
animator.startAnimation()
}
private func didTapDeleteLinkPreview() {
AssertIsOnMainThread()
linkPreviewFetchState.disable()
}
// MARK: Stickers
private let suggestedStickerViewCache = StickerViewCache(maxSize: 12)
private var suggestedStickerEmoji: Character?
private var suggestedStickerInfos: [StickerInfo] = []
private lazy var suggestedStickersListView: 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 suggestedStickersPanel: UIView = {
let view = UIView.container()
view.clipsToBounds = true
return view
}()
private var suggestedStickersPanelConstraints: [NSLayoutConstraint] = []
private func updateSuggestedStickersPanelConstraints() {
NSLayoutConstraint.deactivate(suggestedStickersPanelConstraints)
defer {
NSLayoutConstraint.activate(suggestedStickersPanelConstraints)
}
// 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 suggestedStickersPanel.subviews.count > 0 else {
let zeroHeightConstraint = suggestedStickersPanel.heightAnchor.constraint(equalToConstant: 0)
suggestedStickersPanelConstraints = [ 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 isSuggestedStickersPanelHidden {
constraint = suggestedStickersPanel.bottomAnchor.constraint(equalTo: suggestedStickersListView.topAnchor)
} else {
constraint = suggestedStickersPanel.bottomAnchor.constraint(equalTo: suggestedStickersListView.bottomAnchor)
}
suggestedStickersPanelConstraints = [ constraint ]
}
private var isSuggestedStickersPanelHidden = 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 {
hideSuggestedStickersPanel(animated: animated)
return
}
showSuggestedStickersPanel(animated: animated)
}
private func showSuggestedStickersPanel(animated: Bool) {
owsAssertDebug(!suggestedStickerInfos.isEmpty)
if suggestedStickersListView.superview == nil {
suggestedStickersPanel.addSubview(suggestedStickersListView)
suggestedStickersListView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom)
UIView.performWithoutAnimation {
suggestedStickersPanel.layoutIfNeeded()
}
}
suggestedStickersListView.items = suggestedStickerInfos.map { stickerInfo in
StickerHorizontalListViewItemSticker(
stickerInfo: stickerInfo,
didSelectBlock: { [weak self] in
self?.didSelectSuggestedSticker(stickerInfo)
},
cache: suggestedStickerViewCache
)
}
guard isSuggestedStickersPanelHidden else { return }
isSuggestedStickersPanelHidden = false
UIView.performWithoutAnimation {
self.suggestedStickersListView.layoutIfNeeded()
self.suggestedStickersListView.contentOffset = CGPoint(
x: -self.suggestedStickersListView.contentInset.left,
y: -self.suggestedStickersListView.contentInset.top
)
}
guard animated else {
updateSuggestedStickersPanelConstraints()
return
}
isAnimatingHeightChange = true
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3
)
animator.addAnimations {
self.updateSuggestedStickersPanelConstraints()
self.layoutIfNeeded()
}
animator.addCompletion { _ in
self.isAnimatingHeightChange = false
}
animator.startAnimation()
}
private func hideSuggestedStickersPanel(animated: Bool) {
guard !isSuggestedStickersPanelHidden else { return }
isSuggestedStickersPanelHidden = true
guard animated else {
updateSuggestedStickersPanelConstraints()
return
}
isAnimatingHeightChange = true
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3
)
animator.addAnimations {
self.updateSuggestedStickersPanelConstraints()
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 lazy var voiceMemoDurationLabel: UILabel = {
let label = UILabel()
label.textAlignment = .left
label.textColor = .Signal.label
label.font = .monospacedDigitSystemFont(ofSize: UIFont.dynamicTypeBodyClamped.pointSize, weight: .semibold)
label.setContentHuggingHigh()
label.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "recordingLabel")
label.translatesAutoresizingMaskIntoConstraints = false
return label
}()
private lazy var voiceMemoCancelLabel: UILabel = {
let cancelLabelFont = UIFont.dynamicTypeSubheadlineClamped
let cancelArrowFontSize = cancelLabelFont.pointSize + 7
let cancelString = NSMutableAttributedString(
string: "\u{F104}",
attributes: [
.font: UIFont.awesomeFont(ofSize: cancelArrowFontSize),
.baselineOffset: -2
]
)
cancelString.append(
NSAttributedString(
string: " ",
attributes: [ .font: cancelLabelFont ]
)
)
cancelString.append(
NSAttributedString(
string: OWSLocalizedString("VOICE_MESSAGE_CANCEL_INSTRUCTIONS", comment: "Indicates how to cancel a voice message."),
attributes: [ .font: cancelLabelFont ]
)
)
cancelString.addAttributeToEntireString(.foregroundColor, value: UIColor.Signal.secondaryLabel)
let label = UILabel()
label.textAlignment = .right
label.attributedText = cancelString
label.translatesAutoresizingMaskIntoConstraints = false
label.sizeToFit()
return label
}()
private lazy var voiceMemoRedRecordingCircle: UIView = {
let micIconSize: CGFloat = 32
let circleSize: CGFloat = 88
let micIcon = UIImageView(image: UIImage(imageLiteralResourceName: "mic-fill"))
micIcon.tintColor = .white
let circleView = CircleView(frame: CGRect(origin: .zero, size: .square(circleSize)))
circleView.backgroundColor = .Signal.red
circleView.addSubview(micIcon)
circleView.translatesAutoresizingMaskIntoConstraints = false
micIcon.translatesAutoresizingMaskIntoConstraints = false
circleView.addConstraints([
micIcon.widthAnchor.constraint(equalToConstant: micIconSize),
micIcon.heightAnchor.constraint(equalToConstant: micIconSize),
micIcon.centerXAnchor.constraint(equalTo: circleView.centerXAnchor),
micIcon.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),
circleView.widthAnchor.constraint(equalToConstant: circleSize),
circleView.heightAnchor.constraint(equalToConstant: circleSize),
])
return circleView
}()
private lazy var voiceMemoLockView: VoiceMemoLockView = {
let view = VoiceMemoLockView()
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private lazy var voiceMemoDeleteButton: UIButton = {
#if compiler(>=6.2)
guard #unavailable(iOS 26.0) else {
return Buttons.deleteVoiceMemoDraftButton(
primaryAction: UIAction { [weak self] _ in
self?.deleteVoiceMemoDraft()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "stickerButton")
)
}
#endif
let button = UIButton(
configuration: .plain(),
primaryAction: UIAction { [weak self] _ in
self?.deleteVoiceMemoDraft()
}
)
button.configuration?.image = UIImage(imageLiteralResourceName: "trash-fill")
button.configuration?.baseForegroundColor = .Signal.red
return button
}()
func showVoiceMemoUI() {
AssertIsOnMainThread()
isShowingVoiceMemoUI = true
// Prepare initial state.
removeVoiceMemoTooltip()
voiceMemoStartTime = Date()
voiceMemoLockView.update(ratioComplete: 0)
voiceMemoContentView.removeAllSubviews()
// These are added to self.
voiceMemoRedRecordingCircle.removeFromSuperview()
voiceMemoLockView.removeFromSuperview()
// Red mic icon
let redMicIconImageView = UIImageView(image: UIImage(imageLiteralResourceName: "mic-fill"))
redMicIconImageView.tintColor = .Signal.red
redMicIconImageView.autoSetDimensions(to: .square(24))
voiceMemoContentView.addSubview(redMicIconImageView)
// Duration Label
updateVoiceMemoDurationLabel()
voiceMemoContentView.addSubview(voiceMemoDurationLabel)
// < Swipe to Cancel
voiceMemoCancelLabel.alpha = 1
voiceMemoContentView.addSubview(voiceMemoCancelLabel)
// Constraints for the content inside of text input box.
redMicIconImageView.translatesAutoresizingMaskIntoConstraints = false
voiceMemoContentView.addConstraints([
redMicIconImageView.leadingAnchor.constraint(equalTo: voiceMemoContentView.leadingAnchor, constant: 12),
redMicIconImageView.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor),
voiceMemoDurationLabel.leadingAnchor.constraint(equalTo: redMicIconImageView.trailingAnchor, constant: 12),
voiceMemoDurationLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor),
// X-position is configured relative to big red circle - later in this method.
voiceMemoCancelLabel.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor, constant: -2),
])
// Big red circle with mic icon inside and lock icon above.
let redCircleCenterXAnchor: NSLayoutXAxisAnchor
if let rightEdgeControls = trailingEdgeControl as? RightEdgeControlsView {
redCircleCenterXAnchor = rightEdgeControls.voiceMemoButton.centerXAnchor
} else {
redCircleCenterXAnchor = voiceNoteButton.centerXAnchor
}
addSubview(voiceMemoLockView)
addSubview(voiceMemoRedRecordingCircle)
addConstraints([
voiceMemoRedRecordingCircle.centerXAnchor.constraint(equalTo: redCircleCenterXAnchor),
voiceMemoRedRecordingCircle.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor),
voiceMemoLockView.centerXAnchor.constraint(equalTo: redCircleCenterXAnchor),
voiceMemoLockView.topAnchor.constraint(equalTo: voiceMemoRedRecordingCircle.topAnchor, constant: -120),
voiceMemoCancelLabel.trailingAnchor.constraint(equalTo: voiceMemoRedRecordingCircle.leadingAnchor, constant: -16),
])
// Animations
// Animate in red circle and lock view (lock view - with a delay).
UIView.performWithoutAnimation {
voiceMemoRedRecordingCircle.alpha = 0
voiceMemoRedRecordingCircle.transform = .scale(0.9)
voiceMemoLockView.alpha = 0
voiceMemoLockView.transform = .scale(0.9)
}
UIView.animate(withDuration: 0.2) {
self.voiceMemoRedRecordingCircle.alpha = 1
self.voiceMemoRedRecordingCircle.transform = .identity
}
UIView.animate(withDuration: 0.2, delay: 1) {
self.voiceMemoLockView.alpha = 1
self.voiceMemoLockView.transform = .identity
}
// Pulse the red mic icon on the left.
redMicIconImageView.alpha = 1
UIView.animate(
withDuration: 0.5,
delay: 0.2,
options: [.repeat, .autoreverse, .curveEaseIn],
animations: {
redMicIconImageView.alpha = 0
}
)
// Start recording timer.
voiceMemoUpdateTimer?.invalidate()
voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
self.updateVoiceMemoDurationLabel()
}
}
func showVoiceMemoDraft(_ voiceMemoDraft: VoiceMessageInterruptedDraft) {
AssertIsOnMainThread()
isShowingVoiceMemoUI = true
voiceMemoRecordingState = .draft
removeVoiceMemoTooltip()
voiceMemoContentView.removeAllSubviews()
// These are added to self.
voiceMemoRedRecordingCircle.removeFromSuperview()
voiceMemoLockView.removeFromSuperview()
voiceMemoUpdateTimer?.invalidate()
voiceMemoUpdateTimer = nil
let draftView = VoiceMessageDraftView(
voiceMessageInterruptedDraft: voiceMemoDraft,
mediaCache: mediaCache
)
voiceMemoContentView.addSubview(draftView)
draftView.translatesAutoresizingMaskIntoConstraints = false
voiceMemoContentView.addConstraints([
draftView.topAnchor.constraint(equalTo: voiceMemoContentView.topAnchor),
draftView.leadingAnchor.constraint(equalTo: voiceMemoContentView.leadingAnchor),
draftView.trailingAnchor.constraint(equalTo: voiceMemoContentView.trailingAnchor),
draftView.bottomAnchor.constraint(equalTo: voiceMemoContentView.bottomAnchor),
])
self.voiceMemoDraft = voiceMemoDraft
}
private func deleteVoiceMemoDraft() {
guard let voiceMemoDraft else {
owsFailBeta("No voice memo draft")
return
}
voiceMemoDraft.audioPlayer.stop()
SSKEnvironment.shared.databaseStorageRef.asyncWrite {
voiceMemoDraft.clearDraft(transaction: $0)
} completion: {
self.hideVoiceMemoUI(animated: true)
}
}
func hideVoiceMemoUI(animated: Bool) {
AssertIsOnMainThread()
isShowingVoiceMemoUI = false
voiceMemoContentView.removeAllSubviews()
voiceMemoRecordingState = .idle
voiceMemoDraft = nil
voiceMemoUpdateTimer?.invalidate()
voiceMemoUpdateTimer = nil
guard voiceMemoRedRecordingCircle.superview != nil else { return }
if animated {
UIView.animate(
withDuration: 0.2,
animations: {
let scale: CGFloat = 0.9
self.voiceMemoRedRecordingCircle.alpha = 0
// Red circle might have a translation transorm - make sure to preserve it.
self.voiceMemoRedRecordingCircle.transform = self.voiceMemoRedRecordingCircle.transform.scaledBy(x: scale, y: scale)
self.voiceMemoLockView.alpha = 0
self.voiceMemoLockView.transform = .scale(scale)
},
completion: { _ in
self.voiceMemoRedRecordingCircle.removeFromSuperview()
self.voiceMemoLockView.removeFromSuperview()
}
)
} else {
voiceMemoRedRecordingCircle.removeFromSuperview()
voiceMemoLockView.removeFromSuperview()
}
}
func lockVoiceMemoUI() {
ImpactHapticFeedback.impactOccurred(style: .medium)
let cancelButton = UIButton(
configuration: .borderless(),
primaryAction: UIAction { [weak self] _ in
self?.inputToolbarDelegate?.voiceMemoGestureDidCancel()
}
)
cancelButton.alpha = 0
cancelButton.configuration?.baseForegroundColor = .Signal.red
cancelButton.configuration?.contentInsets = .init(margin: 8)
cancelButton.configuration?.title = CommonStrings.cancelButton
cancelButton.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped)
cancelButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cancelButton")
voiceMemoContentView.addSubview(cancelButton)
cancelButton.translatesAutoresizingMaskIntoConstraints = false
voiceMemoContentView.addConstraints([
cancelButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor),
cancelButton.trailingAnchor.constraint(equalTo: voiceMemoContentView.trailingAnchor, constant: -16),
])
voiceMemoCancelLabel.removeFromSuperview()
voiceMemoContentView.layoutIfNeeded()
UIView.animate(
withDuration: 0.2,
animations: {
let scale: CGFloat = 0.9
self.voiceMemoRedRecordingCircle.alpha = 0
self.voiceMemoRedRecordingCircle.transform = self.voiceMemoRedRecordingCircle.transform.scaledBy(x: scale, y: scale)
self.voiceMemoLockView.alpha = 0
self.voiceMemoLockView.transform = .scale(scale)
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 updateVoiceMemoDurationLabel() {
AssertIsOnMainThread()
defer {
voiceMemoDurationLabel.sizeToFit()
}
guard let voiceMemoStartTime else {
voiceMemoDurationLabel.text = ""
return
}
let durationSeconds = abs(voiceMemoStartTime.timeIntervalSinceNow)
voiceMemoDurationLabel.text = OWSFormat.formatDurationSeconds(Int(round(durationSeconds)))
}
func showVoiceMemoTooltip() {
guard voiceMemoTooltipView == nil else { return }
guard let rightEdgeControlsView = trailingEdgeControl as? RightEdgeControlsView 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 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 var stickerKeyboard: StickerKeyboard {
if let stickerKeyboard = _stickerKeyboard {
return stickerKeyboard
}
let stickerKeyboard = StickerKeyboard(delegate: 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)
_attachmentKeyboard = keyboard
return keyboard
}
func showAttachmentKeyboard() {
AssertIsOnMainThread()
guard desiredKeyboardType != .attachment else { return }
toggleKeyboardType(.attachment, animated: false)
}
private func toggleKeyboardType(_ keyboardType: KeyboardType, animated: Bool) {
guard let 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 }
// Measure system keyboard size when switching away from it,
// but only if we don't know the height for this orientation yet.
if desiredKeyboardType == .system,
inputTextView.isFirstResponder,
!CustomKeyboard.hasCachedHeight(forTraitCollection: traitCollection)
{
calculateCustomKeyboardHeight()
}
_desiredKeyboardType = keyboardType
ensureButtonVisibility(withAnimation: animated, doLayout: true)
inputTextView.inputView = desiredInputView
inputTextView.reloadInputViews()
// Add "Tap to switch to system keyboard" behavior.
if desiredKeyboardType == .system {
inputTextView.removeGestureRecognizer(textInputViewTapGesture)
} else if textInputViewTapGesture.view == nil {
inputTextView.addGestureRecognizer(textInputViewTapGesture)
}
}
private func calculateCustomKeyboardHeight() {
guard desiredKeyboardType == .system, inputTextView.isFirstResponder else { return }
let viewForKeyboardLayoutGuide = inputToolbarDelegate?.viewForKeyboardLayoutGuide() ?? self
let keyboardHeight = viewForKeyboardLayoutGuide.keyboardLayoutGuide.layoutFrame.height
if keyboardHeight > 100 {
Logger.debug("Keyboard height: \(keyboardHeight). Horizontal: \(traitCollection.horizontalSizeClass) Vertical: \(traitCollection.verticalSizeClass)")
stickerKeyboard.setSystemKeyboardHeight(keyboardHeight, forTraitCollection: traitCollection)
attachmentKeyboard.setSystemKeyboardHeight(keyboardHeight, forTraitCollection: traitCollection)
} else {
Logger.warn("Suspicious keyboard height: \(keyboardHeight)")
}
}
func clearDesiredKeyboard() {
AssertIsOnMainThread()
desiredKeyboardType = .system
}
private func restoreDesiredKeyboardIfNecessary() {
AssertIsOnMainThread()
if desiredKeyboardType != .system && !inputTextView.isFirstResponder {
beginEditingMessage()
}
}
var isInputViewFirstResponder: Bool {
return inputTextView.isFirstResponder
}
private var desiredInputView: UIInputView? {
switch desiredKeyboardType {
case .system: return nil
case .sticker: return stickerKeyboard
case .attachment: return attachmentKeyboard
}
}
func beginEditingMessage() {
_ = inputTextView.becomeFirstResponder()
}
func endEditingMessage() {
_ = inputTextView.resignFirstResponder()
}
func viewDidAppear() {
ensureButtonVisibility(withAnimation: false, doLayout: false)
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
// Starting with iOS 17 UIKit messes up keyboard layout guide on rotation if custom keyboard is up.
// That causes the keyboard to overlap text input field and become unaccessible.
// The workaround is to hide the keyboard on rotation.
guard #available(iOS 17, *) else { return }
// Require a custom keyboard to be up.
guard inputTextView.isFirstResponder, desiredKeyboardType != .system else { return }
// We only care about changes in size classes, which would be triggered by interface rotation.
guard
previousTraitCollection?.horizontalSizeClass != traitCollection.horizontalSizeClass ||
previousTraitCollection?.verticalSizeClass != traitCollection.verticalSizeClass
else { return }
// Dismiss keyboard.
endEditingMessage()
}
@objc
private func applicationDidBecomeActive(notification: Notification) {
AssertIsOnMainThread()
restoreDesiredKeyboardIfNecessary()
}
private lazy var textInputViewTapGesture = UITapGestureRecognizer(target: self, action: #selector(textInputViewTapped))
@objc
private func textInputViewTapped() {
clearDesiredKeyboard()
}
}
// MARK: Button Actions
extension ConversationInputToolbar {
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)
}
}
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()
}
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)
}
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 textToMeasure: NSAttributedString = textView.attributedText
if textToMeasure.isEmpty {
textToMeasure = NSAttributedString(string: "M", attributes: [.font: textView.font ?? .dynamicTypeBody])
}
var contentSize = textToMeasure.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.rounded(),
min: LayoutMetrics.minTextViewHeight,
max: UIDevice.current.isIPad ? LayoutMetrics.maxTextViewHeightIpad : 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()
}
}
public override func didMoveToSuperview() {
super.didMoveToSuperview()
// Probably because of a regression in iOS 26 `keyboardLayoutGuide`,
// if first accessed in `calculateCustomKeyboardHeight`, would have an
// incorrect height of 34 dp (amount of bottom safe area).
// Accessing the layout guide before somehow fixes that issue.
if #available(iOS 26, *), superview != nil {
_ = keyboardLayoutGuide
}
}
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 {
setSendButtonEnabled(textView.hasText)
}
}
func textViewDidChangeSelection(_ textView: UITextView) { }
}
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()
}
func didTapPoll() {
inputToolbarDelegate?.pollButtonPressed()
}
var isGroup: Bool {
inputToolbarDelegate?.isGroup() ?? false
}
}