mastodon-ios/Mastodon/Scene/Compose/ComposeViewController.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
}
}