// // ComposeHostingController.swift // Tusker // // Created by Shadowfacts on 8/22/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Combine import Pachyderm import PencilKit class ComposeHostingController: UIHostingController { let mastodonController: MastodonController let uiState: ComposeUIState var draft: Draft { uiState.draft } private var cancellables = [AnyCancellable]() private var keyboardHeight: CGFloat = 0 private var toolbarHeight: CGFloat = 44 private var mainToolbar: UIToolbar! private var inputAccessoryToolbar: UIToolbar! private var visibilityBarButtonItems = [UIBarButtonItem]() 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 mainToolbar = createToolbar() inputAccessoryToolbar = createToolbar() NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidHide(_:)), 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() self.uiState.$draft .flatMap(\.$visibility) .sink(receiveValue: self.visibilityChanged) .store(in: &cancellables) self.uiState.$draft .flatMap(\.objectWillChange) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) .sink { DraftsManager.save() } .store(in: &cancellables) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // can't do this in viewDidLoad because viewDidLoad isn't called for UIHostingController // if mainToolbar.superview == nil { // 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 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 createToolbar() -> UIToolbar { let toolbar = UIToolbar() toolbar.translatesAutoresizingMaskIntoConstraints = false toolbar.isAccessibilityElement = true let visibilityAction: Selector? if #available(iOS 14.0, *) { visibilityAction = nil } else { visibilityAction = #selector(visibilityButtonPressed(_:)) } let visibilityItem = UIBarButtonItem(image: UIImage(systemName: draft.visibility.imageName), style: .plain, target: self, action: visibilityAction) visibilityBarButtonItems.append(visibilityItem) visibilityChanged(draft.visibility) toolbar.items = [ UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(cwButtonPressed)), visibilityItem, UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPresed)) ] return toolbar } private func updateAdditionalSafeAreaInsets() { additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: toolbarHeight + keyboardHeight, right: 0) } @objc private func keyboardWillShow(_ notification: Foundation.Notification) { keyboardWillShow(accessoryView: inputAccessoryToolbar, notification: notification) } func keyboardWillShow(accessoryView: UIView, notification: Foundation.Notification) { mainToolbar.isHidden = true accessoryView.alpha = 1 accessoryView.isHidden = false // on iOS 14, SwiftUI safe area automatically includes the keyboard if #available(iOS 14.0, *) { } else { let userInfo = notification.userInfo! let frame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! CGRect // temporarily reset add'l safe area insets so we can access the default inset additionalSafeAreaInsets = .zero // there are a few extra points that come from somewhere, it seems to be four // and without it, the autocomplete suggestions are cut off :S keyboardHeight = frame.height - view.safeAreaInsets.bottom - accessoryView.frame.height + 4 updateAdditionalSafeAreaInsets() } } @objc private func keyboardWillHide(_ 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 } // on iOS 14, SwiftUI safe area automatically includes the keyboard if #available(iOS 14.0, *) { } else { keyboardHeight = 0 updateAdditionalSafeAreaInsets() } } @objc private func keyboardDidHide(_ 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 item in visibilityBarButtonItems { item.image = UIImage(systemName: newVisibility.imageName) item.image!.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) item.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), draft.visibility.displayName) if #available(iOS 14.0, *) { let elements = Status.Visibility.allCases.map { (visibility) -> UIMenuElement in let state = visibility == newVisibility ? UIMenuElement.State.on : .off return UIAction(title: visibility.displayName, image: UIImage(systemName: visibility.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { (_) in self.draft.visibility = visibility } } item.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: elements) } } } override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool { guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else { return false } switch mastodonController.instance.instanceType { case .pleroma: return true case .mastodon: 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 } } 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 visibilityButtonPressed(_ sender: UIBarButtonItem) { // if #available(iOS 14.0, *) { // } else { let alertController = UIAlertController(currentVisibility: draft.visibility) { (visibility) in guard let visibility = visibility else { return } self.draft.visibility = visibility } alertController.popoverPresentationController?.barButtonItem = sender present(alertController, animated: true) // } } @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() { self.dismiss(animated: true) } func presentAssetPickerSheet() { 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 { switch mastodonController.instance.instanceType { case .pleroma: return true case .mastodon: 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 } } func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) { let attachments = attachments.map { CompositionAttachment(data: $0) } 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) } }