// // ComposeHostingController.swift // Tusker // // Created by Shadowfacts on 8/22/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Combine import Pachyderm import PencilKit protocol ComposeHostingControllerDelegate: AnyObject { func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool } class ComposeHostingController: UIHostingController { weak var delegate: ComposeHostingControllerDelegate? let mastodonController: MastodonController let uiState: ComposeUIState var draft: Draft { uiState.draft } private var cancellables = [AnyCancellable]() private var toolbarHeight: CGFloat = 44 private var mainToolbar: UIToolbar! private var inputAccessoryToolbar: UIToolbar! override var inputAccessoryView: UIView? { inputAccessoryToolbar } init(draft: Draft? = nil, mastodonController: MastodonController) { self.mastodonController = mastodonController let realDraft = draft ?? Draft(accountID: mastodonController.accountInfo!.id) DraftsManager.shared.add(realDraft) self.uiState = ComposeUIState(draft: realDraft) // we need our own environment object wrapper so that we can set the mastodon controller as an // environment object and setup the draft change listener while still having a concrete type // to use as the UIHostingController type parameter let container = ComposeContainerView( mastodonController: mastodonController, uiState: uiState ) super.init(rootView: container) self.uiState.delegate = self // main toolbar is shown at the bottom of the screen, the input accessory is attached to the keyboard while editing // (except for MainComposeTextView which has its own accessory to add formatting buttons) mainToolbar = UIToolbar() mainToolbar.translatesAutoresizingMaskIntoConstraints = false mainToolbar.isAccessibilityElement = true setupToolbarItems(toolbar: mainToolbar, input: nil) inputAccessoryToolbar = UIToolbar() inputAccessoryToolbar.translatesAutoresizingMaskIntoConstraints = false inputAccessoryToolbar.isAccessibilityElement = true setupToolbarItems(toolbar: inputAccessoryToolbar, input: nil) NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(composeKeyboardDidHide(_:)), name: UIResponder.keyboardDidHideNotification, object: nil) // add the height of the toolbar itself to the bottom of the safe area so content inside SwiftUI ScrollView doesn't underflow it updateAdditionalSafeAreaInsets() pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) self.uiState.$draft .flatMap(\.$visibility) .sink(receiveValue: self.visibilityChanged) .store(in: &cancellables) self.uiState.$draft .flatMap(\.$localOnly) .sink(receiveValue: self.localOnlyChanged) .store(in: &cancellables) self.uiState.$draft .flatMap(\.objectWillChange) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) .sink { DraftsManager.save() } .store(in: &cancellables) self.uiState.$currentInput .sink { [unowned self] in self.setupToolbarItems(toolbar: self.inputAccessoryToolbar, input: $0) } .store(in: &cancellables) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) if let parent = parent { parent.view.addSubview(mainToolbar) NSLayoutConstraint.activate([ mainToolbar.leadingAnchor.constraint(equalTo: view.leadingAnchor), mainToolbar.trailingAnchor.constraint(equalTo: view.trailingAnchor), // use the top anchor of the toolbar so our additionalSafeAreaInsets (which has the bottom as the toolbar height) don't affect it mainToolbar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if !draft.hasContent { DraftsManager.shared.remove(draft) } DraftsManager.save() } private func setupToolbarItems(toolbar: UIToolbar, input: ComposeInput?) { var items: [UIBarButtonItem] = [] items.append(UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed))) let visibilityItem = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) visibilityItem.tag = ViewTags.composeVisibilityBarButton items.append(visibilityItem) if mastodonController.instanceFeatures.localOnlyPosts { let item = UIBarButtonItem(image: nil, style: .plain, target: nil, action: nil) item.tag = ViewTags.composeLocalOnlyBarButton items.append(item) localOnlyChanged(draft.localOnly) } if input?.toolbarElements.contains(.emojiPicker) == true { items.append(UIBarButtonItem(image: UIImage(systemName: "face.smiling"), style: .plain, target: self, action: #selector(emojiPickerButtonPressed))) } items.append(UIBarButtonItem(systemItem: .flexibleSpace)) if input?.toolbarElements.contains(.formattingButtons) == true, Preferences.shared.statusContentType != .plain { for (idx, format) in StatusFormat.allCases.enumerated() { let item: UIBarButtonItem if let image = format.image { item = UIBarButtonItem(image: image, style: .plain, target: self, action: #selector(formatButtonPressed(_:))) } else if let (str, attributes) = format.title { item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:))) item.setTitleTextAttributes(attributes, for: .normal) item.setTitleTextAttributes(attributes, for: .highlighted) } else { fatalError("StatusFormat must have either image or title") } item.tag = StatusFormat.allCases.firstIndex(of: format)! item.accessibilityLabel = format.accessibilityLabel items.append(item) if idx != StatusFormat.allCases.count - 1 { let spacer = UIBarButtonItem(systemItem: .fixedSpace) spacer.width = 8 items.append(spacer) } } items.append(UIBarButtonItem(systemItem: .flexibleSpace)) } items.append(UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed))) toolbar.items = items visibilityChanged(draft.visibility) localOnlyChanged(draft.localOnly) } private func updateAdditionalSafeAreaInsets() { additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight, right: 0) } @objc private func composeKeyboardWillShow(_ notification: Foundation.Notification) { keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification) } func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) { mainToolbar.isHidden = true accessoryView.alpha = 1 accessoryView.isHidden = false } @objc private func composeKeyboardWillHide(_ notification: Foundation.Notification) { keyboardWillHide(accessoryView: inputAccessoryToolbar, notification: notification) } func keyboardWillHide(accessoryView: UIView, notification: Foundation.Notification) { mainToolbar.isHidden = false let userInfo = notification.userInfo! let durationObj = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as! NSNumber let duration = TimeInterval(durationObj.doubleValue) let curveValue = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as! NSNumber let curve = UIView.AnimationCurve(rawValue: curveValue.intValue)! let curveOption: UIView.AnimationOptions switch curve { case .easeInOut: curveOption = .curveEaseInOut case .easeIn: curveOption = .curveEaseIn case .easeOut: curveOption = .curveEaseOut case .linear: curveOption = .curveLinear @unknown default: curveOption = .curveLinear } UIView.animate(withDuration: duration, delay: 0, options: curveOption) { accessoryView.alpha = 0 } completion: { (finished) in accessoryView.alpha = 1 } } @objc private func composeKeyboardDidHide(_ notification: Foundation.Notification) { keyboardDidHide(accessoryView: inputAccessoryToolbar, notification: notification) } func keyboardDidHide(accessoryView: UIView, notification: Foundation.Notification) { accessoryView.isHidden = true } private func visibilityChanged(_ newVisibility: Status.Visibility) { for toolbar in [mainToolbar, inputAccessoryToolbar] { guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeVisibilityBarButton }) else { continue } item.image = UIImage(systemName: newVisibility.imageName) item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in let state = visibility == newVisibility ? UIMenuElement.State.on : .off return UIAction(title: visibility.displayName, subtitle: visibility.subtitle, image: UIImage(systemName: visibility.unfilledImageName), state: state) { [unowned self] (_) in self.draft.visibility = visibility } } item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements) } } private func localOnlyChanged(_ localOnly: Bool) { for toolbar in [mainToolbar, inputAccessoryToolbar] { guard let item = toolbar?.items?.first(where: { $0.tag == ViewTags.composeLocalOnlyBarButton }) else { continue } if localOnly { item.image = UIImage(named: "link.broken") item.accessibilityLabel = "Local-only" } else { item.image = UIImage(systemName: "link") item.accessibilityLabel = "Federated" } let instanceSubtitle = "Only \(mastodonController.accountInfo!.instanceURL.host!)" item.menu = UIMenu(children: [ UIAction(title: "Local-only", subtitle: instanceSubtitle, image: UIImage(named: "link.broken"), state: localOnly ? .on : .off) { [unowned self] (_) in self.draft.localOnly = true }, UIAction(title: "Federated", image: UIImage(systemName: "link"), state: localOnly ? .off : .on) { [unowned self] (_) in self.draft.localOnly = false }, ]) } } override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { guard draft.attachments.allSatisfy({ $0.data.type == .image }) else { return false } // todo: if providers are videos, this technically allows invalid video/image combinations return itemProviders.count + draft.attachments.count <= 4 } else { return true } } override func paste(itemProviders: [NSItemProvider]) { for provider in itemProviders where provider.canLoadObject(ofClass: CompositionAttachment.self) { provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in guard let attachment = object as? CompositionAttachment else { return } DispatchQueue.main.async { self.draft.attachments.append(attachment) } } } } // MARK: Interaction @objc func cwButtonPressed() { draft.contentWarningEnabled = !draft.contentWarningEnabled } @objc func formatButtonPressed(_ sender: UIBarButtonItem) { let format = StatusFormat.allCases[sender.tag] uiState.currentInput?.applyFormat(format) } @objc func emojiPickerButtonPressed() { guard uiState.autocompleteState == nil else { return } uiState.shouldEmojiAutocompletionBeginExpanded = true uiState.currentInput?.beginAutocompletingEmoji() } @objc func draftsButtonPresed() { let draftsVC = DraftsTableViewController(account: mastodonController.accountInfo!, exclude: draft) draftsVC.delegate = self present(UINavigationController(rootViewController: draftsVC), animated: true) } } extension ComposeHostingController: ComposeUIStateDelegate { var assetPickerDelegate: AssetPickerViewControllerDelegate? { self } func dismissCompose(mode: ComposeUIState.DismissMode) { let dismissed = delegate?.dismissCompose(mode: mode) ?? false if !dismissed { self.dismiss(animated: true) } } func presentAssetPickerSheet() { if #available(iOS 15.0, *) { let picker = AssetPickerViewController() picker.assetPickerDelegate = self picker.modalPresentationStyle = .pageSheet picker.overrideUserInterfaceStyle = .dark let sheet = picker.sheetPresentationController! sheet.detents = [.medium(), .large()] sheet.prefersEdgeAttachedInCompactHeight = true self.present(picker, animated: true) } else { presentOldAssetPickerSheet() } } private func presentOldAssetPickerSheet() { let sheetContainer = AssetPickerSheetContainerViewController() sheetContainer.assetPicker.assetPickerDelegate = self self.present(sheetContainer, animated: true) } func presentComposeDrawing() { let drawing: PKDrawing if case let .edit(id) = uiState.composeDrawingMode, let attachment = draft.attachments.first(where: { $0.id == id }), case let .drawing(existingDrawing) = attachment.data { drawing = existingDrawing } else { drawing = PKDrawing() } let drawingVC = ComposeDrawingViewController(editing: drawing) drawingVC.delegate = self let nav = UINavigationController(rootViewController: drawingVC) nav.modalPresentationStyle = .fullScreen present(nav, animated: true) } } extension ComposeHostingController: AssetPickerViewControllerDelegate { func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool { if mastodonController.instanceFeatures.mastodonAttachmentRestrictions { if (type == .video && draft.attachments.count > 0) || draft.attachments.contains(where: { $0.data.type == .video }) || assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) { return false } return draft.attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4 } else { return true } } func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) { let attachments = attachments.map { CompositionAttachment(data: $0) } withAnimation { draft.attachments.append(contentsOf: attachments) } } } extension ComposeHostingController: DraftsTableViewControllerDelegate { func draftSelectionCanceled() { } func shouldSelectDraft(_ draft: Draft, completion: @escaping (Bool) -> Void) { if draft.inReplyToID != self.draft.inReplyToID, self.draft.hasContent { let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert) alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in completion(false) })) alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in completion(true) })) // we can't present the laert ourselves since the compose VC is already presenting the draft selector // but presenting on the presented view controller seems hacky, is there a better way to do this? presentedViewController!.present(alertController, animated: true) } else { completion(true) } } func draftSelected(_ draft: Draft) { if self.draft.hasContent { DraftsManager.save() } else { DraftsManager.shared.remove(self.draft) } uiState.draft = draft } func draftSelectionCompleted() { } } extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return Preferences.shared.automaticallySaveDrafts || !draft.hasContent } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { uiState.isShowingSaveDraftSheet = true } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { DraftsManager.save() } } extension ComposeHostingController: ComposeDrawingViewControllerDelegate { func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) { dismiss(animated: true) } func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) { switch uiState.composeDrawingMode { case nil, .createNew: let attachment = CompositionAttachment(data: .drawing(drawing)) draft.attachments.append(attachment) case let .edit(id): let existing = draft.attachments.first { $0.id == id } existing?.data = .drawing(drawing) } dismiss(animated: true) } }