Signal-iOS/Signal/ConversationView/ConversationInputToolbar.swift
2026-05-31 10:12:11 -07:00

3338 lines
130 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
/// Return a view where `ConversationInputToolbar` should place suggested stickers panel.
/// This view must contain `ConversationInputToolbar` otherwise the behavior is undefined (we'll crash).
func viewForSuggestedStickersPanel() -> 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: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits)
func showUnblockConversationUI(completion: ((Bool) -> Void)?)
}
public class ConversationInputToolbar: UIView, 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,
bodyRangesTextViewDelegate: 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() }
setupContentView()
createContentsWithMessageDraft(
messageDraft,
quotedReplyDraft: quotedReplyDraft,
inputTextViewDelegate: inputTextViewDelegate,
bodyRangesTextViewDelegate: bodyRangesTextViewDelegate,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(applicationDidBecomeActive(notification:)),
name: .OWSApplicationDidBecomeActive,
object: nil,
)
if #available(iOS 17, *) {
inputTextView.registerForTraitChanges(
[UITraitPreferredContentSizeCategory.self],
) { [weak self] (textView: UITextView, _) in
self?.updateTextViewFontSize()
}
} else {
contentSizeChangeNotificationObserver = NotificationCenter.default.addObserver(
name: UIContentSizeCategory.didChangeNotification,
) { [weak self] _ in
self?.updateTextViewFontSize()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
if let contentSizeChangeNotificationObserver {
NotificationCenter.default.removeObserver(contentSizeChangeNotificationObserver)
}
}
// MARK: Layout Configuration.
override public var frame: CGRect {
didSet {
guard oldValue.size.height != frame.size.height else { return }
inputToolbarDelegate?.updateToolbarHeight()
}
}
override public var bounds: CGRect {
didSet {
guard abs(oldValue.size.height - bounds.size.height) > 1 else { return }
inputToolbarDelegate?.updateToolbarHeight()
}
}
override public func willMove(toSuperview newSuperview: UIView?) {
super.willMove(toSuperview: newSuperview)
// Suggested sticker panel is placed outside of ConversationInputToolbar
// and need to be removed manually.
if newSuperview == nil, !isStickerPanelHidden {
stickerPanel.removeFromSuperview()
}
}
override public func didMoveToSuperview() {
super.didMoveToSuperview()
guard superview != nil else { return }
// Show suggested stickers for the draft as soon as we are placed in the view hierarchy.
updateSuggestedStickers(animated: false)
// 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, *) {
_ = keyboardLayoutGuide
}
}
func update(conversationStyle: ConversationStyle) {
self.conversationStyle = conversationStyle
if #available(iOS 26, *), let sendButton = trailingEdgeControl as? UIButton {
sendButton.tintColor = conversationStyle.chatColorValue.asChatUIElementTintColor()
}
}
private enum LayoutMetrics {
static let initialToolbarHeight: CGFloat = 56
static let initialTextBoxHeight: CGFloat = 40
static let minTextViewHeight: CGFloat = 35
static let maxTextViewHeight: CGFloat = 98
static let maxTextViewHeightIpad: CGFloat = 142
}
public enum Style {
@available(iOS 26, *)
static func glassEffect(isInteractive: Bool = false) -> UIGlassEffect {
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.tintColor = .Signal.glassBackgroundTint
glassEffect.isInteractive = isInteractive
return glassEffect
}
static var primaryTextColor: UIColor {
.Signal.label
}
static var secondaryTextColor: UIColor {
.Signal.secondaryLabel
}
static var buttonTintColor: UIColor {
if #available(iOS 26, *) {
return .Signal.label
}
return UIColor(
light: Theme.lightThemeLegacyPrimaryIconColor,
dark: Theme.darkThemeLegacyPrimaryIconColor,
)
}
}
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
button.configuration?.baseForegroundColor = Style.buttonTintColor
button.accessibilityLabel = accessibilityLabel
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
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
}
@available(iOS 26.0, *)
static func sendButton(
primaryAction: UIAction?,
accessibilityIdentifier: String?,
) -> UIButton {
let buttonSize = LayoutMetrics.initialTextBoxHeight
let button = UIButton(
configuration: .prominentGlass(),
primaryAction: primaryAction,
)
// Button's tint color is set externalley (from `conversationStyle`).
button.configuration?.image = Theme.iconImage(.arrowUp)
button.configuration?.baseForegroundColor = .white
button.configuration?.cornerStyle = .capsule
button.accessibilityLabel = MessageStrings.sendButton
button.accessibilityIdentifier = accessibilityIdentifier
button.isPointerInteractionEnabled = true
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 = .Signal.glassBackgroundTint
button.configuration?.image = UIImage(imageLiteralResourceName: "plus")
button.configuration?.baseForegroundColor = Style.buttonTintColor
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.isPointerInteractionEnabled = true
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.isPointerInteractionEnabled = true
button.translatesAutoresizingMaskIntoConstraints = false
button.addConstraints([
button.widthAnchor.constraint(equalToConstant: buttonSize),
button.heightAnchor.constraint(equalToConstant: buttonSize),
])
return button
}
}
private lazy var inputTextView: ConversationInputTextView = {
let inputTextView = ConversationInputTextView()
inputTextView.textViewToolbarDelegate = self
inputTextView.font = .dynamicTypeBody
inputTextView.textColor = Style.primaryTextColor
inputTextView.placeholderTextColor = Style.secondaryTextColor
inputTextView.semanticContentAttribute = .forceLeftToRight
inputTextView.setContentHuggingVerticalHigh()
inputTextView.setCompressionResistanceLow()
inputTextView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "inputTextView")
return inputTextView
}()
private lazy var leadingEdgeControl: UIView = {
guard #unavailable(iOS 26.0) else {
return Buttons.addAttachmentButton(
primaryAction: UIAction { [weak self] _ in
self?.addOrCancelButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "attachmentButton"),
)
}
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 #available(iOS 26, *) {
let button = Buttons.sendButton(
primaryAction: UIAction { [weak self] _ in
self?.sendButtonPressed()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "sendButton"),
)
button.tintColor = conversationStyle.bubbleChatColorOutgoing.asChatUIElementTintColor()
return button
}
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 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
}()
private var glassContainerView: UIView?
private var legacyBackgroundView: UIView?
private var legacyBackgroundBlurView: UIVisualEffectView?
/// 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()
@available(iOS 26, *)
func setScrollEdgeElementContainerInteraction(_ interaction: UIInteraction) {
owsAssertBeta(glassContainerView != nil)
glassContainerView?.addInteraction(interaction)
}
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
contentView.semanticContentAttribute = .forceLeftToRight
let contentViewSuperview: UIView
if #available(iOS 26, *) {
iOS26Layout = true
// Glass Container.
let glassContainerView = UIVisualEffectView(effect: UIGlassContainerEffect())
addSubview(glassContainerView)
glassContainerView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
glassContainerView.topAnchor.constraint(equalTo: topAnchor),
glassContainerView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
glassContainerView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
glassContainerView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
contentViewSuperview = glassContainerView.contentView
self.glassContainerView = glassContainerView
} else {
// Background needed on pre-iOS 26 devices.
// The background is stretched to all edges to cover any safe area gaps.
let backgroundView = UIView()
if UIAccessibility.isReduceTransparencyEnabled {
backgroundView.backgroundColor = .Signal.background
} else {
let blurEffectView = UIVisualEffectView(effect: nil) // will be updated later
backgroundView.addSubview(blurEffectView)
blurEffectView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
blurEffectView.topAnchor.constraint(equalTo: backgroundView.topAnchor),
blurEffectView.leadingAnchor.constraint(equalTo: backgroundView.leadingAnchor),
blurEffectView.trailingAnchor.constraint(equalTo: backgroundView.trailingAnchor),
blurEffectView.bottomAnchor.constraint(equalTo: backgroundView.bottomAnchor),
])
// Set background color and visual effect.
updateBackgroundColors(backgroundView: backgroundView, backgroundBlurView: blurEffectView)
// Remember these views so that we can update colors on traitCollection changes.
self.legacyBackgroundView = backgroundView
self.legacyBackgroundBlurView = blurEffectView
}
addSubview(backgroundView)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
backgroundView.topAnchor.constraint(equalTo: topAnchor),
backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
// extend background view down to cover any potentian gaps between input toolbar and keyboard.
backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 200),
])
contentViewSuperview = self
}
// Set up content view.
contentView.directionalLayoutMargins = NSDirectionalEdgeInsets(
hMargin: OWSTableViewController2.defaultHOuterMargin - 16,
vMargin: iOS26Layout ? 0.5 * (LayoutMetrics.initialToolbarHeight - LayoutMetrics.initialTextBoxHeight) : 0,
)
contentViewSuperview.addSubview(contentView)
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.leadingAnchor.constraint(equalTo: safeAreaLayoutGuide.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: safeAreaLayoutGuide.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: 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
// Initial state for "Editing Message" label
if isEditingMessage {
loadEditMessageViewIfNecessary()
editMessageViewVisibleConstraint.isActive = true
}
// Initial state for the quoted message snippet.
quotedReplyViewConstraints = [
quotedReplyWrapper.heightAnchor.constraint(equalToConstant: 0),
]
NSLayoutConstraint.activate(quotedReplyViewConstraints)
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
textViewHeightConstraint = inputTextView.heightAnchor.constraint(equalToConstant: LayoutMetrics.minTextViewHeight)
inputTextViewContainer.addConstraints([
// This defines height of `inputTextView` which is always set to content size. calculated in `updateHeightWithTextView()`
textViewHeightConstraint,
// This sets minimum height on visual text view box. This height can exceed height of an empty inputTextView.
// We don't want `inputTextView` to grow above it's content size because that causes
// incorrect (top) alignment of text when there's just a single line of it.
inputTextViewContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: LayoutMetrics.initialTextBoxHeight),
// This lets `inputTextViewContainer` grow with `inputTextView` when height of the latter increases with text.
// Working in conjuction with the next constraint they center `inputTextView` vertically
// when it's height is below minimum height of `inputTextViewContainer`.
inputTextView.topAnchor.constraint(greaterThanOrEqualTo: inputTextViewContainer.topAnchor),
inputTextView.centerYAnchor.constraint(equalTo: inputTextViewContainer.centerYAnchor),
// This constraint doesn't allow `inputTextViewContainer` to grow uncontrollably in height
// when mentions picker is placed above, being constrained between VC's root view's top edge
// and ConversationInputToolbar's top edge.
// Priority is set to not conflict with the constraints above, but still be higher
// than vertical hugging priority of the mentions picker.
{
let c = inputTextView.topAnchor.constraint(equalTo: inputTextViewContainer.topAnchor)
c.priority = .defaultHigh
return c
}(),
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.
let backgroundView: UIView
let cornerRadius = LayoutMetrics.initialTextBoxHeight / 2
if #available(iOS 26, *) {
let glassEffectView = UIVisualEffectView(effect: Style.glassEffect(isInteractive: true))
glassEffectView.cornerConfiguration = .uniformCorners(radius: .fixed(cornerRadius))
glassEffectView.contentView.addSubview(messageComponentsView)
backgroundView = glassEffectView
messageContentView.addSubview(backgroundView)
} else {
backgroundView = UIView()
backgroundView.backgroundColor = UIColor.Signal.tertiaryFill
backgroundView.layer.cornerRadius = cornerRadius
messageContentView.addSubview(backgroundView)
messageContentView.addSubview(messageComponentsView)
}
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
backgroundView.semanticContentAttribute = .forceLeftToRight
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
inputTextViewContainer.addSubview(stickerButton)
inputTextViewContainer.addSubview(keyboardButton)
inputTextViewContainer.addSubview(cameraButton)
inputTextViewContainer.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.bottomAnchor.constraint(equalTo: inputTextViewContainer.bottomAnchor),
cameraButton.bottomAnchor.constraint(equalTo: inputTextViewContainer.bottomAnchor),
stickerButton.bottomAnchor.constraint(equalTo: inputTextViewContainer.bottomAnchor),
keyboardButton.bottomAnchor.constraint(equalTo: inputTextViewContainer.bottomAnchor),
])
} else {
inputTextView.inFieldButtonsAreaWidth = 1 * LayoutMetrics.initialTextBoxHeight
inputTextViewContainer.addSubview(stickerButton)
inputTextViewContainer.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
}
// MARK: Layout Updates.
@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
let animator: UIViewPropertyAnimator?
if isAnimated {
animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 0.645, springResponse: 0.25)
} else {
animator = nil
}
//
// 1. Show / hide Voice Memo UI.
//
voiceMemoContentView.setIsHidden(isShowingVoiceMemoUI == false, animated: isAnimated)
//
// 2. Update leading edge control.
//
// Possible states of the leading edge control:
// * (+) attachment button: when there is no voice note UI visible.
// * Delete Voice Note button: when there's a voice note draft.
// * No control: when there's voice note recording in progress.
let leadingEdgeControlState: LeadingEdgeControlState = {
if isShowingVoiceMemoUI {
return voiceMemoRecordingState == .draft ? .deleteVoiceMemoDraft : .none
}
return .addAttachment
}()
if setLeadingEdgeControlState(leadingEdgeControlState, usingAnimator: animator) {
hasLayoutChanged = true
}
// (+) attachment button can be displayed in its alternative appearance - as (X) button in two cases:
// * attachment keyboard is displayed.
// * user is editing a message.
if let attachmentButton = leadingEdgeControl as? AttachmentButtonProtocol {
let buttonState: AttachmentButtonState = {
if isEditingMessage {
return .close
} else {
return desiredKeyboardType == .attachment ? .close : .add
}
}()
attachmentButton.setButtonState(buttonState, usingAnimator: animator)
}
//
// 3. Determine state of the trailing edge controls.
//
let rightEdgeControlsState: TrailingEdgeControlState
// Voice recording is in progress in "locked" state: show Send button in active state.
// In all other voice note recording states there are no trailing edge controls.
if isShowingVoiceMemoUI {
let showSendButton: Bool = {
switch voiceMemoRecordingState {
case .recordingLocked, .draft:
true
default:
false
}
}()
rightEdgeControlsState = showSendButton ? .sendButton : .hiddenSendButton
}
// Text field has non-whitespace input: show Send button in active state.
// Note: Activating "edit message" feature would temporarily disable Send button
// even if there is non-whitespace text. Editing text would re-enable Send button.
else if hasMessageText {
rightEdgeControlsState = .sendButton
}
// If there's a quoted message or we're editing a message: show inactive Send button.
else if isEditingMessage {
rightEdgeControlsState = .disabledSendButton
}
// No input, not editing message, no quoted message: do not show Send button.
// On iOS 26 there would be no right edge controls.
// On iOS 15-18 there would be Camera and Mic buttons.
else {
rightEdgeControlsState = .default
}
//
// 4. Update middle part: text input field and buttons inside.
//
// Only ever show in-field buttons when there's no Send button visible on the right or when
// text input contains newlines (that increases text box's height).
// On iOS 26 there are Camera and Voice Note buttons inside of the text input field:
// those would be hidden to match pre-iOS 26 behavior.
let hideAllTextFieldButtons = rightEdgeControlsState != .default || inputTextView.untrimmedText.rangeOfCharacter(from: .newlines) != nil
// Sticker/keyboard buttons will also be hidden if there's whitespace-only input.
let textFieldHasAnyInput = !inputTextView.untrimmedText.isEmpty
let hideInputMethodButtons = hideAllTextFieldButtons || textFieldHasAnyInput || hasQuotedMessage
let hideStickerButton = hideInputMethodButtons || desiredKeyboardType == .sticker
let hideKeyboardButton = hideInputMethodButtons || !hideStickerButton
ConversationInputToolbar.setView(stickerButton, hidden: hideStickerButton, usingAnimator: animator)
ConversationInputToolbar.setView(keyboardButton, hidden: hideKeyboardButton, usingAnimator: animator)
if iOS26Layout {
ConversationInputToolbar.setView(cameraButton, hidden: hideAllTextFieldButtons, usingAnimator: animator)
ConversationInputToolbar.setView(voiceNoteButton, hidden: hideAllTextFieldButtons, usingAnimator: animator)
}
// Text input is hidden whenever Voice Message UI is presented.
// Change view's opacity instead of `isHidden` because the latter will cause inputTextView to lose focus.
let inputTextViewAlpha: CGFloat = isShowingVoiceMemoUI ? 0 : 1
if let animator {
animator.addAnimations {
self.inputTextView.alpha = inputTextViewAlpha
}
} else {
inputTextView.alpha = inputTextViewAlpha
}
//
// 5. Apply changes to trailing edge controls.
//
// iOS 15-18: update trailing edge controls view.
if
let rightEdgeControlsView = trailingEdgeControl as? RightEdgeControlsView,
rightEdgeControlsView.state != rightEdgeControlsState
{
hasLayoutChanged = true
if let animator {
// `state` in implicitly animatable.
animator.addAnimations {
rightEdgeControlsView.state = rightEdgeControlsState
}
} else {
rightEdgeControlsView.state = rightEdgeControlsState
}
}
// iOS 26: Update Send button state.
if iOS26Layout, let sendButton = trailingEdgeControl as? UIButton {
let hideSendButton: Bool
var disableSendButton = false
switch rightEdgeControlsState {
case .default:
hideSendButton = true
case .sendButton:
hideSendButton = false
case .disabledSendButton:
hideSendButton = false
disableSendButton = true
case .hiddenSendButton:
hideSendButton = true
}
let sendButtonVisibilityChanges = setSendButtonHidden(hideSendButton, usingAnimator: animator)
if sendButtonVisibilityChanges {
hasLayoutChanged = true
}
// Enable/disable Send button, taking potential visibility changes into account.
if hideSendButton, sendButtonVisibilityChanges {
// If Send button becomes hidden do not update `isEnabled` until animation completes.
if let animator {
animator.addCompletion { _ in
sendButton.isEnabled = !disableSendButton
}
} else {
sendButton.isEnabled = !disableSendButton
}
} else {
// If Send button becomes visible or becomes enabled/disabled while being visible
// we need to apply changes to `isEnabled` right away.
sendButton.isEnabled = !disableSendButton
}
}
//
// 6. Commit animations.
//
if let animator {
if doLayout, hasLayoutChanged {
animator.addAnimations {
self.setNeedsLayout()
self.layoutIfNeeded()
}
}
animator.startAnimation()
} else {
if doLayout, hasLayoutChanged {
self.setNeedsLayout()
self.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 {
/// No control.
case none
/// (+) button.
case addAttachment
/// Red 🗑 delete Voice Note button.
case deleteVoiceMemoDraft
}
private enum TrailingEdgeControlState {
/// No control on iOS 26+. Camera and Mic on iOS 15-18.
case `default`
/// Active Send button.
case sendButton
/// Inactive Send button.
case disabledSendButton
/// Send button not visible, but the space for is is reserved.
case hiddenSendButton
}
@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()
}
// Dynamic color and visual effect support for background view(s) on iOS 15-18.
@available(iOS, deprecated: 26)
private func updateBackgroundColors(backgroundView: UIView, backgroundBlurView: UIVisualEffectView) {
let backgroundColor = UIColor.Signal.background
.resolvedColor(with: traitCollection)
.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
backgroundView.backgroundColor = backgroundColor
// Match Theme.barBlurEffect.
backgroundBlurView.effect =
traitCollection.userInterfaceStyle == .dark
? UIBlurEffect(style: .dark)
: UIBlurEffect(style: .light)
// 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 = backgroundBlurView.subviews.first(where: {
String(describing: type(of: $0)) == "_UIVisualEffectSubview"
})
{
tintingView.backgroundColor = backgroundColor
}
}
// MARK: Right Edge Buttons
@available(iOS, deprecated: 26.0)
private class RightEdgeControlsView: UIView {
typealias State = TrailingEdgeControlState
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.isPointerInteractionEnabled = 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 = Style.buttonTintColor
button.isPointerInteractionEnabled = true
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 = Style.buttonTintColor
button.isPointerInteractionEnabled = true
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, .disabledSendButton, .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, .disabledSendButton, .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
sendButton.isEnabled = state == .sendButton
}
}
override var intrinsicContentSize: CGSize {
let width: CGFloat = {
switch state {
case .default: return cameraButton.width + voiceMemoButton.width + 2 * Self.cameraButtonHMargin
case .sendButton, .disabledSendButton, .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"))
override private init(frame: CGRect) {
super.init(frame: frame)
isPointerInteractionEnabled = true
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 = Style.buttonTintColor
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)
}
}
}
@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)
}
}
}
// MARK: Message Body
private var hasMessageText: Bool { inputTextView.trimmedText.isEmpty == false }
private var textViewHeight: CGFloat = 0
private var textViewHeightConstraint: NSLayoutConstraint!
class var heightChangeAnimationDuration: TimeInterval { 0.25 }
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()
resetKeyboardLayout()
}
/// Resets the iOS keyboard from the symbols/numbers pane back to the
/// default alphabetic layout by toggling ``keyboardType``. Each
/// reload is required the first forces the keyboard to tear down
/// its current layout, and the second rebuilds it in the default
/// alpha state. Does not affect the user's selected language.
private func resetKeyboardLayout() {
guard inputTextView.inputView == nil, inputTextView.isFirstResponder else { return }
let original = inputTextView.keyboardType
inputTextView.keyboardType = (original == .default) ? .emailAddress : .default
inputTextView.reloadInputViews()
inputTextView.keyboardType = original
inputTextView.reloadInputViews()
}
// MARK: Content Size Change Handling
// Unused on iOS 17 and later.
private var contentSizeChangeNotificationObserver: NotificationCenter.Observer?
private func updateTextViewFontSize() {
inputTextView.font = .dynamicTypeBody
updateHeightWithTextView(inputTextView)
}
// MARK: Edit Message
var isEditingMessage: Bool { editTarget != nil }
var editTarget: TSOutgoingMessage? {
didSet {
let animateChanges = window != nil
// Show the 'editing' tag
if let 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 lazy var editMessageThumbnailView: UIImageView = {
let imageView = UIImageView()
imageView.layer.cornerRadius = 4
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
return imageView
}()
private lazy var editMessageLabelView: UIView = {
let editIconView = UIImageView(image: Theme.iconImage(.compose16))
editIconView.contentMode = .scaleAspectFit
editIconView.setContentHuggingHigh()
editIconView.tintColor = Style.buttonTintColor
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 = Style.primaryTextColor
// Font produced via `.semibold()` is no longer dynamic
// and UILabel has to be updated when content size changes.
if #available(iOS 17, *) {
editLabel.registerForTraitChanges(
[UITraitPreferredContentSizeCategory.self],
handler: { (label: UILabel, _) in
label.font = UIFont.dynamicTypeSubheadlineClamped.semibold()
},
)
}
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)
NSLayoutConstraint.activate([
// per design specs, align using textLabel, not stackView
editLabel.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
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),
])
return view
}()
private lazy var editMessageLabelWrapper: UIView = {
let view = UIView.container()
view.clipsToBounds = true
view.translatesAutoresizingMaskIntoConstraints = false
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "editMessageWrapper")
return view
}()
private lazy var editMessageViewVisibleConstraint = editMessageLabelView.bottomAnchor.constraint(
equalTo: editMessageLabelWrapper.bottomAnchor,
)
private lazy var editMessageViewHiddenConstraint = editMessageLabelView.bottomAnchor.constraint(
equalTo: editMessageLabelWrapper.topAnchor,
)
private func loadEditMessageViewIfNecessary() {
guard editMessageLabelView.superview == nil else { return }
editMessageLabelView.translatesAutoresizingMaskIntoConstraints = false
editMessageLabelWrapper.addSubview(editMessageLabelView)
NSLayoutConstraint.activate([
editMessageLabelView.topAnchor.constraint(equalTo: editMessageLabelWrapper.topAnchor),
editMessageLabelView.leadingAnchor.constraint(equalTo: editMessageLabelWrapper.leadingAnchor),
editMessageLabelView.trailingAnchor.constraint(equalTo: editMessageLabelWrapper.trailingAnchor),
])
}
private func showEditMessageView(animated isAnimated: Bool) {
loadEditMessageViewIfNecessary()
guard isAnimated else {
editMessageLabelView.alpha = 1
editMessageViewHiddenConstraint.isActive = false
editMessageViewVisibleConstraint.isActive = true
return
}
UIView.performWithoutAnimation {
editMessageLabelView.alpha = 0
}
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3,
)
animator.addAnimations {
self.editMessageLabelView.alpha = 1
self.editMessageViewHiddenConstraint.isActive = false
self.editMessageViewVisibleConstraint.isActive = true
// We simply disable Send button until something (like user editing text) enables it back.
// Whether or not message text actually changes isn't tracked.
self.setSendButtonEnabled(false)
self.layoutIfNeeded()
}
animator.startAnimation()
}
private func hideEditMessageView(animated isAnimated: Bool) {
owsAssertDebug(editTarget == nil)
guard isAnimated else {
editMessageViewVisibleConstraint.isActive = false
editMessageViewHiddenConstraint.isActive = true
return
}
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3,
)
animator.addAnimations {
self.editMessageLabelView.alpha = 0
self.editMessageViewVisibleConstraint.isActive = false
self.editMessageViewHiddenConstraint.isActive = true
self.layoutIfNeeded()
}
animator.startAnimation()
}
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
private var hasQuotedMessage: Bool { quotedReplyDraft != nil }
var quotedReplyDraft: DraftQuotedReplyModel? {
didSet {
guard oldValue != quotedReplyDraft else { return }
layer.removeAllAnimations()
let animateChanges = window != nil
if hasQuotedMessage {
showQuotedReplyView(animated: animateChanges)
} else {
hideQuotedReplyView(animated: animateChanges)
}
// This would show / hide Stickers|Keyboard button.
ensureButtonVisibility(withAnimation: animateChanges, doLayout: true)
clearDesiredKeyboard()
}
}
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)
}
private lazy var quotedReplyWrapper: UIView = {
let view = UIView.container()
view.clipsToBounds = true
view.directionalLayoutMargins = .init(top: 6, leading: 6, bottom: 0, trailing: 6)
view.translatesAutoresizingMaskIntoConstraints = false
view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedReplyWrapper")
return view
}()
private var quotedReplyViewConstraints = [NSLayoutConstraint]()
private func showQuotedReplyView(animated isAnimated: Bool) {
guard let quotedReplyDraft else {
owsFailDebug("quotedReply == nil")
return
}
let oldMessagePreviewView = quotedReplyWrapper.subviews.first as? QuotedReplyPreview
let oldConstraints = quotedReplyViewConstraints
// New quoted message snippet.
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
// Resize message snippet to its final size.
// Don't constrain the bottom though - do so in the animation block.
// Bottom constrain will cause `quotedReplyWrapper` to grow vertically.
NSLayoutConstraint.activate([
quotedMessagePreview.topAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.topAnchor),
quotedMessagePreview.leadingAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.leadingAnchor),
quotedMessagePreview.trailingAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.trailingAnchor),
])
UIView.performWithoutAnimation {
quotedReplyWrapper.setNeedsLayout()
quotedReplyWrapper.layoutIfNeeded()
}
// New constraints.
let newConstraints = [
quotedMessagePreview.bottomAnchor.constraint(equalTo: quotedReplyWrapper.layoutMarginsGuide.bottomAnchor),
]
defer {
quotedReplyViewConstraints = newConstraints
}
guard isAnimated else {
oldMessagePreviewView?.removeFromSuperview()
NSLayoutConstraint.deactivate(oldConstraints)
NSLayoutConstraint.activate(newConstraints)
return
}
UIView.performWithoutAnimation {
quotedMessagePreview.alpha = 0
}
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3,
)
animator.addAnimations {
oldMessagePreviewView?.alpha = 0
quotedMessagePreview.alpha = 1
NSLayoutConstraint.deactivate(oldConstraints)
NSLayoutConstraint.activate(newConstraints)
self.layoutIfNeeded()
}
animator.addCompletion { _ in
oldMessagePreviewView?.removeFromSuperview()
}
animator.startAnimation()
}
private func hideQuotedReplyView(animated isAnimated: Bool) {
owsAssertDebug(quotedReplyDraft == nil)
let oldMessagePreviewView = quotedReplyWrapper.subviews.first as? QuotedReplyPreview
let oldConstraints = quotedReplyViewConstraints
let newConstraints = [
quotedReplyWrapper.heightAnchor.constraint(equalToConstant: 0),
]
defer {
quotedReplyViewConstraints = newConstraints
}
guard isAnimated else {
oldMessagePreviewView?.removeFromSuperview()
NSLayoutConstraint.deactivate(oldConstraints)
NSLayoutConstraint.activate(newConstraints)
return
}
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3,
)
animator.addAnimations {
oldMessagePreviewView?.alpha = 0
NSLayoutConstraint.deactivate(oldConstraints)
NSLayoutConstraint.activate(newConstraints)
self.layoutIfNeeded()
}
animator.addCompletion { _ in
oldMessagePreviewView?.removeFromSuperview()
}
animator.startAnimation()
}
func quotedReplyPreviewDidPressCancel(_ preview: QuotedReplyPreview) {
quotedReplyDraft = nil
}
// MARK: Link Preview
private let linkPreviewFetchState: LinkPreviewFetchState
private var linkPreviewView: LinkPreviewView?
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: LinkPreviewView
if let existingLinkPreviewView = self.linkPreviewView {
linkPreviewView = existingLinkPreviewView
linkPreviewView.configure(withState: state)
} else {
linkPreviewView = LinkPreviewView(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
}
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3,
)
animator.addAnimations {
self.updateLinkPreviewConstraint()
self.layoutIfNeeded()
}
animator.startAnimation()
}
private func hideLinkPreviewView(animated: Bool) {
AssertIsOnMainThread()
guard !isLinkPreviewHidden else { return }
isLinkPreviewHidden = true
guard animated else {
updateLinkPreviewConstraint()
layoutIfNeeded()
return
}
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 0.9,
springResponse: 0.3,
)
animator.addAnimations {
self.updateLinkPreviewConstraint()
self.layoutIfNeeded()
}
animator.addCompletion { _ in
self.linkPreviewView?.resetContent()
}
animator.startAnimation()
}
private func didTapDeleteLinkPreview() {
AssertIsOnMainThread()
linkPreviewFetchState.disable()
}
// MARK: Stickers
private let suggestedStickerViewCache = StickerViewCache(maxSize: 12)
private var currentSuggestedStickerEmoji: Character?
private var currentSuggestedStickers: [StickerInfo] = []
private var isStickerPanelHidden = true
private enum StickerLayout {
// Square.
static let listItemSize: CGFloat = 56
// Horizontal.
static let listItemSpacing: CGFloat = 12
// Spacing around sticker list view's content.
// Set spacing as `UICollectionView.contentInset` to allow scrolling stickers right up to the edge of the background.
static let listViewPadding = UIEdgeInsets(hMargin: 10, vMargin: 6)
// `stickersListView` must be inset a little bit to make room for glass background's border.
static let backgroundMargins = NSDirectionalEdgeInsets(margin: 2)
// How much is the sticker panel (visible background) inset from the full-width `stickerPanel`.
static let outerPanelHMargin: CGFloat = if #available(iOS 26, *) { OWSTableViewController2.cellHInnerMargin } else { 0 }
// Corner radius of the glass/blur background.
@available(iOS 26, *)
static let backgroundCornerRadius: CGFloat = 26
// Make sure to match parameters from MentionPicker.
static func animationTransform(_ view: UIView) -> CGAffineTransform {
guard #available(iOS 26, *) else { return .identity }
return .scale(0.9)
}
// Make sure to match parameters from MentionPicker.
static func animator() -> UIViewPropertyAnimator {
return UIViewPropertyAnimator(
duration: 0.35,
springDamping: 1,
springResponse: 0.35,
)
}
static let panelVisualEffect: UIVisualEffect = {
// UIVisualEffect cannot "dematerialize" glass on iOS 26.0: setting `effect` to `nil` simply doesn't work.
// That was fixed in 26.1.
if #available(iOS 26.1, *) { Style.glassEffect() } else { UIBlurEffect(style: .systemMaterial) }
}()
}
/// Outermost sticker view placed as a subview of the delegate provided view and takes full width of that.
private let stickerPanel = UIView.container()
private var stickerPanelConstraint: NSLayoutConstraint?
/// Subview of `stickerPanel`. Contains background panel and sticker list view.
/// Constrained horizontally to `stickerPanel.safeAreaLayoutGuide` with a fixed margin.
/// On iOS 26 it's leading edge aligns with (+) attachment button and
/// trailing edge aligns with the blue Send button.
private lazy var stickerListViewWrapper: UIVisualEffectView = {
let view = UIVisualEffectView()
if #available(iOS 26.0, *) {
view.clipsToBounds = true
view.cornerConfiguration = .uniformCorners(radius: .fixed(StickerLayout.backgroundCornerRadius))
// `stickersListView` is inset from its parent container with a very small inset.
// Make sure its corners are also rounded so that content doesn't go outside of the panel.
let minRadius = StickerLayout.backgroundCornerRadius - max(StickerLayout.backgroundMargins.leading, StickerLayout.backgroundMargins.top)
stickersListView.cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: minRadius))
}
// List view.
view.directionalLayoutMargins = StickerLayout.backgroundMargins
stickersListView.translatesAutoresizingMaskIntoConstraints = false
view.contentView.addSubview(stickersListView)
NSLayoutConstraint.activate([
stickersListView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor),
stickersListView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
stickersListView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
stickersListView.bottomAnchor.constraint(equalTo: view.layoutMarginsGuide.bottomAnchor),
stickersListView.heightAnchor.constraint(
equalToConstant: StickerLayout.listItemSize + StickerLayout.listViewPadding.totalHeight,
),
])
return view
}()
private lazy var stickersListView: StickerHorizontalListView = {
let view = StickerHorizontalListView(
cellSize: StickerLayout.listItemSize,
cellContentInset: 0,
spacing: StickerLayout.listItemSpacing,
)
view.backgroundColor = .clear
view.contentInset = StickerLayout.listViewPadding
return view
}()
private func loadStickerPanelIfNecessary() {
guard stickerListViewWrapper.superview == nil else { return }
stickerPanel.addSubview(stickerListViewWrapper)
stickerListViewWrapper.translatesAutoresizingMaskIntoConstraints = false
stickerPanel.addConstraints([
stickerListViewWrapper.topAnchor.constraint(
equalTo: stickerPanel.topAnchor,
),
stickerListViewWrapper.leadingAnchor.constraint(
equalTo: stickerPanel.safeAreaLayoutGuide.leadingAnchor,
constant: StickerLayout.outerPanelHMargin,
),
stickerListViewWrapper.trailingAnchor.constraint(
equalTo: stickerPanel.layoutMarginsGuide.trailingAnchor,
constant: -StickerLayout.outerPanelHMargin,
),
stickerListViewWrapper.bottomAnchor.constraint(
equalTo: stickerPanel.bottomAnchor,
),
])
UIView.performWithoutAnimation {
stickerPanel.layoutIfNeeded()
}
}
private func updateSuggestedStickers(animated: Bool) {
// Skip this until we are in the view hierarchy.
guard superview != nil else { return }
let suggestedStickerEmoji = StickerManager.suggestedStickerEmoji(chatBoxText: inputTextView.trimmedText)
guard currentSuggestedStickerEmoji != suggestedStickerEmoji else { return }
currentSuggestedStickerEmoji = suggestedStickerEmoji
let suggestedStickers: [StickerInfo]
if let suggestedStickerEmoji {
suggestedStickers = SSKEnvironment.shared.databaseStorageRef.read { tx in
return StickerManager.suggestedStickers(for: suggestedStickerEmoji, tx: tx).map { $0.info }
}
} else {
suggestedStickers = []
}
guard currentSuggestedStickers != suggestedStickers else { return }
currentSuggestedStickers = suggestedStickers
guard !suggestedStickers.isEmpty else {
hideStickerPanel(animated: animated)
return
}
showStickerPanel(animated: animated)
}
private func showStickerPanel(animated: Bool) {
guard let stickerPanelSuperview = inputToolbarDelegate?.viewForSuggestedStickersPanel() else {
owsFailBeta("No view provided for stickers panel.")
return
}
owsAssertDebug(!currentSuggestedStickers.isEmpty)
loadStickerPanelIfNecessary()
stickersListView.items = currentSuggestedStickers.map { stickerInfo in
StickerHorizontalListViewItemSticker(
stickerInfo: stickerInfo,
didSelectBlock: { [weak self] in
self?.didSelectSuggestedSticker(stickerInfo)
},
cache: suggestedStickerViewCache,
)
}
guard isStickerPanelHidden else { return }
isStickerPanelHidden = false
UIView.performWithoutAnimation {
// Find a subview of `stickerPanelSuperview` that we would put `stickerPanel` behind.
var stickerPanelSiblingView: UIView = self
while
let siblingSuperView = stickerPanelSiblingView.superview,
siblingSuperView != stickerPanelSuperview
{
stickerPanelSiblingView = siblingSuperView
}
// Add `stickerPanel` to the view hierarchy and set up constraints.
stickerPanelSuperview.insertSubview(stickerPanel, belowSubview: stickerPanelSiblingView)
stickerPanel.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stickerPanel.leadingAnchor.constraint(equalTo: stickerPanelSuperview.leadingAnchor),
stickerPanel.trailingAnchor.constraint(equalTo: stickerPanelSuperview.trailingAnchor),
stickerPanel.bottomAnchor.constraint(equalTo: self.topAnchor),
])
// Manually calculate final size and position of the `stickerPanel`
// and place it appropriately.
// This is done to avoid calling `layoutSubviews` on the panel's parent which is likely VC's root view.
let stickerPanelMaxY = stickerPanelSuperview.convert(bounds.origin, from: self).y
let stickerPanelSize = stickerPanel.systemLayoutSizeFitting(
CGSize(width: stickerPanelSuperview.bounds.width, height: 300),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel,
)
stickerPanel.frame = CGRect(
origin: CGPoint(
x: stickerPanelSuperview.bounds.minX,
y: stickerPanelMaxY - stickerPanelSize.height,
),
size: CGSize(
width: stickerPanelSuperview.bounds.width,
height: stickerPanelSize.height,
),
)
// Ensure final layout within the panel.
stickerPanel.layoutIfNeeded()
// Set initial scroll position in the list.
stickersListView.contentOffset = CGPoint(
x: -(
CurrentAppContext().isRTL
? stickersListView.frame.width - stickersListView.contentSize.width - StickerLayout.listViewPadding.right
: StickerLayout.listViewPadding.left
),
y: -StickerLayout.listViewPadding.top,
)
}
guard animated else {
stickerListViewWrapper.transform = .identity
stickerListViewWrapper.effect = StickerLayout.panelVisualEffect
stickersListView.alpha = 1
return
}
// Prepare initial state for animations.
UIView.performWithoutAnimation {
stickerListViewWrapper.transform = StickerLayout.animationTransform(stickerListViewWrapper)
stickerListViewWrapper.effect = nil
stickersListView.alpha = 0
}
// Animate.
let animator = StickerLayout.animator()
animator.addAnimations {
self.stickerListViewWrapper.transform = .identity
self.stickerListViewWrapper.effect = StickerLayout.panelVisualEffect
self.stickersListView.alpha = 1
}
animator.startAnimation()
}
private func hideStickerPanel(animated: Bool) {
guard !isStickerPanelHidden else { return }
guard animated else {
stickerPanel.removeFromSuperview()
isStickerPanelHidden = true
return
}
let animator = StickerLayout.animator()
animator.addAnimations {
self.stickerListViewWrapper.transform = StickerLayout.animationTransform(self.stickerListViewWrapper)
self.stickerListViewWrapper.effect = nil
self.stickersListView.alpha = 0
}
animator.addCompletion { _ in
self.stickerPanel.removeFromSuperview()
self.isStickerPanelHidden = true
}
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 = Style.primaryTextColor
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: Style.secondaryTextColor)
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 = {
guard #unavailable(iOS 26.0) else {
return Buttons.deleteVoiceMemoDraftButton(
primaryAction: UIAction { [weak self] _ in
self?.deleteVoiceMemoDraft()
},
accessibilityIdentifier: UIView.accessibilityIdentifier(in: self, name: "stickerButton"),
)
}
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 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 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 defaultLeadingInputAssistantItems: [UIBarButtonItemGroup]?
private var defaultTrailingInputAssistantItems: [UIBarButtonItemGroup]?
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, !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()
}
// Store default input assistant bar items - buttons displayed above the keyboard on iPad.
// We want to hide them when showing custom keyboard and restore when using standard keyboard.
if desiredKeyboardType == .system {
if defaultLeadingInputAssistantItems == nil {
defaultLeadingInputAssistantItems = inputTextView.inputAssistantItem.leadingBarButtonGroups
}
if defaultTrailingInputAssistantItems == nil {
defaultTrailingInputAssistantItems = inputTextView.inputAssistantItem.trailingBarButtonGroups
}
}
_desiredKeyboardType = keyboardType
ensureButtonVisibility(withAnimation: animated, doLayout: true)
// Do this before assigning as `inputView`.
if let customKeyboard = desiredInputView as? CustomKeyboard {
customKeyboard.updateHeightForPresentation()
}
if desiredKeyboardType == .system {
if let defaultLeadingInputAssistantItems {
inputTextView.inputAssistantItem.leadingBarButtonGroups = defaultLeadingInputAssistantItems
}
if let defaultTrailingInputAssistantItems {
inputTextView.inputAssistantItem.trailingBarButtonGroups = defaultTrailingInputAssistantItems
}
} else {
inputTextView.inputAssistantItem.leadingBarButtonGroups = []
inputTextView.inputAssistantItem.trailingBarButtonGroups = []
}
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
CustomKeyboard.setSystemKeyboardHeight(keyboardHeight, forTraitCollection: traitCollection)
}
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)
}
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if #unavailable(iOS 26), let legacyBackgroundView, let legacyBackgroundBlurView {
updateBackgroundColors(backgroundView: legacyBackgroundView, backgroundBlurView: legacyBackgroundBlurView)
}
// 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 else {
owsFailDebug("inputToolbarDelegate == nil")
return
}
ImpactHapticFeedback.impactOccurred(style: .light)
inputToolbarDelegate.cameraButtonPressed()
}
@objc
private func addOrCancelButtonPressed() {
ImpactHapticFeedback.impactOccurred(style: .light)
if isEditingMessage {
editTarget = nil
quotedReplyDraft = nil
clearTextMessage(animated: true)
} else {
toggleKeyboardType(.attachment, animated: true)
}
}
private func sendButtonPressed() {
guard let inputToolbarDelegate else {
owsFailDebug("inputToolbarDelegate == nil")
return
}
guard !isShowingVoiceMemoUI else {
voiceMemoRecordingState = .idle
guard let 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 {
inputToolbarDelegate?.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(.up),
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 {
let animator = UIViewPropertyAnimator(
duration: ConversationInputToolbar.heightChangeAnimationDuration,
springDamping: 1,
springResponse: 0.25,
)
animator.addAnimations {
self.invalidateIntrinsicContentSize()
superview.layoutIfNeeded()
}
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 {
// Here we could potentially compare to original (before edit)
// message and update Send button accordingly.
setSendButtonEnabled(hasMessageText)
}
}
func textViewDidChangeSelection(_ textView: UITextView) { }
}
extension ConversationInputToolbar: StickerKeyboardDelegate {
public func stickerKeyboard(_: StickerKeyboard, didSelect stickerInfo: StickerInfo) {
AssertIsOnMainThread()
inputToolbarDelegate?.sendSticker(stickerInfo)
}
public func stickerKeyboardDidRequestPresentManageStickersView(_ stickerKeyboard: StickerKeyboard) {
AssertIsOnMainThread()
inputToolbarDelegate?.presentManageStickersView()
}
}
extension ConversationInputToolbar: AttachmentKeyboardDelegate {
func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) {
inputToolbarDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment, attachmentLimits: attachmentLimits)
}
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
}
}
extension ConversationInputToolbar: ConversationBottomBar {
var shouldAttachToKeyboardLayoutGuide: Bool { true }
}