// // ComposeHostingController.swift // Tusker // // Created by Shadowfacts on 8/22/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import SwiftUI import Combine import Pachyderm import PencilKit import Duckable protocol ComposeHostingControllerDelegate: AnyObject { func dismissCompose(mode: ComposeUIState.DismissMode) -> Bool } class ComposeHostingController: UIHostingController, DuckableViewController { weak var delegate: ComposeHostingControllerDelegate? weak var duckableDelegate: DuckableViewControllerDelegate? let mastodonController: MastodonController let uiState: ComposeUIState var draft: OldDraft { uiState.draft } private var cancellables = [AnyCancellable]() init(draft: OldDraft? = nil, mastodonController: MastodonController) { self.mastodonController = mastodonController let realDraft = draft ?? OldDraft(accountID: mastodonController.accountInfo!.id) OldDraftsManager.shared.add(realDraft) self.uiState = ComposeUIState(draft: realDraft) let wrapper = Wrapper( mastodonController: mastodonController, uiState: uiState ) super.init(rootView: wrapper) self.uiState.delegate = self pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self) userActivity = UserActivityManager.newPostActivity(accountID: mastodonController.accountInfo!.id) updateNavigationTitle(draft: uiState.draft) self.uiState.$draft .flatMap(\.objectWillChange) .debounce(for: .milliseconds(250), scheduler: DispatchQueue.global(qos: .utility)) .sink { OldDraftsManager.save() } .store(in: &cancellables) self.uiState.$draft .sink { [unowned self] draft in self.updateNavigationTitle(draft: draft) } .store(in: &cancellables) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func updateNavigationTitle(draft: OldDraft) { if let id = draft.inReplyToID, let status = mastodonController.persistentContainer.status(for: id) { navigationItem.title = "Reply to @\(status.account.acct)" } else { navigationItem.title = "New Post" } } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if !draft.hasContent { OldDraftsManager.shared.remove(draft) } OldDraftsManager.save() } 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) } } } } override func accessibilityPerformEscape() -> Bool { dismissCompose(mode: .cancel) return true } // MARK: Duckable func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) { withAnimation(.linear(duration: duration).delay(delay)) { uiState.isDucking = true } } func duckableViewControllerDidFinishAnimatingDuck() { uiState.isDucking = false } // 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() { uiState.isShowingDraftsList = true } } extension ComposeHostingController { struct Wrapper: View { let mastodonController: MastodonController @ObservedObject var uiState: ComposeUIState var draft: OldDraft { uiState.draft } var body: some View { ComposeView() .environmentObject(mastodonController) .environmentObject(uiState) .environmentObject(draft) } } } 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) self.duckableDelegate?.duckableViewControllerWillDismiss(animated: true) } } func presentAssetPickerSheet() { 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) } 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() } present(ComposeDrawingNavigationController(editing: drawing, delegate: self), animated: true) } func selectDraft(_ draft: OldDraft) { if self.draft.hasContent { OldDraftsManager.save() } else { OldDraftsManager.shared.remove(self.draft) } uiState.draft = draft uiState.isShowingDraftsList = false } } 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) } } } // superseded by duckable stuff @available(iOS, obsoleted: 16.0) extension ComposeHostingController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return Preferences.shared.automaticallySaveDrafts || !draft.hasContent } func presentationControllerWillDismiss(_ presentationController: UIPresentationController) { UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: self, for: nil) } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { uiState.isShowingSaveDraftSheet = true } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { OldDraftsManager.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) } }