Tusker/Tusker/Screens/Compose/ComposeHostingController.swift

279 lines
9.6 KiB
Swift

//
// 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<ComposeHostingController.Wrapper>, 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)
}
}