453 lines
19 KiB
Swift
453 lines
19 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<ComposeContainerView>, DuckableViewController {
|
|
|
|
weak var delegate: ComposeHostingControllerDelegate?
|
|
weak var duckableDelegate: DuckableViewControllerDelegate?
|
|
|
|
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 willMove(toParent parent: UIViewController?) {
|
|
super.willMove(toParent: parent)
|
|
|
|
if let parent = parent {
|
|
parent.view.addSubview(mainToolbar)
|
|
NSLayoutConstraint.activate([
|
|
mainToolbar.leadingAnchor.constraint(equalTo: parent.view.leadingAnchor),
|
|
mainToolbar.trailingAnchor.constraint(equalTo: parent.view.trailingAnchor),
|
|
mainToolbar.bottomAnchor.constraint(equalTo: parent.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: Duckable
|
|
|
|
func duckableViewControllerWillAnimateDuck(withDuration duration: CGFloat, afterDelay delay: CGFloat) {
|
|
let animator = UIViewPropertyAnimator(duration: duration, curve: .linear) {
|
|
self.mainToolbar.layer.opacity = 0
|
|
}
|
|
animator.startAnimation(afterDelay: delay)
|
|
}
|
|
|
|
func duckableViewControllerDidFinishAnimatingDuck() {
|
|
mainToolbar.layer.opacity = 1
|
|
}
|
|
|
|
// 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: 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: Draft) {
|
|
if self.draft.hasContent {
|
|
DraftsManager.save()
|
|
} else {
|
|
DraftsManager.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) {
|
|
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)
|
|
}
|
|
}
|