forked from shadowfacts/Tusker
411 lines
17 KiB
Swift
411 lines
17 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
|
|
|
|
class ComposeHostingController: UIHostingController<ComposeContainerView> {
|
|
|
|
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)
|
|
}
|
|
}
|