ComposeViewController.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. //
  2. // ComposeViewController.swift
  3. // Mastodon
  4. //
  5. // Created by MainasuK Cirno on 2021-3-11.
  6. //
  7. import os.log
  8. import UIKit
  9. import Combine
  10. import PhotosUI
  11. import Meta
  12. import MetaTextKit
  13. import MastodonMeta
  14. import MastodonAsset
  15. import MastodonCore
  16. import MastodonUI
  17. import MastodonLocalization
  18. import MastodonSDK
  19. final class ComposeViewController: UIViewController, NeedsDependency {
  20. static let minAutoCompleteVisibleHeight: CGFloat = 100
  21. weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
  22. weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
  23. var disposeBag = Set<AnyCancellable>()
  24. var viewModel: ComposeViewModel!
  25. let logger = Logger(subsystem: "ComposeViewController", category: "logic")
  26. lazy var composeContentViewModel: ComposeContentViewModel = {
  27. return ComposeContentViewModel(
  28. context: context,
  29. authContext: viewModel.authContext,
  30. kind: viewModel.kind
  31. )
  32. }()
  33. private(set) lazy var composeContentViewController: ComposeContentViewController = {
  34. let composeContentViewController = ComposeContentViewController()
  35. composeContentViewController.viewModel = composeContentViewModel
  36. return composeContentViewController
  37. }()
  38. private(set) lazy var cancelBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:)))
  39. let publishButton: UIButton = {
  40. let button = RoundedEdgesButton(type: .custom)
  41. button.cornerRadius = 10
  42. button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
  43. button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
  44. button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
  45. return button
  46. }()
  47. private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
  48. configurePublishButtonApperance()
  49. let shadowBackgroundContainer = ShadowBackgroundContainer()
  50. publishButton.translatesAutoresizingMaskIntoConstraints = false
  51. shadowBackgroundContainer.addSubview(publishButton)
  52. NSLayoutConstraint.activate([
  53. publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor),
  54. publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor),
  55. publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor),
  56. publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor),
  57. ])
  58. let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
  59. return barButtonItem
  60. }()
  61. private func configurePublishButtonApperance() {
  62. publishButton.adjustsImageWhenHighlighted = false
  63. publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal)
  64. publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted)
  65. publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
  66. publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
  67. }
  68. deinit {
  69. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  70. }
  71. }
  72. extension ComposeViewController {
  73. override func viewDidLoad() {
  74. super.viewDidLoad()
  75. navigationItem.leftBarButtonItem = cancelBarButtonItem
  76. navigationItem.rightBarButtonItem = publishBarButtonItem
  77. viewModel.traitCollectionDidChangePublisher
  78. .receive(on: DispatchQueue.main)
  79. .sink { [weak self] _ in
  80. guard let self = self else { return }
  81. guard self.traitCollection.userInterfaceIdiom == .pad else { return }
  82. var items = [self.publishBarButtonItem]
  83. // if self.traitCollection.horizontalSizeClass == .regular {
  84. // items.append(self.characterCountBarButtonItem)
  85. // }
  86. self.navigationItem.rightBarButtonItems = items
  87. }
  88. .store(in: &disposeBag)
  89. publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
  90. addChild(composeContentViewController)
  91. composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false
  92. view.addSubview(composeContentViewController.view)
  93. NSLayoutConstraint.activate([
  94. composeContentViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
  95. composeContentViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  96. composeContentViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  97. composeContentViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
  98. ])
  99. composeContentViewController.didMove(toParent: self)
  100. // bind title
  101. viewModel.$title
  102. .receive(on: DispatchQueue.main)
  103. .sink { [weak self] title in
  104. guard let self = self else { return }
  105. self.title = title
  106. }
  107. .store(in: &disposeBag)
  108. // bind publish bar button state
  109. composeContentViewModel.$isPublishBarButtonItemEnabled
  110. .receive(on: DispatchQueue.main)
  111. .assign(to: \.isEnabled, on: publishButton)
  112. .store(in: &disposeBag)
  113. }
  114. override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  115. super.traitCollectionDidChange(previousTraitCollection)
  116. configurePublishButtonApperance()
  117. viewModel.traitCollectionDidChangePublisher.send()
  118. }
  119. }
  120. extension ComposeViewController {
  121. private func showDismissConfirmAlertController() {
  122. let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
  123. let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in
  124. guard let self = self else { return }
  125. self.dismiss(animated: true, completion: nil)
  126. }
  127. alertController.addAction(discardAction)
  128. let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel)
  129. alertController.addAction(cancelAction)
  130. alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem
  131. present(alertController, animated: true, completion: nil)
  132. }
  133. }
  134. extension ComposeViewController {
  135. @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
  136. logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
  137. guard composeContentViewModel.shouldDismiss else {
  138. showDismissConfirmAlertController()
  139. return
  140. }
  141. dismiss(animated: true, completion: nil)
  142. }
  143. @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
  144. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  145. do {
  146. try composeContentViewModel.checkAttachmentPrecondition()
  147. } catch {
  148. let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert)
  149. let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil)
  150. alertController.addAction(okAction)
  151. coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
  152. return
  153. }
  154. do {
  155. let statusPublisher = try composeContentViewModel.statusPublisher()
  156. // let result = try await statusPublisher.publish(api: context.apiService, authContext: viewModel.authContext)
  157. // if let reactor = presentingViewController?.topMostNotModal as? StatusPublisherReactor {
  158. // statusPublisher.reactor = reactor
  159. // }
  160. viewModel.context.publisherService.enqueue(
  161. statusPublisher: statusPublisher,
  162. authContext: viewModel.authContext
  163. )
  164. } catch {
  165. let alertController = UIAlertController.standardAlert(of: error)
  166. present(alertController, animated: true)
  167. return
  168. }
  169. dismiss(animated: true, completion: nil)
  170. }
  171. }
  172. extension ComposeViewController {
  173. public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
  174. // Enable pasting images
  175. if (action == #selector(UIResponderStandardEditActions.paste(_:))) {
  176. return UIPasteboard.general.hasStrings || UIPasteboard.general.hasImages;
  177. }
  178. return super.canPerformAction(action, withSender: sender);
  179. }
  180. override func paste(_ sender: Any?) {
  181. logger.debug("Paste event received")
  182. // Look for images on the clipboard
  183. if UIPasteboard.general.hasImages, let images = UIPasteboard.general.images {
  184. logger.warning("Got image paste event, however attachments are not yet re-implemented.");
  185. let attachmentViewModels = images.map { image in
  186. return AttachmentViewModel(
  187. api: viewModel.context.apiService,
  188. authContext: viewModel.authContext,
  189. input: .image(image),
  190. delegate: composeContentViewModel
  191. )
  192. }
  193. composeContentViewModel.attachmentViewModels += attachmentViewModels
  194. }
  195. }
  196. }
  197. // MARK: - UIAdaptivePresentationControllerDelegate
  198. extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
  199. func adaptivePresentationStyle(for controller: UIPresentationController, traitCollection: UITraitCollection) -> UIModalPresentationStyle {
  200. switch traitCollection.horizontalSizeClass {
  201. case .compact:
  202. return .overFullScreen
  203. default:
  204. return .pageSheet
  205. }
  206. }
  207. func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
  208. return composeContentViewModel.shouldDismiss
  209. }
  210. func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
  211. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  212. showDismissConfirmAlertController()
  213. }
  214. func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
  215. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  216. }
  217. }
  218. extension ComposeViewController {
  219. override var keyCommands: [UIKeyCommand]? {
  220. composeKeyCommands
  221. }
  222. }
  223. extension ComposeViewController {
  224. enum ComposeKeyCommand: String, CaseIterable {
  225. case discardPost
  226. case publishPost
  227. case mediaBrowse
  228. case mediaPhotoLibrary
  229. case mediaCamera
  230. case togglePoll
  231. case toggleContentWarning
  232. case selectVisibilityPublic
  233. // TODO: remove selectVisibilityUnlisted from codebase
  234. // case selectVisibilityUnlisted
  235. case selectVisibilityPrivate
  236. case selectVisibilityDirect
  237. var title: String {
  238. switch self {
  239. case .discardPost: return L10n.Scene.Compose.Keyboard.discardPost
  240. case .publishPost: return L10n.Scene.Compose.Keyboard.publishPost
  241. case .mediaBrowse: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.browse)
  242. case .mediaPhotoLibrary: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.photoLibrary)
  243. case .mediaCamera: return L10n.Scene.Compose.Keyboard.appendAttachmentEntry(L10n.Scene.Compose.MediaSelection.camera)
  244. case .togglePoll: return L10n.Scene.Compose.Keyboard.togglePoll
  245. case .toggleContentWarning: return L10n.Scene.Compose.Keyboard.toggleContentWarning
  246. case .selectVisibilityPublic: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.public)
  247. // case .selectVisibilityUnlisted: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.unlisted)
  248. case .selectVisibilityPrivate: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.private)
  249. case .selectVisibilityDirect: return L10n.Scene.Compose.Keyboard.selectVisibilityEntry(L10n.Scene.Compose.Visibility.direct)
  250. }
  251. }
  252. // UIKeyCommand input
  253. var input: String {
  254. switch self {
  255. case .discardPost: return "w" // + command
  256. case .publishPost: return "\r" // (enter) + command
  257. case .mediaBrowse: return "b" // + option + command
  258. case .mediaPhotoLibrary: return "p" // + option + command
  259. case .mediaCamera: return "c" // + option + command
  260. case .togglePoll: return "p" // + shift + command
  261. case .toggleContentWarning: return "c" // + shift + command
  262. case .selectVisibilityPublic: return "1" // + command
  263. // case .selectVisibilityUnlisted: return "2" // + command
  264. case .selectVisibilityPrivate: return "2" // + command
  265. case .selectVisibilityDirect: return "3" // + command
  266. }
  267. }
  268. var modifierFlags: UIKeyModifierFlags {
  269. switch self {
  270. case .discardPost: return [.command]
  271. case .publishPost: return [.command]
  272. case .mediaBrowse: return [.alternate, .command]
  273. case .mediaPhotoLibrary: return [.alternate, .command]
  274. case .mediaCamera: return [.alternate, .command]
  275. case .togglePoll: return [.shift, .command]
  276. case .toggleContentWarning: return [.shift, .command]
  277. case .selectVisibilityPublic: return [.command]
  278. // case .selectVisibilityUnlisted: return [.command]
  279. case .selectVisibilityPrivate: return [.command]
  280. case .selectVisibilityDirect: return [.command]
  281. }
  282. }
  283. var propertyList: Any {
  284. return rawValue
  285. }
  286. }
  287. var composeKeyCommands: [UIKeyCommand]? {
  288. ComposeKeyCommand.allCases.map { command in
  289. UIKeyCommand(
  290. title: command.title,
  291. image: nil,
  292. action: #selector(Self.composeKeyCommandHandler(_:)),
  293. input: command.input,
  294. modifierFlags: command.modifierFlags,
  295. propertyList: command.propertyList,
  296. alternates: [],
  297. discoverabilityTitle: nil,
  298. attributes: [],
  299. state: .off
  300. )
  301. }
  302. }
  303. @objc private func composeKeyCommandHandler(_ sender: UIKeyCommand) {
  304. guard let rawValue = sender.propertyList as? String,
  305. let command = ComposeKeyCommand(rawValue: rawValue) else { return }
  306. switch command {
  307. case .discardPost:
  308. cancelBarButtonItemPressed(cancelBarButtonItem)
  309. case .publishPost:
  310. publishBarButtonItemPressed(publishBarButtonItem)
  311. case .mediaBrowse:
  312. guard !isViewControllerIsAlreadyModal(composeContentViewController.documentPickerController) else { return }
  313. present(composeContentViewController.documentPickerController, animated: true, completion: nil)
  314. case .mediaPhotoLibrary:
  315. guard !isViewControllerIsAlreadyModal(composeContentViewController.photoLibraryPicker) else { return }
  316. present(composeContentViewController.photoLibraryPicker, animated: true, completion: nil)
  317. case .mediaCamera:
  318. guard UIImagePickerController.isSourceTypeAvailable(.camera) else {
  319. return
  320. }
  321. guard !isViewControllerIsAlreadyModal(composeContentViewController.imagePickerController) else { return }
  322. present(composeContentViewController.imagePickerController, animated: true, completion: nil)
  323. case .togglePoll:
  324. composeContentViewModel.isPollActive.toggle()
  325. case .toggleContentWarning:
  326. composeContentViewModel.isContentWarningActive.toggle()
  327. case .selectVisibilityPublic:
  328. composeContentViewModel.visibility = .public
  329. // case .selectVisibilityUnlisted:
  330. // viewModel.selectedStatusVisibility.value = .unlisted
  331. case .selectVisibilityPrivate:
  332. composeContentViewModel.visibility = .private
  333. case .selectVisibilityDirect:
  334. composeContentViewModel.visibility = .direct
  335. }
  336. }
  337. private func isViewControllerIsAlreadyModal(_ viewController: UIViewController) -> Bool {
  338. return viewController.presentingViewController != nil
  339. }
  340. }