mastodon-ios/Mastodon/Scene/Compose/ComposeViewController.swift

470 lines
21 KiB
Swift

//
// ComposeViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
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!
let logger = Logger(subsystem: "ComposeViewController", category: "logic")
lazy var composeContentViewModel: ComposeContentViewModel = {
return ComposeContentViewModel(
context: context,
authContext: viewModel.authContext,
kind: viewModel.kind
)
}()
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(_:)))
let characterCountLabel: UILabel = {
let label = UILabel()
label.font = .systemFont(ofSize: 15, weight: .regular)
label.text = "500"
label.textColor = Asset.Colors.Label.secondary.color
label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500)
return label
}()
private(set) lazy var characterCountBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(customView: characterCountLabel)
return barButtonItem
}()
let 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)
return button
}()
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
configurePublishButtonApperance()
let shadowBackgroundContainer = ShadowBackgroundContainer()
publishButton.translatesAutoresizingMaskIntoConstraints = false
shadowBackgroundContainer.addSubview(publishButton)
NSLayoutConstraint.activate([
publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor),
publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor),
publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor),
publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor),
])
let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
return barButtonItem
}()
private func configurePublishButtonApperance() {
publishButton.adjustsImageWhenHighlighted = false
publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal)
publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted)
publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ComposeViewController {
private static func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item])
let section = NSCollectionLayoutSection(group: group)
section.contentInsetsReference = .readableContent
// section.interGroupSpacing = 10
// section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
return UICollectionViewCompositionalLayout(section: section)
}
}
extension ComposeViewController {
override func viewDidLoad() {
super.viewDidLoad()
navigationItem.leftBarButtonItem = cancelBarButtonItem
navigationItem.rightBarButtonItem = publishBarButtonItem
viewModel.traitCollectionDidChangePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
guard self.traitCollection.userInterfaceIdiom == .pad else { return }
var items = [self.publishBarButtonItem]
// if self.traitCollection.horizontalSizeClass == .regular {
// items.append(self.characterCountBarButtonItem)
// }
self.navigationItem.rightBarButtonItems = items
}
.store(in: &disposeBag)
publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
addChild(composeContentViewController)
composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(composeContentViewController.view)
NSLayoutConstraint.activate([
composeContentViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
composeContentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
composeContentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
composeContentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
composeContentViewController.didMove(toParent: self)
// bind navigation bar style
// configureNavigationBarTitleStyle()
viewModel.traitCollectionDidChangePublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.configureNavigationBarTitleStyle()
}
.store(in: &disposeBag)
// 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)
configurePublishButtonApperance()
viewModel.traitCollectionDidChangePublisher.send()
}
}
extension ComposeViewController {
private func showDismissConfirmAlertController() {
let alertController = UIAlertController(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 func setupBackgroundColor(theme: Theme) {
// let backgroundColor = UIColor(dynamicProvider: { traitCollection in
// switch traitCollection.userInterfaceStyle {
// case .light:
// return .systemBackground
// default:
// return theme.systemElevatedBackgroundColor
// }
// })
// view.backgroundColor = backgroundColor
//// tableView.backgroundColor = backgroundColor
//// composeToolbarBackgroundView.backgroundColor = theme.composeToolbarBackgroundColor
// }
//
// // keyboard shortcutBar
// private func setupInputAssistantItem(item: UITextInputAssistantItem) {
// let barButtonItems = [
// composeToolbarView.mediaBarButtonItem,
// composeToolbarView.pollBarButtonItem,
// composeToolbarView.contentWarningBarButtonItem,
// composeToolbarView.visibilityBarButtonItem,
// ]
// let group = UIBarButtonItemGroup(barButtonItems: barButtonItems, representativeItem: nil)
//
// item.trailingBarButtonGroups = [group]
// }
//
// private func configureToolbarDisplay(keyboardHasShortcutBar: Bool) {
// switch self.traitCollection.userInterfaceIdiom {
// case .pad:
// let shouldHideToolbar = keyboardHasShortcutBar && self.traitCollection.horizontalSizeClass == .regular
// self.composeToolbarView.alpha = shouldHideToolbar ? 0 : 1
// self.composeToolbarBackgroundView.alpha = shouldHideToolbar ? 0 : 1
// default:
// break
// }
// }
//
private func configureNavigationBarTitleStyle() {
switch traitCollection.userInterfaceIdiom {
case .pad:
navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular
default:
break
}
}
}
extension ComposeViewController {
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
guard composeContentViewModel.shouldDismiss else {
showDismissConfirmAlertController()
return
}
dismiss(animated: true, completion: nil)
}
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
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)
}
}
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?) {
logger.debug("Paste event received")
// Look for images on the clipboard
if UIPasteboard.general.hasImages, let images = UIPasteboard.general.images {
logger.warning("Got image paste event, however attachments are not yet re-implemented.");
let attachmentViewModels = images.map { image in
return AttachmentViewModel(
api: viewModel.context.apiService,
authContext: viewModel.authContext,
input: .image(image),
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) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
showDismissConfirmAlertController()
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
//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:
// present(documentPickerController, animated: true, completion: nil)
// case .mediaPhotoLibrary:
// present(photoLibraryPicker, animated: true, completion: nil)
// case .mediaCamera:
// guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
// return
// }
// present(imagePickerController, animated: true, completion: nil)
// case .togglePoll:
// composeToolbarView.pollButton.sendActions(for: .touchUpInside)
// case .toggleContentWarning:
// composeToolbarView.contentWarningButton.sendActions(for: .touchUpInside)
// case .selectVisibilityPublic:
// viewModel.selectedStatusVisibility = .public
// // case .selectVisibilityUnlisted:
// // viewModel.selectedStatusVisibility.value = .unlisted
// case .selectVisibilityPrivate:
// viewModel.selectedStatusVisibility = .private
// case .selectVisibilityDirect:
// viewModel.selectedStatusVisibility = .direct
// }
// }
//
//}