450 lines
19 KiB
Swift
450 lines
19 KiB
Swift
//
|
|
// ComposeViewController.swift
|
|
// Mastodon
|
|
//
|
|
// Created by MainasuK Cirno on 2021-3-11.
|
|
//
|
|
|
|
import UIKit
|
|
import Combine
|
|
import PhotosUI
|
|
import Meta
|
|
import MetaTextKit
|
|
import MastodonMeta
|
|
import MastodonAsset
|
|
import MastodonCore
|
|
import MastodonUI
|
|
import MastodonLocalization
|
|
import MastodonSDK
|
|
|
|
final class ComposeViewController: UIViewController, NeedsDependency {
|
|
static let minAutoCompleteVisibleHeight: CGFloat = 100
|
|
|
|
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
|
|
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
|
|
|
|
var disposeBag = Set<AnyCancellable>()
|
|
var viewModel: ComposeViewModel
|
|
|
|
init(viewModel: ComposeViewModel) {
|
|
self.viewModel = viewModel
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
lazy var composeContentViewModel: ComposeContentViewModel = {
|
|
|
|
let composeContext: ComposeContentViewModel.ComposeContext
|
|
let initialContent: String
|
|
|
|
switch viewModel.composeContext {
|
|
case .composeStatus:
|
|
composeContext = .composeStatus
|
|
initialContent = viewModel.initialContent
|
|
case .editStatus(let status, let statusSource):
|
|
composeContext = .editStatus(status: status, statusSource: statusSource)
|
|
initialContent = statusSource.text
|
|
}
|
|
|
|
return ComposeContentViewModel(
|
|
context: context,
|
|
authContext: viewModel.authContext,
|
|
composeContext: composeContext,
|
|
destination: viewModel.destination,
|
|
initialContent: initialContent
|
|
)
|
|
}()
|
|
private(set) lazy var composeContentViewController: ComposeContentViewController = {
|
|
let composeContentViewController = ComposeContentViewController()
|
|
composeContentViewController.viewModel = composeContentViewModel
|
|
return composeContentViewController
|
|
}()
|
|
|
|
private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:)))
|
|
|
|
private lazy var publishButton: UIButton = {
|
|
let button = RoundedEdgesButton(type: .custom)
|
|
button.cornerRadius = 10
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
|
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
|
|
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
|
|
button.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
|
|
return button
|
|
}()
|
|
|
|
private lazy var saveButton: UIButton = {
|
|
let button = RoundedEdgesButton(type: .custom)
|
|
button.cornerRadius = 10
|
|
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
|
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
|
|
button.setTitle(L10n.Common.Controls.Actions.save, for: .normal)
|
|
button.addTarget(self, action: #selector(ComposeViewController.publishStatusEdit(_:)), for: .touchUpInside)
|
|
return button
|
|
}()
|
|
|
|
private(set) lazy var saveBarButtonItem: UIBarButtonItem = {
|
|
configurePublishButtonApperance(button: saveButton)
|
|
let shadowBackgroundContainer = ShadowBackgroundContainer()
|
|
saveButton.translatesAutoresizingMaskIntoConstraints = false
|
|
shadowBackgroundContainer.addSubview(saveButton)
|
|
saveButton.pinToParent()
|
|
let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
|
|
return barButtonItem
|
|
}()
|
|
|
|
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
|
|
configurePublishButtonApperance(button: publishButton)
|
|
let shadowBackgroundContainer = ShadowBackgroundContainer()
|
|
publishButton.translatesAutoresizingMaskIntoConstraints = false
|
|
shadowBackgroundContainer.addSubview(publishButton)
|
|
publishButton.pinToParent()
|
|
let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
|
|
return barButtonItem
|
|
}()
|
|
|
|
private func configurePublishButtonApperance(button: UIButton) {
|
|
button.adjustsImageWhenHighlighted = false
|
|
button.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal)
|
|
button.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted)
|
|
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
|
|
button.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
|
|
}
|
|
|
|
|
|
}
|
|
|
|
extension ComposeViewController {
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
navigationItem.leftBarButtonItem = cancelBarButtonItem
|
|
viewModel.traitCollectionDidChangePublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] _ in
|
|
guard let self = self else { return }
|
|
guard self.traitCollection.userInterfaceIdiom == .pad else { return }
|
|
self.navigationItem.rightBarButtonItem = self.rightBarButtonItemForCurrentContext
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
navigationItem.rightBarButtonItem = rightBarButtonItemForCurrentContext
|
|
|
|
addChild(composeContentViewController)
|
|
composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
|
view.addSubview(composeContentViewController.view)
|
|
composeContentViewController.view.pinToParent()
|
|
composeContentViewController.didMove(toParent: self)
|
|
|
|
// bind title
|
|
viewModel.$title
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] title in
|
|
guard let self = self else { return }
|
|
self.title = title
|
|
}
|
|
.store(in: &disposeBag)
|
|
|
|
// bind publish bar button state
|
|
composeContentViewModel.$isPublishBarButtonItemEnabled
|
|
.receive(on: DispatchQueue.main)
|
|
.assign(to: \.isEnabled, on: publishButton)
|
|
.store(in: &disposeBag)
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
|
|
switch viewModel.composeContext {
|
|
case .composeStatus:
|
|
configurePublishButtonApperance(button: publishButton)
|
|
case .editStatus:
|
|
configurePublishButtonApperance(button: saveButton)
|
|
}
|
|
|
|
viewModel.traitCollectionDidChangePublisher.send()
|
|
}
|
|
|
|
}
|
|
|
|
extension ComposeViewController {
|
|
|
|
private func showDismissConfirmAlertController() {
|
|
let alertController = PortraitAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
|
|
let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in
|
|
guard let self = self else { return }
|
|
self.dismiss(animated: true, completion: nil)
|
|
}
|
|
alertController.addAction(discardAction)
|
|
let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel)
|
|
alertController.addAction(cancelAction)
|
|
alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem
|
|
present(alertController, animated: true, completion: nil)
|
|
}
|
|
|
|
private var rightBarButtonItemForCurrentContext: UIBarButtonItem {
|
|
switch viewModel.composeContext {
|
|
case .composeStatus:
|
|
return publishBarButtonItem
|
|
case .editStatus:
|
|
return saveBarButtonItem
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ComposeViewController {
|
|
|
|
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
|
guard composeContentViewModel.shouldDismiss else {
|
|
showDismissConfirmAlertController()
|
|
return
|
|
}
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
|
|
|
do {
|
|
try composeContentViewModel.checkAttachmentPrecondition()
|
|
} catch {
|
|
let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
|
|
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
|
alertController.addAction(okAction)
|
|
_ = coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
|
return
|
|
}
|
|
|
|
do {
|
|
let statusPublisher = try composeContentViewModel.statusPublisher()
|
|
// let result = try await statusPublisher.publish(api: context.apiService, authContext: viewModel.authContext)
|
|
// if let reactor = presentingViewController?.topMostNotModal as? StatusPublisherReactor {
|
|
// statusPublisher.reactor = reactor
|
|
// }
|
|
viewModel.context.publisherService.enqueue(
|
|
statusPublisher: statusPublisher,
|
|
authContext: viewModel.authContext
|
|
)
|
|
} catch {
|
|
let alertController = UIAlertController.standardAlert(of: error)
|
|
present(alertController, animated: true)
|
|
return
|
|
}
|
|
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
@objc
|
|
private func publishStatusEdit(_ sender: Any) {
|
|
do {
|
|
try composeContentViewModel.checkAttachmentPrecondition()
|
|
} catch {
|
|
let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
|
|
let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
|
|
alertController.addAction(okAction)
|
|
_ = coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
|
return
|
|
}
|
|
|
|
do {
|
|
guard let editStatusPublisher = try composeContentViewModel.statusEditPublisher() else { return }
|
|
viewModel.context.publisherService.enqueue(
|
|
statusPublisher: editStatusPublisher,
|
|
authContext: viewModel.authContext
|
|
)
|
|
} catch {
|
|
let alertController = UIAlertController.standardAlert(of: error)
|
|
present(alertController, animated: true)
|
|
return
|
|
}
|
|
|
|
dismiss(animated: true, completion: nil)
|
|
|
|
}
|
|
}
|
|
|
|
extension ComposeViewController {
|
|
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
|
|
|
// Enable pasting images
|
|
if (action == #selector(UIResponderStandardEditActions.paste(_:))) {
|
|
return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages;
|
|
}
|
|
|
|
return super.canPerformAction(action, withSender: sender);
|
|
}
|
|
|
|
override func paste(_ sender: Any?) {
|
|
|
|
// Look for images on the clipboard
|
|
if UIPasteboard.general.hasImages, let images = UIPasteboard.general.images {
|
|
let attachmentViewModels = images.map { image in
|
|
return AttachmentViewModel(
|
|
api: viewModel.context.apiService,
|
|
authContext: viewModel.authContext,
|
|
input: .image(image),
|
|
sizeLimit: composeContentViewModel.sizeLimit,
|
|
delegate: composeContentViewModel
|
|
)
|
|
}
|
|
composeContentViewModel.attachmentViewModels += attachmentViewModels
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UIAdaptivePresentationControllerDelegate
|
|
extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
|
|
|
|
func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
|
|
switch traitCollection.horizontalSizeClass {
|
|
case .compact:
|
|
return .overFullScreen
|
|
default:
|
|
return .pageSheet
|
|
}
|
|
}
|
|
|
|
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
|
|
return composeContentViewModel.shouldDismiss
|
|
}
|
|
|
|
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
|
|
showDismissConfirmAlertController()
|
|
}
|
|
}
|
|
|
|
extension ComposeViewController {
|
|
override var keyCommands: [UIKeyCommand]? {
|
|
composeKeyCommands
|
|
}
|
|
}
|
|
|
|
extension ComposeViewController {
|
|
|
|
enum ComposeKeyCommand: String, CaseIterable {
|
|
case discardPost
|
|
case publishPost
|
|
case mediaBrowse
|
|
case mediaPhotoLibrary
|
|
case mediaCamera
|
|
case togglePoll
|
|
case toggleContentWarning
|
|
case selectVisibilityPublic
|
|
// TODO: remove selectVisibilityUnlisted from codebase
|
|
// case selectVisibilityUnlisted
|
|
case selectVisibilityPrivate
|
|
case selectVisibilityDirect
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .discardPost: return L10n.Scene.Compose.Keyboard.discardPost
|
|
case .publishPost: return L10n.Scene.Compose.Keyboard.publishPost
|
|
case .mediaBrowse: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.browse)
|
|
case .mediaPhotoLibrary: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.photoLibrary)
|
|
case .mediaCamera: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.camera)
|
|
case .togglePoll: return L10n.Scene.Compose.Keyboard.togglePoll
|
|
case .toggleContentWarning: return L10n.Scene.Compose.Keyboard.toggleContentWarning
|
|
case .selectVisibilityPublic: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.public)
|
|
// case .selectVisibilityUnlisted: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.unlisted)
|
|
case .selectVisibilityPrivate: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.private)
|
|
case .selectVisibilityDirect: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.direct)
|
|
}
|
|
}
|
|
|
|
// UIKeyCommand input
|
|
var input: String {
|
|
switch self {
|
|
case .discardPost: return "w" // + command
|
|
case .publishPost: return "\r" // (enter) + command
|
|
case .mediaBrowse: return "b" // + option + command
|
|
case .mediaPhotoLibrary: return "p" // + option + command
|
|
case .mediaCamera: return "c" // + option + command
|
|
case .togglePoll: return "p" // + shift + command
|
|
case .toggleContentWarning: return "c" // + shift + command
|
|
case .selectVisibilityPublic: return "1" // + command
|
|
// case .selectVisibilityUnlisted: return "2" // + command
|
|
case .selectVisibilityPrivate: return "2" // + command
|
|
case .selectVisibilityDirect: return "3" // + command
|
|
}
|
|
}
|
|
|
|
var modifierFlags: UIKeyModifierFlags {
|
|
switch self {
|
|
case .discardPost: return [.command]
|
|
case .publishPost: return [.command]
|
|
case .mediaBrowse: return [.alternate, .command]
|
|
case .mediaPhotoLibrary: return [.alternate, .command]
|
|
case .mediaCamera: return [.alternate, .command]
|
|
case .togglePoll: return [.shift, .command]
|
|
case .toggleContentWarning: return [.shift, .command]
|
|
case .selectVisibilityPublic: return [.command]
|
|
// case .selectVisibilityUnlisted: return [.command]
|
|
case .selectVisibilityPrivate: return [.command]
|
|
case .selectVisibilityDirect: return [.command]
|
|
}
|
|
}
|
|
|
|
var propertyList: Any {
|
|
return rawValue
|
|
}
|
|
}
|
|
|
|
var composeKeyCommands: [UIKeyCommand]? {
|
|
ComposeKeyCommand.allCases.map { command in
|
|
UIKeyCommand(
|
|
title: command.title,
|
|
image: nil,
|
|
action: #selector(Self.composeKeyCommandHandler(_:)),
|
|
input: command.input,
|
|
modifierFlags: command.modifierFlags,
|
|
propertyList: command.propertyList,
|
|
alternates: [],
|
|
discoverabilityTitle: nil,
|
|
attributes: [],
|
|
state: .off
|
|
)
|
|
}
|
|
}
|
|
|
|
@objc private func composeKeyCommandHandler(_ sender: UIKeyCommand) {
|
|
guard let rawValue = sender.propertyList as? String,
|
|
let command = ComposeKeyCommand(rawValue: rawValue) else { return }
|
|
|
|
switch command {
|
|
case .discardPost:
|
|
cancelBarButtonItemPressed(cancelBarButtonItem)
|
|
case .publishPost:
|
|
publishBarButtonItemPressed(publishBarButtonItem)
|
|
case .mediaBrowse:
|
|
guard !isViewControllerIsAlreadyModal(composeContentViewController.documentPickerController) else { return }
|
|
present(composeContentViewController.documentPickerController, animated: true, completion: nil)
|
|
case .mediaPhotoLibrary:
|
|
guard !isViewControllerIsAlreadyModal(composeContentViewController.photoLibraryPicker) else { return }
|
|
present(composeContentViewController.photoLibraryPicker, animated: true, completion: nil)
|
|
case .mediaCamera:
|
|
guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
|
|
return
|
|
}
|
|
guard !isViewControllerIsAlreadyModal(composeContentViewController.imagePickerController) else { return }
|
|
present(composeContentViewController.imagePickerController, animated: true, completion: nil)
|
|
case .togglePoll:
|
|
composeContentViewModel.isPollActive.toggle()
|
|
case .toggleContentWarning:
|
|
composeContentViewModel.isContentWarningActive.toggle()
|
|
case .selectVisibilityPublic:
|
|
composeContentViewModel.visibility = .public
|
|
// case .selectVisibilityUnlisted:
|
|
// viewModel.selectedStatusVisibility.value = .unlisted
|
|
case .selectVisibilityPrivate:
|
|
composeContentViewModel.visibility = .private
|
|
case .selectVisibilityDirect:
|
|
composeContentViewModel.visibility = .direct
|
|
}
|
|
}
|
|
|
|
private func isViewControllerIsAlreadyModal(_ viewController: UIViewController) -> Bool {
|
|
return viewController.presentingViewController != nil
|
|
}
|
|
|
|
}
|