// // ComposeViewController.swift // Mastodon // // Created by MainasuK Cirno on 2021-3-11. // import os.log import UIKit import Combine import PhotosUI import MetaTextKit import MastodonMeta import Meta import MastodonUI import MastodonAsset import MastodonLocalization import MastodonSDK final class ComposeViewController: UIViewController, NeedsDependency { static let minAutoCompleteVisibleHeight: CGFloat = 100 weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } var disposeBag = Set() var viewModel: ComposeViewModel! let logger = Logger(subsystem: "ComposeViewController", category: "logic") private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:))) let characterCountLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 15, weight: .regular) label.text = "500" label.textColor = Asset.Colors.Label.secondary.color label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500) return label }() private(set) lazy var characterCountBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(customView: characterCountLabel) return barButtonItem }() let publishButton: UIButton = { let button = RoundedEdgesButton(type: .custom) button.cornerRadius = 10 button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) return button }() private(set) lazy var publishBarButtonItem: UIBarButtonItem = { configurePublishButtonApperance() let shadowBackgroundContainer = ShadowBackgroundContainer() publishButton.translatesAutoresizingMaskIntoConstraints = false shadowBackgroundContainer.addSubview(publishButton) NSLayoutConstraint.activate([ publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor), publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor), publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor), publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor), ]) let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) return barButtonItem }() private func configurePublishButtonApperance() { publishButton.adjustsImageWhenHighlighted = false publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) } let tableView: ComposeTableView = { let tableView = ComposeTableView() tableView.register(ComposeRepliedToStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self)) tableView.register(ComposeStatusContentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusContentTableViewCell.self)) tableView.register(ComposeStatusAttachmentTableViewCell.self, forCellReuseIdentifier: String(describing: ComposeStatusAttachmentTableViewCell.self)) tableView.alwaysBounceVertical = true tableView.separatorStyle = .none tableView.tableFooterView = UIView() return tableView }() var systemKeyboardHeight: CGFloat = .zero { didSet { // note: some system AutoLayout warning here let height = max(300, systemKeyboardHeight) customEmojiPickerInputView.frame.size.height = height } } // CustomEmojiPickerView let customEmojiPickerInputView: CustomEmojiPickerInputView = { let view = CustomEmojiPickerInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 300), inputViewStyle: .keyboard) return view }() let composeToolbarView = ComposeToolbarView() var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeToolbarBackgroundView = UIView() static func createPhotoLibraryPickerConfiguration(selectionLimit: Int = 4) -> PHPickerConfiguration { var configuration = PHPickerConfiguration() configuration.filter = .any(of: [.images, .videos]) configuration.selectionLimit = selectionLimit return configuration } private(set) lazy var photoLibraryPicker: PHPickerViewController = { let imagePicker = PHPickerViewController(configuration: ComposeViewController.createPhotoLibraryPickerConfiguration()) imagePicker.delegate = self return imagePicker }() private(set) lazy var imagePickerController: UIImagePickerController = { let imagePickerController = UIImagePickerController() imagePickerController.sourceType = .camera imagePickerController.delegate = self return imagePickerController }() private(set) lazy var documentPickerController: UIDocumentPickerViewController = { let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie]) documentPickerController.delegate = self return documentPickerController }() private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { let viewController = AutoCompleteViewController() viewController.viewModel = AutoCompleteViewModel(context: context) viewController.delegate = self viewController.viewModel.customEmojiViewModel.value = viewModel.customEmojiViewModel return viewController }() deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } extension ComposeViewController { private static func createLayout() -> UICollectionViewLayout { let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let item = NSCollectionLayoutItem(layoutSize: itemSize) let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsetsReference = .readableContent // section.interGroupSpacing = 10 // section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10) return UICollectionViewCompositionalLayout(section: section) } } extension ComposeViewController { override func viewDidLoad() { super.viewDidLoad() configureNavigationBarTitleStyle() viewModel.traitCollectionDidChangePublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } self.configureNavigationBarTitleStyle() } .store(in: &disposeBag) viewModel.$title .receive(on: DispatchQueue.main) .sink { [weak self] title in guard let self = self else { return } self.title = title } .store(in: &disposeBag) self.setupBackgroundColor(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme .receive(on: RunLoop.main) .sink { [weak self] theme in guard let self = self else { return } self.setupBackgroundColor(theme: theme) } .store(in: &disposeBag) navigationItem.leftBarButtonItem = cancelBarButtonItem navigationItem.rightBarButtonItem = publishBarButtonItem viewModel.traitCollectionDidChangePublisher .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } guard self.traitCollection.userInterfaceIdiom == .pad else { return } var items = [self.publishBarButtonItem] if self.traitCollection.horizontalSizeClass == .regular { items.append(self.characterCountBarButtonItem) } self.navigationItem.rightBarButtonItems = items } .store(in: &disposeBag) publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) tableView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(tableView) NSLayoutConstraint.activate([ tableView.topAnchor.constraint(equalTo: view.topAnchor), tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) composeToolbarView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(composeToolbarView) composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) NSLayoutConstraint.activate([ composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), composeToolbarViewBottomLayoutConstraint, composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), ]) composeToolbarView.preservesSuperviewLayoutMargins = true composeToolbarView.delegate = self composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) NSLayoutConstraint.activate([ composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), ]) tableView.delegate = self viewModel.setupDataSource( tableView: tableView, metaTextDelegate: self, metaTextViewDelegate: self, customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, composeStatusAttachmentCollectionViewCellDelegate: self, composeStatusPollOptionCollectionViewCellDelegate: self, composeStatusPollOptionAppendEntryCollectionViewCellDelegate: self, composeStatusPollExpiresOptionCollectionViewCellDelegate: self ) viewModel.composeStatusAttribute.$composeContent .removeDuplicates() .receive(on: DispatchQueue.main) .sink { [weak self] _ in guard let self = self else { return } guard self.view.window != nil else { return } UIView.performWithoutAnimation { self.tableView.beginUpdates() self.tableView.endUpdates() } } .store(in: &disposeBag) customEmojiPickerInputView.collectionView.delegate = self viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView viewModel.setupCustomEmojiPickerDiffableDataSource( for: customEmojiPickerInputView.collectionView, dependency: self ) viewModel.composeStatusContentTableViewCell.delegate = self // update layout when keyboard show/dismiss view.layoutIfNeeded() let keyboardHasShortcutBar = CurrentValueSubject(traitCollection.userInterfaceIdiom == .pad) // update default value later let keyboardEventPublishers = Publishers.CombineLatest3( KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.state, KeyboardResponderService.shared.endFrame ) Publishers.CombineLatest3( keyboardEventPublishers, viewModel.$isCustomEmojiComposing, viewModel.$autoCompleteInfo ) .sink(receiveValue: { [weak self] keyboardEvents, isCustomEmojiComposing, autoCompleteInfo in guard let self = self else { return } let (isShow, state, endFrame) = keyboardEvents switch self.traitCollection.userInterfaceIdiom { case .pad: keyboardHasShortcutBar.value = state != .floating default: keyboardHasShortcutBar.value = false } let extraMargin: CGFloat = { var margin = self.composeToolbarView.frame.height if autoCompleteInfo != nil { margin += ComposeViewController.minAutoCompleteVisibleHeight } return margin }() guard isShow, state == .dock else { self.tableView.contentInset.bottom = extraMargin self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin if let superView = self.autoCompleteViewController.tableView.superview { let autoCompleteTableViewBottomInset: CGFloat = { let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil) let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY return max(0, padding) }() self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset } UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom if self.view.window != nil { self.view.layoutIfNeeded() } } return } // isShow AND dock state self.systemKeyboardHeight = endFrame.height // adjust inset for auto-complete let autoCompleteTableViewBottomInset: CGFloat = { guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero } let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil) let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY return max(0, padding) }() self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset // adjust inset for tableView let contentFrame = self.view.convert(self.tableView.frame, to: nil) let padding = contentFrame.maxY + extraMargin - endFrame.minY guard padding > 0 else { self.tableView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin self.tableView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin return } self.tableView.contentInset.bottom = padding - self.view.safeAreaInsets.bottom self.tableView.verticalScrollIndicatorInsets.bottom = padding - self.view.safeAreaInsets.bottom UIView.animate(withDuration: 0.3) { self.composeToolbarViewBottomLayoutConstraint.constant = endFrame.height self.view.layoutIfNeeded() } }) .store(in: &disposeBag) // bind auto-complete viewModel.$autoCompleteInfo .receive(on: DispatchQueue.main) .sink { [weak self] info in guard let self = self else { return } let textEditorView = self.textEditorView if self.autoCompleteViewController.view.superview == nil { self.autoCompleteViewController.view.frame = self.view.bounds // add to container view. seealso: `viewDidLayoutSubviews()` self.viewModel.composeStatusContentTableViewCell.textEditorViewContainerView.addSubview(self.autoCompleteViewController.view) self.addChild(self.autoCompleteViewController) self.autoCompleteViewController.didMove(toParent: self) self.autoCompleteViewController.view.isHidden = true self.tableView.autoCompleteViewController = self.autoCompleteViewController } self.updateAutoCompleteViewControllerLayout() self.autoCompleteViewController.view.isHidden = info == nil guard let info = info else { return } let symbolBoundingRectInContainer = textEditorView.textView.convert(info.symbolBoundingRect, to: self.autoCompleteViewController.chevronView) self.autoCompleteViewController.view.frame.origin.y = info.textBoundingRect.maxY self.autoCompleteViewController.viewModel.symbolBoundingRect.value = symbolBoundingRectInContainer self.autoCompleteViewController.viewModel.inputText.value = String(info.inputText) } .store(in: &disposeBag) // bind publish bar button state viewModel.$isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishButton) .store(in: &disposeBag) // bind media button toolbar state viewModel.$isMediaToolbarButtonEnabled .receive(on: DispatchQueue.main) .sink { [weak self] isMediaToolbarButtonEnabled in guard let self = self else { return } self.composeToolbarView.mediaBarButtonItem.isEnabled = isMediaToolbarButtonEnabled self.composeToolbarView.mediaButton.isEnabled = isMediaToolbarButtonEnabled } .store(in: &disposeBag) // bind poll button toolbar state viewModel.$isPollToolbarButtonEnabled .receive(on: DispatchQueue.main) .sink { [weak self] isPollToolbarButtonEnabled in guard let self = self else { return } self.composeToolbarView.pollBarButtonItem.isEnabled = isPollToolbarButtonEnabled self.composeToolbarView.pollButton.isEnabled = isPollToolbarButtonEnabled } .store(in: &disposeBag) Publishers.CombineLatest( viewModel.$isPollComposing, viewModel.$isPollToolbarButtonEnabled ) .receive(on: DispatchQueue.main) .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in guard let self = self else { return } guard isPollToolbarButtonEnabled else { let accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel return } let accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) // bind image picker toolbar state viewModel.$attachmentServices .receive(on: DispatchQueue.main) .sink { [weak self] attachmentServices in guard let self = self else { return } let isEnabled = attachmentServices.count < self.viewModel.maxMediaAttachments self.composeToolbarView.mediaBarButtonItem.isEnabled = isEnabled self.composeToolbarView.mediaButton.isEnabled = isEnabled self.resetImagePicker() } .store(in: &disposeBag) // bind content warning button state viewModel.$isContentWarningComposing .receive(on: DispatchQueue.main) .sink { [weak self] isContentWarningComposing in guard let self = self else { return } let accessibilityLabel = isContentWarningComposing ? L10n.Scene.Compose.Accessibility.disableContentWarning : L10n.Scene.Compose.Accessibility.enableContentWarning self.composeToolbarView.contentWarningBarButtonItem.accessibilityLabel = accessibilityLabel self.composeToolbarView.contentWarningButton.accessibilityLabel = accessibilityLabel } .store(in: &disposeBag) // bind visibility toolbar UI Publishers.CombineLatest( viewModel.$selectedStatusVisibility, viewModel.traitCollectionDidChangePublisher ) .receive(on: DispatchQueue.main) .sink { [weak self] type, _ in guard let self = self else { return } let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) self.composeToolbarView.visibilityBarButtonItem.image = image self.composeToolbarView.visibilityButton.setImage(image, for: .normal) self.composeToolbarView.activeVisibilityType.value = type } .store(in: &disposeBag) viewModel.$characterCount .receive(on: DispatchQueue.main) .sink { [weak self] characterCount in guard let self = self else { return } let count = self.viewModel.composeContentLimit - characterCount self.composeToolbarView.characterCountLabel.text = "\(count)" self.characterCountLabel.text = "\(count)" let font: UIFont let textColor: UIColor let accessibilityLabel: String switch count { case _ where count < 0: font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) textColor = Asset.Colors.danger.color accessibilityLabel = L10n.A11y.Plural.Count.inputLimitExceeds(abs(count)) default: font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) textColor = Asset.Colors.Label.secondary.color accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(count) } self.composeToolbarView.characterCountLabel.font = font self.composeToolbarView.characterCountLabel.textColor = textColor self.composeToolbarView.characterCountLabel.accessibilityLabel = accessibilityLabel self.characterCountLabel.font = font self.characterCountLabel.textColor = textColor self.characterCountLabel.accessibilityLabel = accessibilityLabel self.characterCountLabel.sizeToFit() } .store(in: &disposeBag) // bind custom emoji picker UI viewModel.customEmojiViewModel?.emojis .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] emojis in guard let self = self else { return } if emojis.isEmpty { self.customEmojiPickerInputView.activityIndicatorView.startAnimating() } else { self.customEmojiPickerInputView.activityIndicatorView.stopAnimating() } }) .store(in: &disposeBag) // setup snap behavior Publishers.CombineLatest( viewModel.$repliedToCellFrame, viewModel.$collectionViewState ) .receive(on: DispatchQueue.main) .sink { [weak self] repliedToCellFrame, collectionViewState in guard let self = self else { return } guard repliedToCellFrame != .zero else { return } switch collectionViewState { case .fold: self.tableView.contentInset.top = -repliedToCellFrame.height os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: set contentInset.top: -%s", ((#file as NSString).lastPathComponent), #line, #function, repliedToCellFrame.height.description) case .expand: self.tableView.contentInset.top = 0 } } .store(in: &disposeBag) configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar.value) Publishers.CombineLatest( keyboardHasShortcutBar, viewModel.traitCollectionDidChangePublisher ) .receive(on: DispatchQueue.main) .sink { [weak self] keyboardHasShortcutBar, _ in guard let self = self else { return } self.configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar) } .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // update MetaText without trigger call underlaying `UITextStorage.processEditing` _ = textEditorView.processEditing(textEditorView.textStorage) markTextEditorViewBecomeFirstResponser() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) viewModel.isViewAppeared = true } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) configurePublishButtonApperance() viewModel.traitCollectionDidChangePublisher.send() } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() updateAutoCompleteViewControllerLayout() } private func updateAutoCompleteViewControllerLayout() { // pin autoCompleteViewController frame to current view if let containerView = autoCompleteViewController.view.superview { let viewFrameInWindow = containerView.convert(autoCompleteViewController.view.frame, to: view) if viewFrameInWindow.origin.x != 0 { autoCompleteViewController.view.frame.origin.x = -viewFrameInWindow.origin.x } autoCompleteViewController.view.frame.size.width = view.frame.width } } } extension ComposeViewController { private var textEditorView: MetaText { return viewModel.composeStatusContentTableViewCell.metaText } private func markTextEditorViewBecomeFirstResponser() { textEditorView.textView.becomeFirstResponder() } private func contentWarningEditorTextView() -> UITextView? { viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView } private func pollOptionCollectionViewCell(of item: ComposeStatusPollItem) -> ComposeStatusPollOptionCollectionViewCell? { guard case .pollOption = item else { return nil } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } guard let indexPath = dataSource.indexPath(for: item), let cell = viewModel.composeStatusPollTableViewCell.collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { return nil } return cell } private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } let items = dataSource.snapshot().itemIdentifiers(inSection: .main) let firstPollItem = items.first { item -> Bool in guard case .pollOption = item else { return false } return true } guard let item = firstPollItem else { return nil } return pollOptionCollectionViewCell(of: item) } private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } let items = dataSource.snapshot().itemIdentifiers(inSection: .main) let lastPollItem = items.last { item -> Bool in guard case .pollOption = item else { return false } return true } guard let item = lastPollItem else { return nil } return pollOptionCollectionViewCell(of: item) } private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { guard let cell = firstPollOptionCollectionViewCell() else { return } cell.pollOptionView.optionTextField.becomeFirstResponder() } private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { guard let cell = lastPollOptionCollectionViewCell() else { return } cell.pollOptionView.optionTextField.becomeFirstResponder() } private func showDismissConfirmAlertController() { let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in guard let self = self else { return } self.dismiss(animated: true, completion: nil) } alertController.addAction(discardAction) let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) alertController.addAction(cancelAction) alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem present(alertController, animated: true, completion: nil) } private func resetImagePicker() { let selectionLimit = max(1, viewModel.maxMediaAttachments - viewModel.attachmentServices.count) let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) photoLibraryPicker = createImagePicker(configuration: configuration) } private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { let imagePicker = PHPickerViewController(configuration: configuration) imagePicker.delegate = self return imagePicker } private func setupBackgroundColor(theme: Theme) { let backgroundColor = UIColor(dynamicProvider: { traitCollection in switch traitCollection.userInterfaceStyle { case .light: return .systemBackground default: return theme.systemElevatedBackgroundColor } }) view.backgroundColor = backgroundColor tableView.backgroundColor = backgroundColor composeToolbarBackgroundView.backgroundColor = theme.composeToolbarBackgroundColor } // keyboard shortcutBar private func setupInputAssistantItem(item: UITextInputAssistantItem) { let barButtonItems = [ composeToolbarView.mediaBarButtonItem, composeToolbarView.pollBarButtonItem, composeToolbarView.contentWarningBarButtonItem, composeToolbarView.visibilityBarButtonItem, ] let group = UIBarButtonItemGroup(barButtonItems: barButtonItems, representativeItem: nil) item.trailingBarButtonGroups = [group] } private func configureToolbarDisplay(keyboardHasShortcutBar: Bool) { switch self.traitCollection.userInterfaceIdiom { case .pad: let shouldHideToolbar = keyboardHasShortcutBar && self.traitCollection.horizontalSizeClass == .regular self.composeToolbarView.alpha = shouldHideToolbar ? 0 : 1 self.composeToolbarBackgroundView.alpha = shouldHideToolbar ? 0 : 1 default: break } } private func configureNavigationBarTitleStyle() { switch traitCollection.userInterfaceIdiom { case .pad: navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular default: break } } } extension ComposeViewController { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) guard viewModel.shouldDismiss else { showDismissConfirmAlertController() return } dismiss(animated: true, completion: nil) } @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) do { try viewModel.checkAttachmentPrecondition() } catch { let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) alertController.addAction(okAction) coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) return } guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { // TODO: handle error return } context.statusPublishService.publish(composeViewModel: viewModel) dismiss(animated: true, completion: nil) } } // MARK: - MetaTextDelegate extension ComposeViewController: MetaTextDelegate { func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { let string = metaText.textStorage.string let content = MastodonContent( content: string, emojis: viewModel.customEmojiViewModel?.emojiMapping.value ?? [:] ) let metaContent = MastodonMetaContent.convert(text: content) return metaContent } } // MARK: - UITextViewDelegate extension ComposeViewController: UITextViewDelegate { func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { setupInputAssistantItem(item: textView.inputAssistantItem) return true } func textViewDidChange(_ textView: UITextView) { switch textView { case textEditorView.textView: // update model let metaText = self.textEditorView let backedString = metaText.backedString viewModel.composeStatusAttribute.composeContent = backedString logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(backedString)") // configure auto completion setupAutoComplete(for: textView) default: assertionFailure() } } struct AutoCompleteInfo { // model let inputText: Substring // range let symbolRange: Range let symbolString: Substring let toCursorRange: Range let toCursorString: Substring let toHighlightEndRange: Range let toHighlightEndString: Substring // geometry var textBoundingRect: CGRect = .zero var symbolBoundingRect: CGRect = .zero } private func setupAutoComplete(for textView: UITextView) { guard var autoCompletion = ComposeViewController.scanAutoCompleteInfo(textView: textView) else { viewModel.autoCompleteInfo = nil return } os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: auto complete %s (%s)", ((#file as NSString).lastPathComponent), #line, #function, String(autoCompletion.toHighlightEndString), String(autoCompletion.toCursorString)) // get layout text bounding rect var glyphRange = NSRange() textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.toCursorRange, in: textView.text), actualGlyphRange: &glyphRange) let textContainer = textView.layoutManager.textContainers[0] let textBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) let retryLayoutTimes = viewModel.autoCompleteRetryLayoutTimes guard textBoundingRect.size != .zero else { viewModel.autoCompleteRetryLayoutTimes += 1 // avoid infinite loop guard retryLayoutTimes < 3 else { return } // needs retry calculate layout when the rect position changing DispatchQueue.main.async { self.setupAutoComplete(for: textView) } return } viewModel.autoCompleteRetryLayoutTimes = 0 // get symbol bounding rect textView.layoutManager.characterRange(forGlyphRange: NSRange(autoCompletion.symbolRange, in: textView.text), actualGlyphRange: &glyphRange) let symbolBoundingRect = textView.layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer) // set bounding rect and trigger layout autoCompletion.textBoundingRect = textBoundingRect autoCompletion.symbolBoundingRect = symbolBoundingRect viewModel.autoCompleteInfo = autoCompletion } private static func scanAutoCompleteInfo(textView: UITextView) -> AutoCompleteInfo? { guard let text = textView.text, textView.selectedRange.location > 0, !text.isEmpty, let selectedRange = Range(textView.selectedRange, in: text) else { return nil } let cursorIndex = selectedRange.upperBound let _highlightStartIndex: String.Index? = { var index = text.index(before: cursorIndex) while index > text.startIndex { let char = text[index] if char == "@" || char == "#" || char == ":" { return index } index = text.index(before: index) } assert(index == text.startIndex) let char = text[index] if char == "@" || char == "#" || char == ":" { return index } else { return nil } }() guard let highlightStartIndex = _highlightStartIndex else { return nil } let scanRange = NSRange(highlightStartIndex..= cursorIndex else { return nil } let symbolRange = highlightStartIndex.. Bool { switch textView { case textEditorView.textView: return false default: return true } } func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { switch textView { case textEditorView.textView: return false default: return true } } } // MARK: - ComposeToolbarViewDelegate extension ComposeViewController: ComposeToolbarViewDelegate { func composeToolbarView(_ composeToolbarView: ComposeToolbarView, mediaButtonDidPressed sender: Any, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) { switch type { case .photoLibrary: present(photoLibraryPicker, animated: true, completion: nil) case .camera: present(imagePickerController, animated: true, completion: nil) case .browse: #if SNAPSHOT guard let image = UIImage(named: "Athens") else { return } let attachmentService = MastodonAttachmentService( context: context, image: image, initialAuthenticationBox: viewModel.authenticationBox ) viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] #else present(documentPickerController, animated: true, completion: nil) #endif } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: Any) { // toggle poll composing state viewModel.isPollComposing.toggle() // cancel custom picker input viewModel.isCustomEmojiComposing = false // setup initial poll option if needs if viewModel.isPollComposing, viewModel.pollOptionAttributes.isEmpty { viewModel.pollOptionAttributes = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()] } if viewModel.isPollComposing { // Magic RunLoop DispatchQueue.main.async { self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() } } else { markTextEditorViewBecomeFirstResponser() } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: Any) { viewModel.isCustomEmojiComposing.toggle() } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: Any) { // cancel custom picker input viewModel.isCustomEmojiComposing = false // restore first responder for text editor when content warning dismiss if viewModel.isContentWarningComposing { if contentWarningEditorTextView()?.isFirstResponder == true { markTextEditorViewBecomeFirstResponser() } } // toggle composing status viewModel.isContentWarningComposing.toggle() // active content warning after toggled if viewModel.isContentWarningComposing { contentWarningEditorTextView()?.becomeFirstResponder() } } func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: Any, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { viewModel.selectedStatusVisibility = type } } // MARK: - UIScrollViewDelegate extension ComposeViewController { func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { guard scrollView === tableView else { return } let repliedToCellFrame = viewModel.repliedToCellFrame guard repliedToCellFrame != .zero else { return } // try to find some patterns: // print(""" // repliedToCellFrame: \(viewModel.repliedToCellFrame.value.height) // scrollView.contentOffset.y: \(scrollView.contentOffset.y) // scrollView.contentSize.height: \(scrollView.contentSize.height) // scrollView.frame: \(scrollView.frame) // scrollView.adjustedContentInset.top: \(scrollView.adjustedContentInset.top) // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) // """) switch viewModel.collectionViewState { case .fold: os_log("%{public}s[%{public}ld], %{public}s: fold", ((#file as NSString).lastPathComponent), #line, #function) guard velocity.y < 0 else { return } let offsetY = scrollView.contentOffset.y + scrollView.adjustedContentInset.top if offsetY < -44 { tableView.contentInset.top = 0 targetContentOffset.pointee = CGPoint(x: 0, y: -scrollView.adjustedContentInset.top) viewModel.collectionViewState = .expand } case .expand: os_log("%{public}s[%{public}ld], %{public}s: expand", ((#file as NSString).lastPathComponent), #line, #function) guard velocity.y > 0 else { return } // check if top across let topOffset = (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) - repliedToCellFrame.height // check if bottom bounce let bottomOffsetY = scrollView.contentOffset.y + (scrollView.frame.height - scrollView.adjustedContentInset.bottom) let bottomOffset = bottomOffsetY - scrollView.contentSize.height if topOffset > 44 { // do not interrupt user scrolling viewModel.collectionViewState = .fold } else if bottomOffset > 44 { tableView.contentInset.top = -repliedToCellFrame.height targetContentOffset.pointee = CGPoint(x: 0, y: -repliedToCellFrame.height) viewModel.collectionViewState = .fold } } } } // MARK: - UITableViewDelegate extension ComposeViewController: UITableViewDelegate { } // MARK: - UICollectionViewDelegate extension ComposeViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription) if collectionView === customEmojiPickerInputView.collectionView { guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return } let item = diffableDataSource.itemIdentifier(for: indexPath) guard case let .emoji(attribute) = item else { return } let emoji = attribute.emoji // make click sound UIDevice.current.playInputClick() // retrieve active text input and insert emoji // the trailing space is REQUIRED to make regex happy _ = viewModel.customEmojiPickerInputViewModel.insertText(":\(emoji.shortcode): ") } else { // do nothing } } } // MARK: - UIAdaptivePresentationControllerDelegate extension ComposeViewController: UIAdaptivePresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle { switch traitCollection.horizontalSizeClass { case .compact: return .overFullScreen default: return .pageSheet } } func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return viewModel.shouldDismiss } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) showDismissConfirmAlertController() } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } // MARK: - PHPickerViewControllerDelegate extension ComposeViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) let attachmentServices: [MastodonAttachmentService] = results.map { result in let service = MastodonAttachmentService( context: context, pickerResult: result, initialAuthenticationBox: viewModel.authenticationBox ) return service } viewModel.attachmentServices = viewModel.attachmentServices + attachmentServices } } // MARK: - UIImagePickerControllerDelegate extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { picker.dismiss(animated: true, completion: nil) guard let image = info[.originalImage] as? UIImage else { return } let attachmentService = MastodonAttachmentService( context: context, image: image, initialAuthenticationBox: viewModel.authenticationBox ) viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] } func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) picker.dismiss(animated: true, completion: nil) } } // MARK: - UIDocumentPickerDelegate extension ComposeViewController: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } let attachmentService = MastodonAttachmentService( context: context, documentURL: url, initialAuthenticationBox: viewModel.authenticationBox ) viewModel.attachmentServices = viewModel.attachmentServices + [attachmentService] } } // MARK: - ComposeStatusAttachmentTableViewCellDelegate extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { guard let diffableDataSource = viewModel.composeStatusAttachmentTableViewCell.dataSource else { return } guard let indexPath = viewModel.composeStatusAttachmentTableViewCell.collectionView.indexPath(for: cell) else { return } guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } guard case let .attachment(attachmentService) = item else { return } var attachmentServices = viewModel.attachmentServices guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } let removedItem = attachmentServices[index] attachmentServices.remove(at: index) viewModel.attachmentServices = attachmentServices // cancel task removedItem.disposeBag.removeAll() } } // MARK: - ComposeStatusPollOptionCollectionViewCellDelegate extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) { setupInputAssistantItem(item: textField.inputAssistantItem) // FIXME: make poll section visible // DispatchQueue.main.async { // self.collectionView.scroll(to: .bottom, animated: true) // } } // handle delete backward event for poll option input func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { guard (text ?? "").isEmpty else { return } guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard case let .pollOption(attribute) = item else { return } var pollAttributes = viewModel.pollOptionAttributes guard let index = pollAttributes.firstIndex(of: attribute) else { return } // mark previous (fallback to next) item of removed middle poll option become first responder let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main) if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { guard index > 0 else { return nil } let indexBeforeRemoved = pollItems.index(before: indexOfItem) let itemBeforeRemoved = pollItems[indexBeforeRemoved] return pollOptionCollectionViewCell(of: itemBeforeRemoved) } func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { guard index < pollItems.count - 1 else { return nil } let indexAfterRemoved = pollItems.index(after: index) let itemAfterRemoved = pollItems[indexAfterRemoved] return pollOptionCollectionViewCell(of: itemAfterRemoved) } var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() if cell == nil { cell = cellAfterRemoved() } cell?.pollOptionView.optionTextField.becomeFirstResponder() } guard pollAttributes.count > 2 else { return } pollAttributes.remove(at: index) // update data source viewModel.pollOptionAttributes = pollAttributes } // handle keyboard return event for poll option input func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main).filter { item in guard case .pollOption = item else { return false } return true } guard let item = dataSource.itemIdentifier(for: indexPath) else { return } guard let index = pollItems.firstIndex(of: item) else { return } if index == pollItems.count - 1 { // is the last viewModel.createNewPollOptionIfPossible() DispatchQueue.main.async { self.markLastPollOptionCollectionViewCellBecomeFirstResponser() } } else { // not the last let indexAfter = pollItems.index(after: index) let itemAfter = pollItems[indexAfter] let cell = pollOptionCollectionViewCell(of: itemAfter) cell?.pollOptionView.optionTextField.becomeFirstResponder() } } } // MARK: - ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { viewModel.createNewPollOptionIfPossible() DispatchQueue.main.async { self.markLastPollOptionCollectionViewCellBecomeFirstResponser() } } } // MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) { viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption } } // MARK: - ComposeStatusContentTableViewCellDelegate extension ComposeViewController: ComposeStatusContentTableViewCellDelegate { func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool { setupInputAssistantItem(item: textView.inputAssistantItem) return true } } // MARK: - AutoCompleteViewControllerDelegate extension ComposeViewController: AutoCompleteViewControllerDelegate { func autoCompleteViewController(_ viewController: AutoCompleteViewController, didSelectItem item: AutoCompleteItem) { guard let info = viewModel.autoCompleteInfo else { return } let _replacedText: String? = { var text: String switch item { case .hashtag(let hashtag): text = "#" + hashtag.name case .hashtagV1(let hashtagName): text = "#" + hashtagName case .account(let account): text = "@" + account.acct case .emoji(let emoji): text = ":" + emoji.shortcode + ":" case .bottomLoader: return nil } return text }() guard let replacedText = _replacedText else { return } guard let text = textEditorView.textView.text else { return } let range = NSRange(info.toHighlightEndRange, in: text) textEditorView.textStorage.replaceCharacters(in: range, with: replacedText) DispatchQueue.main.async { self.textEditorView.textView.insertText(" ") // trigger textView delegate update } viewModel.autoCompleteInfo = nil switch item { case .emoji, .bottomLoader: break default: // set selected range except emoji let newRange = NSRange(location: range.location + (replacedText as NSString).length, length: 0) guard textEditorView.textStorage.length <= newRange.location else { return } textEditorView.textView.selectedRange = newRange } } } extension ComposeViewController { override var keyCommands: [UIKeyCommand]? { composeKeyCommands } } extension ComposeViewController { enum ComposeKeyCommand: String, CaseIterable { case discardPost case publishPost case mediaBrowse case mediaPhotoLibrary case mediaCamera case togglePoll case toggleContentWarning case selectVisibilityPublic // TODO: remove selectVisibilityUnlisted from codebase // case selectVisibilityUnlisted case selectVisibilityPrivate case selectVisibilityDirect var title: String { switch self { case .discardPost: return L10n.Scene.Compose.Keyboard.discardPost case .publishPost: return L10n.Scene.Compose.Keyboard.publishPost case .mediaBrowse: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.browse) case .mediaPhotoLibrary: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.photoLibrary) case .mediaCamera: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.camera) case .togglePoll: return L10n.Scene.Compose.Keyboard.togglePoll case .toggleContentWarning: return L10n.Scene.Compose.Keyboard.toggleContentWarning case .selectVisibilityPublic: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.public) // case .selectVisibilityUnlisted: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.unlisted) case .selectVisibilityPrivate: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.private) case .selectVisibilityDirect: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.direct) } } // UIKeyCommand input var input: String { switch self { case .discardPost: return "w" // + command case .publishPost: return "\r" // (enter) + command case .mediaBrowse: return "b" // + option + command case .mediaPhotoLibrary: return "p" // + option + command case .mediaCamera: return "c" // + option + command case .togglePoll: return "p" // + shift + command case .toggleContentWarning: return "c" // + shift + command case .selectVisibilityPublic: return "1" // + command // case .selectVisibilityUnlisted: return "2" // + command case .selectVisibilityPrivate: return "2" // + command case .selectVisibilityDirect: return "3" // + command } } var modifierFlags: UIKeyModifierFlags { switch self { case .discardPost: return [.command] case .publishPost: return [.command] case .mediaBrowse: return [.alternate, .command] case .mediaPhotoLibrary: return [.alternate, .command] case .mediaCamera: return [.alternate, .command] case .togglePoll: return [.shift, .command] case .toggleContentWarning: return [.shift, .command] case .selectVisibilityPublic: return [.command] // case .selectVisibilityUnlisted: return [.command] case .selectVisibilityPrivate: return [.command] case .selectVisibilityDirect: return [.command] } } var propertyList: Any { return rawValue } } var composeKeyCommands: [UIKeyCommand]? { ComposeKeyCommand.allCases.map { command in UIKeyCommand( title: command.title, image: nil, action: #selector(Self.composeKeyCommandHandler(_:)), input: command.input, modifierFlags: command.modifierFlags, propertyList: command.propertyList, alternates: [], discoverabilityTitle: nil, attributes: [], state: .off ) } } @objc private func composeKeyCommandHandler(_ sender: UIKeyCommand) { guard let rawValue = sender.propertyList as? String, let command = ComposeKeyCommand(rawValue: rawValue) else { return } switch command { case .discardPost: cancelBarButtonItemPressed(cancelBarButtonItem) case .publishPost: publishBarButtonItemPressed(publishBarButtonItem) case .mediaBrowse: present(documentPickerController, animated: true, completion: nil) case .mediaPhotoLibrary: present(photoLibraryPicker, animated: true, completion: nil) case .mediaCamera: guard UIImagePickerController.isSourceTypeAvailable(.camera) else { return } present(imagePickerController, animated: true, completion: nil) case .togglePoll: composeToolbarView.pollButton.sendActions(for: .touchUpInside) case .toggleContentWarning: composeToolbarView.contentWarningButton.sendActions(for: .touchUpInside) case .selectVisibilityPublic: viewModel.selectedStatusVisibility = .public // case .selectVisibilityUnlisted: // viewModel.selectedStatusVisibility.value = .unlisted case .selectVisibilityPrivate: viewModel.selectedStatusVisibility = .private case .selectVisibilityDirect: viewModel.selectedStatusVisibility = .direct } } }