// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // public protocol ForwardMessageDelegate: AnyObject { func forwardMessageFlowDidComplete(items: [ForwardMessageItem], recipientThreads: [TSThread]) func forwardMessageFlowDidCancel() } // MARK: - @objc class ForwardMessageViewController: InteractiveSheetViewController { private let pickerVC: ForwardPickerViewController private let forwardNavigationViewController = ForwardNavigationViewController() private let handle = UIView() override var interactiveScrollViews: [UIScrollView] { [ pickerVC.tableView ] } public weak var forwardMessageDelegate: ForwardMessageDelegate? public typealias Item = ForwardMessageItem fileprivate typealias Content = ForwardMessageContent fileprivate typealias RecipientThread = ForwardMessageRecipientThread fileprivate var content: Content fileprivate var textMessage: String? private let selection = ConversationPickerSelection() var selectedConversations: [ConversationItem] { selection.conversations } fileprivate var currentMentionableAddresses: [SignalServiceAddress] = [] private init(content: Content) { self.content = content self.pickerVC = ForwardPickerViewController(selection: selection) super.init() selectRecipientsStep() } required init() { fatalError("init() has not been implemented") } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public class func present(forItemViewModels itemViewModels: [CVItemViewModelImpl], from fromViewController: UIViewController, delegate: ForwardMessageDelegate) { do { let content: Content = try Self.databaseStorage.read { transaction in try Content.build(itemViewModels: itemViewModels, transaction: transaction) } present(content: content, from: fromViewController, delegate: delegate) } catch { ForwardMessageViewController.showAlertForForwardError(error: error, forwardedInteractionCount: itemViewModels.count) } } public class func present(forSelectionItems selectionItems: [CVSelectionItem], from fromViewController: UIViewController, delegate: ForwardMessageDelegate) { do { let content: Content = try Self.databaseStorage.read { transaction in try Content.build(selectionItems: selectionItems, transaction: transaction) } present(content: content, from: fromViewController, delegate: delegate) } catch { ForwardMessageViewController.showAlertForForwardError(error: error, forwardedInteractionCount: selectionItems.count) } } private class func present(content: Content, from fromViewController: UIViewController, delegate: ForwardMessageDelegate) { let sheet = ForwardMessageViewController(content: content) sheet.forwardMessageDelegate = delegate fromViewController.present(sheet, animated: true) { UIApplication.shared.hideKeyboard() } } public override func themeDidChange() { super.themeDidChange() } public override func applyTheme() { AssertIsOnMainThread() super.applyTheme() contentView.backgroundColor = pickerVC.tableBackgroundColor handle.backgroundColor = Theme.tableView2PresentedSeparatorColor } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) applyTheme() } private func selectRecipientsStep() { handle.autoSetDimensions(to: CGSize(width: 36, height: 5)) handle.layer.cornerRadius = 5 / 2 let handleContainer = UIView() handleContainer.addSubview(handle) handle.autoPinHeightToSuperview(withMargin: 12) handle.autoHCenterInSuperview() pickerVC.forwardMessageViewController = self pickerVC.shouldShowSearchBar = false pickerVC.shouldHideSearchBarIfCancelled = true pickerVC.pickerDelegate = self forwardNavigationViewController.forwardMessageViewController = self forwardNavigationViewController.viewControllers = [ pickerVC ] self.addChild(forwardNavigationViewController) let navView = forwardNavigationViewController.view! let stackView = UIStackView(arrangedSubviews: [ handleContainer, navView ]) stackView.axis = .vertical stackView.alignment = .fill self.contentView.addSubview(stackView) stackView.autoPinEdgesToSuperviewEdges() applyTheme() } fileprivate func selectSearchBar() { AssertIsOnMainThread() pickerVC.selectSearchBar() ensureHeaderVisibility() } fileprivate func ensureHeaderVisibility() { AssertIsOnMainThread() forwardNavigationViewController.setNavigationBarHidden(pickerVC.isSearchBarActive, animated: false) if pickerVC.isSearchBarActive { maximizeHeight() } } public override func willDismissInteractively() { AssertIsOnMainThread() forwardMessageDelegate?.forwardMessageFlowDidCancel() } override var renderExternalHandle: Bool { false } override var minHeight: CGFloat { 576 } fileprivate func updateCurrentMentionableAddresses() { guard selectedConversations.count == 1, let conversationItem = selectedConversations.first else { self.currentMentionableAddresses = [] return } do { try databaseStorage.write { transaction in let recipientThread = try RecipientThread.build(conversationItem: conversationItem, transaction: transaction) self.currentMentionableAddresses = recipientThread.mentionCandidates } } catch { owsFailDebug("Error: \(error)") self.currentMentionableAddresses = [] } } } // MARK: - Sending extension ForwardMessageViewController { private static let keyValueStore = SDSKeyValueStore(collection: "ForwardMessageViewController") private static let hasForwardedKey = "hasForwardedKey" private var hasForwardedWithSneakyTransaction: Bool { databaseStorage.read { transaction in Self.keyValueStore.getBool(Self.hasForwardedKey, defaultValue: false, transaction: transaction) } } private static func markHasForwardedWithSneakyTransaction() { databaseStorage.write { transaction in Self.keyValueStore.setBool(true, key: Self.hasForwardedKey, transaction: transaction) } } func sendStep() { if hasForwardedWithSneakyTransaction { tryToSend() } else { showFirstForwardAlert() } } private func showFirstForwardAlert() { let actionSheet = ActionSheetController( title: NSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_TITLE", comment: "Title for alert with information about forwarding messages."), message: NSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_MESSAGE", comment: "Message for alert with information about forwarding messages.") ) let actionTitle: String if content.allItems.count > 1 { let format = NSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_PROCEED_N_FORMAT", comment: "Format for label for button to proceed with forwarding multiple messages. Embeds: {{ the number of forwarded messages. }}") actionTitle = String(format: format, OWSFormat.formatInt(content.allItems.count)) } else { actionTitle = NSLocalizedString("FORWARD_MESSAGE_FIRST_FORWARD_PROCEED_1", comment: "Label for button to proceed with forwarding a single message.") } actionSheet.addAction(ActionSheetAction(title: actionTitle) { [weak self] _ in Self.markHasForwardedWithSneakyTransaction() self?.tryToSend() }) actionSheet.addAction(OWSActionSheets.cancelAction) presentActionSheet(actionSheet) } private func tryToSend() { AssertIsOnMainThread() do { try tryToSendThrows() } catch { owsFailDebug("Error: \(error)") self.forwardMessageDelegate?.forwardMessageFlowDidCancel() } } private func tryToSendThrows() throws { let content = self.content let textMessage = self.textMessage?.strippedOrNil let recipientConversations = self.selectedConversations firstly(on: .global()) { self.recipientThreads(for: recipientConversations) }.then(on: .main) { (recipientThreads: [RecipientThread]) -> Promise in try Self.databaseStorage.write { transaction in for recipientThread in recipientThreads { // We're sending a message to this thread, approve any pending message request ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimer(thread: recipientThread.thread, transaction: transaction) } func hasRenderableContent(interaction: TSInteraction) -> Bool { guard let message = interaction as? TSMessage else { return false } return message.hasRenderableContent() } // Make sure the message and its content haven't been deleted (view-once // messages, remove delete, disappearing messages, manual deletion, etc.). for item in content.allItems { let interactionId = item.interaction.uniqueId guard let latestInteraction = TSInteraction.anyFetch(uniqueId: interactionId, transaction: transaction), hasRenderableContent(interaction: latestInteraction) else { throw ForwardError.missingInteraction } } } // TODO: Ideally we would enqueue all with a single write tranasction. return firstly { () -> Promise in // Maintain order of interactions. let sortedItems = content.allItems.sorted { lhs, rhs in lhs.interaction.timestamp < rhs.interaction.timestamp } let promises: [Promise] = sortedItems.map { item in self.send(item: item, toRecipientThreads: recipientThreads) } return firstly(on: .main) { () -> Promise in Promise.when(resolved: promises).asVoid() }.then(on: .main) { _ -> Promise in // The user may have added an additional text message to the forward. // It should be sent last. if let textMessage = textMessage { let messageBody = MessageBody(text: textMessage, ranges: .empty) return self.send(toRecipientThreads: recipientThreads) { recipientThread in self.send(body: messageBody, linkPreviewDraft: nil, thread: recipientThread.thread) } } else { return Promise.value(()) } } }.map(on: .main) { let threads = recipientThreads.map { $0.thread } self.forwardMessageDelegate?.forwardMessageFlowDidComplete(items: content.allItems, recipientThreads: threads) } }.catch(on: .main) { error in owsFailDebug("Error: \(error)") Self.showAlertForForwardError(error: error, forwardedInteractionCount: content.allItems.count) } } private func send(item: Item, toRecipientThreads recipientThreads: [RecipientThread]) -> Promise { AssertIsOnMainThread() let componentState = item.componentState if let stickerMetadata = item.stickerMetadata { let stickerInfo = stickerMetadata.stickerInfo if StickerManager.isStickerInstalled(stickerInfo: stickerInfo) { return send(toRecipientThreads: recipientThreads) { recipientThread in self.send(installedSticker: stickerInfo, thread: recipientThread.thread) } } else { guard let stickerAttachment = componentState.stickerAttachment else { return Promise(error: OWSAssertionError("Missing stickerAttachment.")) } do { let stickerData = try stickerAttachment.readDataFromFile() return send(toRecipientThreads: recipientThreads) { recipientThread in self.send(uninstalledSticker: stickerMetadata, stickerData: stickerData, thread: recipientThread.thread) } } catch { return Promise(error: error) } } } else if let contactShare = item.contactShare { return send(toRecipientThreads: recipientThreads) { recipientThread in if let avatarImage = contactShare.avatarImage { self.databaseStorage.write { transaction in contactShare.dbRecord.saveAvatarImage(avatarImage, transaction: transaction) } } return self.send(contactShare: contactShare, thread: recipientThread.thread) } } else if let attachments = item.attachments, !attachments.isEmpty { // TODO: What about link previews in this case? let conversations = selectedConversations return AttachmentMultisend.sendApprovedMedia(conversations: conversations, approvalMessageBody: item.messageBody, approvedAttachments: attachments).asVoid() } else if let messageBody = item.messageBody { let linkPreviewDraft = item.linkPreviewDraft return send(toRecipientThreads: recipientThreads) { recipientThread in self.send(body: messageBody, linkPreviewDraft: linkPreviewDraft, thread: recipientThread.thread) } } else { return Promise(error: ForwardError.invalidInteraction) } } fileprivate func send(body: MessageBody, linkPreviewDraft: OWSLinkPreviewDraft?, thread: TSThread) -> Promise { databaseStorage.read { transaction in ThreadUtil.enqueueMessage(with: body, thread: thread, quotedReplyModel: nil, linkPreviewDraft: linkPreviewDraft, transaction: transaction) } return Promise.value(()) } fileprivate func send(contactShare: ContactShareViewModel, thread: TSThread) -> Promise { ThreadUtil.enqueueMessage(withContactShare: contactShare.dbRecord, thread: thread) return Promise.value(()) } fileprivate func send(body: MessageBody?, attachment: SignalAttachment, thread: TSThread) -> Promise { databaseStorage.read { transaction in ThreadUtil.enqueueMessage(with: body, mediaAttachments: [attachment], thread: thread, quotedReplyModel: nil, linkPreviewDraft: nil, transaction: transaction) } return Promise.value(()) } fileprivate func send(installedSticker stickerInfo: StickerInfo, thread: TSThread) -> Promise { ThreadUtil.enqueueMessage(withInstalledSticker: stickerInfo, thread: thread) return Promise.value(()) } fileprivate func send(uninstalledSticker stickerMetadata: StickerMetadata, stickerData: Data, thread: TSThread) -> Promise { ThreadUtil.enqueueMessage(withUninstalledSticker: stickerMetadata, stickerData: stickerData, thread: thread) return Promise.value(()) } fileprivate func send(toRecipientThreads recipientThreads: [RecipientThread], enqueueBlock: @escaping (RecipientThread) -> Promise) -> Promise { AssertIsOnMainThread() return Promise.when(fulfilled: recipientThreads.map { thread in enqueueBlock(thread) }).asVoid() } fileprivate func recipientThreads(for conversationItems: [ConversationItem]) -> Promise<[RecipientThread]> { firstly(on: .global()) { guard conversationItems.count > 0 else { throw OWSAssertionError("No recipients.") } return try self.databaseStorage.write { transaction in try conversationItems.map { try RecipientThread.build(conversationItem: $0, transaction: transaction) } } } } } // MARK: - extension ForwardMessageViewController: ConversationPickerDelegate { func conversationPickerSelectionDidChange(_ conversationPickerViewController: ConversationPickerViewController) { updateCurrentMentionableAddresses() } func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController) { self.textMessage = conversationPickerViewController.textInput?.strippedOrNil sendStep() } func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool { false } func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController) { forwardMessageDelegate?.forwardMessageFlowDidCancel() } func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode { .send } var conversationPickerHasTextInput: Bool { true } var conversationPickerTextInputDefaultText: String? { NSLocalizedString("FORWARD_MESSAGE_TEXT_PLACEHOLDER", comment: "Indicates that the user can add a text message to forwarded messages.") } func conversationPickerDidBeginEditingText() { AssertIsOnMainThread() maximizeHeight() } func conversationPickerSearchBarActiveDidChange(_ conversationPickerViewController: ConversationPickerViewController) { ensureHeaderVisibility() } } // MARK: - extension TSAttachmentStream { func cloneAsSignalAttachment() throws -> SignalAttachment { guard let sourceUrl = originalMediaURL else { throw OWSAssertionError("Missing originalMediaURL.") } guard let dataUTI = MIMETypeUtil.utiType(forMIMEType: contentType) else { throw OWSAssertionError("Missing dataUTI.") } let newUrl = OWSFileSystem.temporaryFileUrl(fileExtension: sourceUrl.pathExtension) try FileManager.default.copyItem(at: sourceUrl, to: newUrl) let clonedDataSource = try DataSourcePath.dataSource(with: newUrl, shouldDeleteOnDeallocation: true) clonedDataSource.sourceFilename = sourceFilename var signalAttachment: SignalAttachment if isVoiceMessage { signalAttachment = SignalAttachment.voiceMessageAttachment(dataSource: clonedDataSource, dataUTI: dataUTI) } else { signalAttachment = SignalAttachment.attachment(dataSource: clonedDataSource, dataUTI: dataUTI) } signalAttachment.captionText = caption signalAttachment.isBorderless = isBorderless signalAttachment.isLoopingVideo = isLoopingVideo return signalAttachment } } // MARK: - extension ForwardMessageViewController { public static func finalizeForward(items: [Item], recipientThreads: [TSThread], fromViewController: UIViewController) { let toast: String if items.count > 1 { toast = NSLocalizedString("FORWARD_MESSAGE_MESSAGES_SENT_N", comment: "Indicates that multiple messages were forwarded.") } else { toast = NSLocalizedString("FORWARD_MESSAGE_MESSAGES_SENT_1", comment: "Indicates that a single message was forwarded.") } fromViewController.presentToast(text: toast) } } // MARK: - public enum ForwardError: Error { case missingInteraction case missingThread case invalidInteraction } // MARK: - extension ForwardMessageViewController { public static func showAlertForForwardError(error: Error, forwardedInteractionCount: Int) { let genericErrorMessage = (forwardedInteractionCount > 1 ? NSLocalizedString("ERROR_COULD_NOT_FORWARD_MESSAGES_N", comment: "Error indicating that messages could not be forwarded.") : NSLocalizedString("ERROR_COULD_NOT_FORWARD_MESSAGES_1", comment: "Error indicating that a message could not be forwarded.")) guard let forwardError = error as? ForwardError else { owsFailDebug("Error: \(error).") OWSActionSheets.showErrorAlert(message: genericErrorMessage) return } switch forwardError { case .missingInteraction: let message = (forwardedInteractionCount > 1 ? NSLocalizedString("ERROR_COULD_NOT_FORWARD_MESSAGES_MISSING_N", comment: "Error indicating that messages could not be forwarded.") : NSLocalizedString("ERROR_COULD_NOT_FORWARD_MESSAGES_MISSING_1", comment: "Error indicating that a message could not be forwarded.")) OWSActionSheets.showErrorAlert(message: message) case .missingThread, .invalidInteraction: owsFailDebug("Error: \(error).") OWSActionSheets.showErrorAlert(message: genericErrorMessage) } } } // MARK: - public struct ForwardMessageItem { fileprivate typealias Item = ForwardMessageItem let interaction: TSInteraction let componentState: CVComponentState let attachments: [SignalAttachment]? let contactShare: ContactShareViewModel? let messageBody: MessageBody? let linkPreviewDraft: OWSLinkPreviewDraft? let stickerMetadata: StickerMetadata? fileprivate class Builder { let interaction: TSInteraction let componentState: CVComponentState var attachments: [SignalAttachment]? var contactShare: ContactShareViewModel? var messageBody: MessageBody? var linkPreviewDraft: OWSLinkPreviewDraft? var stickerMetadata: StickerMetadata? init(interaction: TSInteraction, componentState: CVComponentState) { self.interaction = interaction self.componentState = componentState } func build() -> ForwardMessageItem { ForwardMessageItem(interaction: interaction, componentState: componentState, attachments: attachments, contactShare: contactShare, messageBody: messageBody, linkPreviewDraft: linkPreviewDraft, stickerMetadata: stickerMetadata) } } fileprivate var asBuilder: Builder { let builder = Builder(interaction: interaction, componentState: componentState) builder.attachments = attachments builder.contactShare = contactShare builder.messageBody = messageBody builder.linkPreviewDraft = linkPreviewDraft builder.stickerMetadata = stickerMetadata return builder } var isEmpty: Bool { if let attachments = attachments, !attachments.isEmpty { return false } if contactShare != nil || messageBody != nil || stickerMetadata != nil { return false } return true } fileprivate static func build(interaction: TSInteraction, componentState: CVComponentState, selectionType: CVSelectionType, transaction: SDSAnyReadTransaction) throws -> Item { let builder = Builder(interaction: interaction, componentState: componentState) let shouldHaveText = (selectionType == .allContent || selectionType == .secondaryContent) let shouldHaveAttachments = (selectionType == .allContent || selectionType == .primaryContent) guard shouldHaveText || shouldHaveAttachments else { throw ForwardError.invalidInteraction } if shouldHaveText, let displayableBodyText = componentState.displayableBodyText, !displayableBodyText.fullAttributedText.isEmpty { let attributedText = displayableBodyText.fullAttributedText builder.messageBody = MessageBody(attributedString: attributedText) if let linkPreview = componentState.linkPreviewModel { builder.linkPreviewDraft = Self.tryToCloneLinkPreview(linkPreview: linkPreview, transaction: transaction) } } if shouldHaveAttachments { if let oldContactShare = componentState.contactShareModel { builder.contactShare = oldContactShare.copyForResending() } var attachmentStreams = [TSAttachmentStream]() attachmentStreams.append(contentsOf: componentState.bodyMediaAttachmentStreams) if let attachmentStream = componentState.audioAttachmentStream { attachmentStreams.append(attachmentStream) } if let attachmentStream = componentState.genericAttachmentStream { attachmentStreams.append(attachmentStream) } if !attachmentStreams.isEmpty { builder.attachments = try attachmentStreams.map { attachmentStream in try attachmentStream.cloneAsSignalAttachment() } } if let stickerMetadata = componentState.stickerMetadata { builder.stickerMetadata = stickerMetadata } } let item = builder.build() guard !item.isEmpty else { throw ForwardError.invalidInteraction } return item } private static func tryToCloneLinkPreview(linkPreview: OWSLinkPreview, transaction: SDSAnyReadTransaction) -> OWSLinkPreviewDraft? { guard let urlString = linkPreview.urlString, let url = URL(string: urlString) else { owsFailDebug("Missing or invalid urlString.") return nil } struct LinkPreviewImage { let imageData: Data let mimetype: String static func load(attachmentId: String, transaction: SDSAnyReadTransaction) -> LinkPreviewImage? { guard let attachment = TSAttachmentStream.anyFetchAttachmentStream(uniqueId: attachmentId, transaction: transaction) else { owsFailDebug("Missing attachment.") return nil } guard let mimeType = attachment.contentType.nilIfEmpty else { owsFailDebug("Missing mimeType.") return nil } do { let imageData = try attachment.readDataFromFile() return LinkPreviewImage(imageData: imageData, mimetype: mimeType) } catch { owsFailDebug("Error: \(error).") return nil } } } var linkPreviewImage: LinkPreviewImage? if let imageAttachmentId = linkPreview.imageAttachmentId, let image = LinkPreviewImage.load(attachmentId: imageAttachmentId, transaction: transaction) { linkPreviewImage = image } let draft = OWSLinkPreviewDraft(url: url, title: linkPreview.title, imageData: linkPreviewImage?.imageData, imageMimeType: linkPreviewImage?.mimetype) draft.previewDescription = linkPreview.previewDescription draft.date = linkPreview.date return draft } } // MARK: - private enum ForwardMessageContent { fileprivate typealias Item = ForwardMessageItem case single(item: Item) case multiple(items: [Item]) var allItems: [Item] { switch self { case .single(let item): return [item] case .multiple(let items): return items } } static func build(items: [Item]) -> ForwardMessageContent { if items.count == 1, let item = items.first { return .single(item: item) } else { return .multiple(items: items) } } static func build(itemViewModels: [CVItemViewModelImpl], transaction: SDSAnyReadTransaction) throws -> ForwardMessageContent { let items: [Item] = try itemViewModels.map { itemViewModel in try Item.build(interaction: itemViewModel.interaction, componentState: itemViewModel.renderItem.componentState, selectionType: .allContent, transaction: transaction) } return build(items: items) } static func build(selectionItems: [CVSelectionItem], transaction: SDSAnyReadTransaction) throws -> ForwardMessageContent { let items: [Item] = try selectionItems.map { selectionItem in let interactionId = selectionItem.interactionId guard let interaction = TSInteraction.anyFetch(uniqueId: interactionId, transaction: transaction) else { throw ForwardError.invalidInteraction } let componentState = try buildComponentState(interactionId: interactionId, transaction: transaction) return try Item.build(interaction: interaction, componentState: componentState, selectionType: selectionItem.selectionType, transaction: transaction) } return build(items: items) } private static func buildComponentState(interactionId: String, transaction: SDSAnyReadTransaction) throws -> CVComponentState { guard let interaction = TSInteraction.anyFetch(uniqueId: interactionId, transaction: transaction) else { throw ForwardError.missingInteraction } guard let componentState = CVLoader.buildStandaloneComponentState(interaction: interaction, transaction: transaction) else { throw ForwardError.invalidInteraction } return componentState } } // MARK: - private struct ForwardMessageRecipientThread { let thread: TSThread let mentionCandidates: [SignalServiceAddress] static func build(conversationItem: ConversationItem, transaction: SDSAnyWriteTransaction) throws -> ForwardMessageRecipientThread { guard let thread = conversationItem.thread(transaction: transaction) else { owsFailDebug("Missing thread for conversation") throw ForwardError.missingThread } let mentionCandidates = self.mentionCandidates(conversationItem: conversationItem, thread: thread, transaction: transaction) return ForwardMessageRecipientThread(thread: thread, mentionCandidates: mentionCandidates) } private static func mentionCandidates(conversationItem: ConversationItem, thread: TSThread, transaction: SDSAnyReadTransaction) -> [SignalServiceAddress] { guard let groupThread = thread as? TSGroupThread, Mention.threadAllowsMentionSend(groupThread) else { return [] } return groupThread.recipientAddresses } } // MARK: - private class ForwardNavigationViewController: OWSNavigationController { weak var forwardMessageViewController: ForwardMessageViewController? } // MARK: - private class ForwardPickerViewController: ConversationPickerViewController { weak var forwardMessageViewController: ForwardMessageViewController? @objc public override func viewDidLoad() { super.viewDidLoad() updateNavigationItem() } public func updateNavigationItem() { title = NSLocalizedString("FORWARD_MESSAGE_TITLE", comment: "Title for the 'forward message(s)' view.") navigationItem.leftBarButtonItem = UIBarButtonItem(image: Theme.iconImage(.cancel20), style: .plain, target: self, action: #selector(didPressCancel)) navigationItem.rightBarButtonItem = UIBarButtonItem(image: Theme.iconImage(.settingsSearch), style: .plain, target: self, action: #selector(didPressSearch)) } @objc private func didPressCancel() { AssertIsOnMainThread() pickerDelegate?.conversationPickerDidCancel(self) } @objc private func didPressSearch() { AssertIsOnMainThread() forwardMessageViewController?.selectSearchBar() } }