// // ComposeViewController.swift // Tusker // // Created by Shadowfacts on 8/28/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Intents import Photos import GMImagePicker import MobileCoreServices class ComposeViewController: UIViewController { var inReplyToID: String? var accountsToMention: [String] var initialText: String? var contentWarningEnabled = false { didSet { contentWarningStateChanged() } } var visibility: Status.Visibility! { didSet { visibilityChanged() } } var selectedAssets: [PHAsset] = [] { didSet { updateAttachmentViews() } } var hasChanges = false var currentDraft: DraftsManager.Draft? // Weak so that if a new session is initiated (i.e. XCBManager.currentSession is changed) while the current one is in progress, this one will be released weak var xcbSession: XCBSession? var postedStatus: Status? weak var postBarButtonItem: UIBarButtonItem! var visibilityBarButtonItem: UIBarButtonItem! var contentWarningBarButtonItem: UIBarButtonItem! @IBOutlet weak var scrollView: UIScrollView! @IBOutlet weak var contentView: UIView! @IBOutlet weak var stackView: UIStackView! var replyView: ComposeStatusReplyView? var replyAvatarImageViewTopConstraint: NSLayoutConstraint? @IBOutlet weak var selfDetailView: LargeAccountDetailView! @IBOutlet weak var charactersRemainingLabel: UILabel! @IBOutlet weak var statusTextView: UITextView! @IBOutlet weak var placeholderLabel: UILabel! @IBOutlet weak var contentWarningContainerView: UIView! @IBOutlet weak var contentWarningTextField: UITextField! @IBOutlet weak var attachmentsStackView: UIStackView! @IBOutlet weak var addAttachmentButton: UIButton! @IBOutlet weak var postProgressView: SteppedProgressView! init(inReplyTo inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil) { self.inReplyToID = inReplyToID if let inReplyToID = inReplyToID, let inReplyTo = MastodonCache.status(for: inReplyToID) { accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct } } else if let mentioningAcct = mentioningAcct { accountsToMention = [mentioningAcct] } else { accountsToMention = [] } if let ownAccount = MastodonController.account { accountsToMention.removeAll(where: { acct in ownAccount.acct == acct }) } accountsToMention = accountsToMention.uniques() super.init(nibName: "ComposeViewController", bundle: nil) title = "Compose" tabBarItem.image = UIImage(systemName: "pencil") navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(showSaveAndClosePrompt)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Post", style: .done, target: self, action: #selector(postButtonPressed)) postBarButtonItem = navigationItem.rightBarButtonItem } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() scrollView.delegate = self statusTextView.delegate = self statusTextView.becomeFirstResponder() let toolbar = UIToolbar() contentWarningBarButtonItem = UIBarButtonItem(title: "CW", style: .plain, target: self, action: #selector(contentWarningButtonPressed)) visibilityBarButtonItem = UIBarButtonItem(image: UIImage(systemName: Preferences.shared.defaultPostVisibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed)) toolbar.items = [ contentWarningBarButtonItem, visibilityBarButtonItem, UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) ] + createFormattingButtons() + [ UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil), UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsButtonPressed)) ] toolbar.translatesAutoresizingMaskIntoConstraints = false statusTextView.inputAccessoryView = toolbar contentWarningTextField.inputAccessoryView = toolbar statusTextView.text = accountsToMention.map({ acct in "@\(acct) " }).joined() initialText = statusTextView.text MastodonController.getOwnAccount { (account) in DispatchQueue.main.async { self.selfDetailView.update(account: account) } } if let inReplyToID = inReplyToID, let inReplyTo = MastodonCache.status(for: inReplyToID) { visibility = inReplyTo.visibility if Preferences.shared.contentWarningCopyMode == .doNotCopy { contentWarningEnabled = false contentWarningContainerView.isHidden = true } else { contentWarningEnabled = !inReplyTo.spoilerText.isEmpty contentWarningContainerView.isHidden = !contentWarningEnabled if Preferences.shared.contentWarningCopyMode == .prependRe, !inReplyTo.spoilerText.lowercased().starts(with: "re:") { contentWarningTextField.text = "re: \(inReplyTo.spoilerText)" } else { contentWarningTextField.text = inReplyTo.spoilerText } } let replyView = ComposeStatusReplyView.create() replyView.updateUI(for: inReplyTo) stackView.insertArrangedSubview(replyView, at: 0) self.replyView = replyView replyAvatarImageViewTopConstraint = replyView.avatarImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8) replyAvatarImageViewTopConstraint!.isActive = true let replyLabelContainer = UIView() replyLabelContainer.backgroundColor = .clear replyLabelContainer.translatesAutoresizingMaskIntoConstraints = false let replyLabel = UILabel() replyLabel.translatesAutoresizingMaskIntoConstraints = false replyLabel.text = "In reply to \(inReplyTo.account.realDisplayName)" replyLabel.textColor = .secondaryLabel replyLabelContainer.addSubview(replyLabel) NSLayoutConstraint.activate([ replyLabel.leadingAnchor.constraint(equalTo: replyLabelContainer.leadingAnchor, constant: 8), replyLabel.trailingAnchor.constraint(equalTo: replyLabelContainer.trailingAnchor, constant: -8), replyLabel.topAnchor.constraint(equalTo: replyLabelContainer.topAnchor), replyLabel.bottomAnchor.constraint(equalTo: replyLabelContainer.bottomAnchor) ]) stackView.insertArrangedSubview(replyLabelContainer, at: 1) } // we have to set the font here, because the monospaced digit font is not available in IB charactersRemainingLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular) updateCharactersRemaining() updatePlaceholder() NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField) if inReplyToID == nil { visibility = Preferences.shared.defaultPostVisibility contentWarningEnabled = false } } override func viewWillAppear(_ animated: Bool) { NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(adjustForKeyboard), name: UIResponder.keyboardWillChangeFrameNotification, object: nil) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillChangeFrameNotification, object: nil) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // if inReplyToID != nil { // scrollView.contentOffset = CGPoint(x: 0, y: stackView.arrangedSubviews.first!.frame.height) // } } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) let imageName: String if traitCollection.userInterfaceStyle == .dark { imageName = "photo.fill" } else { imageName = "photo" } addAttachmentButton.setImage(UIImage(systemName: imageName), for: .normal) } func createFormattingButtons() -> [UIBarButtonItem] { guard Preferences.shared.statusContentType != .plain else { return [] } return StatusFormat.allCases.map { (format) in 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 an image or a title") } item.tag = StatusFormat.allCases.firstIndex(of: format)! return item } } @objc func adjustForKeyboard(notification: NSNotification) { let userInfo = notification.userInfo! let keyboardScreenEndFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as! NSValue).cgRectValue let keyboardViewEndFrame = view.convert(keyboardScreenEndFrame, from: view.window) if notification.name == UIResponder.keyboardWillHideNotification { scrollView.contentInset = .zero } else { // let accessoryFrame = view.convert(statusTextView.inputAccessoryView!.frame, from: view.window) let offset = keyboardViewEndFrame.height// + accessoryFrame.height // TODO: radar for incorrect keyboard end frame height (either converted or screen) // the value returned is somewhere between the height of the keyboard and the height of the keyboard + accessory // actually maybe not?? scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: offset, right: 0) } scrollView.scrollIndicatorInsets = scrollView.contentInset } func updateCharactersRemaining() { // TODO: include CW char count let count = CharacterCounter.count(text: statusTextView.text) let cwCount = contentWarningEnabled ? (contentWarningTextField.text?.count ?? 0) : 0 let remaining = (MastodonController.instance.maxStatusCharacters ?? 500) - count - cwCount if remaining < 0 { charactersRemainingLabel.textColor = .red postBarButtonItem.isEnabled = false } else { charactersRemainingLabel.textColor = .darkGray postBarButtonItem.isEnabled = true } charactersRemainingLabel.text = remaining.description } func updateHasChanges() { if let currentDraft = currentDraft { let cw = contentWarningEnabled ? contentWarningTextField.text : nil hasChanges = statusTextView.text != currentDraft.text || cw != currentDraft.contentWarning } else { hasChanges = !statusTextView.text.isEmpty || (contentWarningEnabled && !(contentWarningTextField.text?.isEmpty ?? true)) } } func updatePlaceholder() { placeholderLabel.isHidden = !statusTextView.text.isEmpty } func updateAddAttachmentButton() { switch MastodonController.instance.instanceType { case .pleroma: addAttachmentButton.isEnabled = true case .mastodon: addAttachmentButton.isEnabled = selectedAssets.count <= 4 && selectedAssets.first(where: { $0.mediaType == .video }) == nil } } func updateAttachmentViews() { for view in attachmentsStackView.arrangedSubviews { if view is ComposeMediaView { view.removeFromSuperview() } } for asset in selectedAssets { let mediaView = ComposeMediaView.create() mediaView.delegate = self mediaView.update(asset: asset) attachmentsStackView.insertArrangedSubview(mediaView, at: attachmentsStackView.arrangedSubviews.count - 1) updateAddAttachmentButton() } } func contentWarningStateChanged() { contentWarningContainerView.isHidden = !contentWarningEnabled } func visibilityChanged() { visibilityBarButtonItem.image = UIImage(systemName: visibility.imageName) } func saveDraft() { var attachments = [DraftsManager.DraftAttachment]() for asset in selectedAssets { let index = attachments.count let mediaView = attachmentsStackView.arrangedSubviews[index] as! ComposeMediaView let description = mediaView.descriptionTextView.text! attachments.append(DraftsManager.DraftAttachment(assetIdentifier: asset.localIdentifier, description: description)) } let cw = contentWarningEnabled ? contentWarningTextField.text : nil if let currentDraft = self.currentDraft { currentDraft.update(text: self.statusTextView.text, contentWarning: cw, attachments: attachments) } else { DraftsManager.shared.create(text: self.statusTextView.text, contentWarning: cw, attachments: attachments) } } @objc func close() { dismiss(animated: true) xcbSession?.complete(with: .cancel) } // MARK: - Navigation override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { statusTextView.resignFirstResponder() super.dismiss(animated: flag, completion: completion) } // MARK: - Interaction @objc func showSaveAndClosePrompt() { guard statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) != initialText else { close() return } if Preferences.shared.automaticallySaveDrafts { saveDraft() close() return } let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alert.addAction(UIAlertAction(title: "Save draft", style: .default, handler: { (_) in self.saveDraft() self.close() })) alert.addAction(UIAlertAction(title: "Delete draft", style: .destructive, handler: { (_) in if let currentDraft = self.currentDraft { DraftsManager.shared.remove(currentDraft) } self.close() })) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) present(alert, animated: true) } @objc func contentWarningButtonPressed() { contentWarningEnabled = !contentWarningEnabled if contentWarningEnabled { contentWarningTextField.becomeFirstResponder() } else { statusTextView.becomeFirstResponder() } } @objc func contentWarningTextFieldDidChange() { updateCharactersRemaining() updateHasChanges() } @objc func visibilityButtonPressed() { let alertController = UIAlertController(currentVisibility: self.visibility) { (visibility) in guard let visibility = visibility else { return } self.visibility = visibility } present(alertController, animated: true) } @objc func formatButtonPressed(_ button: UIBarButtonItem) { guard statusTextView.isFirstResponder else { return } let format = StatusFormat.allCases[button.tag] guard let insertionResult = format.insertionResult else { return } let currentSelectedRange = statusTextView.selectedRange if currentSelectedRange.length == 0 { statusTextView.insertText(insertionResult.prefix + insertionResult.suffix) statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.insertionPoint, length: 0) } else { let start = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.lowerBound) let end = statusTextView.text.index(statusTextView.text.startIndex, offsetBy: currentSelectedRange.upperBound) let selectedText = statusTextView.text[start.. replyView.frame.height - replyView.avatarImageView.frame.height - 16 { constant += replyView.frame.height - replyView.avatarImageView.frame.height - 16 - scrollView.contentOffset.y } replyAvatarImageViewTopConstraint?.constant = constant } } extension ComposeViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { updateCharactersRemaining() updatePlaceholder() updateHasChanges() } } extension ComposeViewController: GMImagePickerControllerDelegate { func assetsPickerController(_ picker: GMImagePickerController!, didFinishPickingAssets assets: [Any]!) { let assets = assets as! [PHAsset] selectedAssets.append(contentsOf: assets) picker.dismiss(animated: true) } func assetsPickerController(_ picker: GMImagePickerController!, shouldSelect asset: PHAsset!) -> Bool { switch MastodonController.instance.instanceType { case .pleroma: return true case .mastodon: if (asset.mediaType == .video && selectedAssets.count > 0) || selectedAssets.first(where: { $0.mediaType == .video }) != nil { return false } return selectedAssets.count + picker.selectedAssets.count < 4 } } } extension ComposeViewController: ComposeMediaViewDelegate { func didRemoveMedia(_ mediaView: ComposeMediaView) { let index = attachmentsStackView.arrangedSubviews.firstIndex(of: mediaView)! selectedAssets.remove(at: index) updateAddAttachmentButton() } } extension ComposeViewController: DraftsTableViewControllerDelegate { func draftSelectionCanceled() { } func draftSelected(_ draft: DraftsManager.Draft) { self.currentDraft = draft statusTextView.text = draft.text contentWarningEnabled = draft.contentWarning != nil contentWarningTextField.text = draft.contentWarning updatePlaceholder() updateCharactersRemaining() let result = PHAsset.fetchAssets(withLocalIdentifiers: draft.attachments.map { $0.assetIdentifier }, options: nil) var assets = [String: (asset: PHAsset, description: String)]() var addedAssets = 0 while addedAssets < result.count { let asset = result[addedAssets] let attachment = draft.attachments.first(where: { $0.assetIdentifier == asset.localIdentifier })! assets[asset.localIdentifier] = (asset, attachment.description) addedAssets += 1 } self.selectedAssets = assets.values.map { $0.asset } updateAttachmentViews() for case let mediaView as ComposeMediaView in attachmentsStackView.arrangedSubviews { let attachment = draft.attachments.first(where: { $0.assetIdentifier == mediaView.assetIdentifier })! mediaView.descriptionTextView.text = attachment.description // call the delegate method manually, since setting the text property doesn't call it mediaView.textViewDidChange(mediaView.descriptionTextView) } } func draftSelectionCompleted() { // check that all the assets from the draft have been added if let currentDraft = currentDraft, selectedAssets.count < currentDraft.attachments.count { // some of the assets in the draft weren't loaded, so notify the user let difference = currentDraft.attachments.count - selectedAssets.count // todo: localize me let suffix = difference == 1 ? "" : "s" let verb = difference == 1 ? "was" : "were" let alertController = UIAlertController(title: "Missing Attachments", message: "\(difference) attachment\(suffix) \(verb) removed from the Photos Library and could not be loaded.", preferredStyle: .alert) alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) present(alertController, animated: true) } } } extension ComposeViewController: UIAdaptivePresentationControllerDelegate { func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { return Preferences.shared.automaticallySaveDrafts || !hasChanges } func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { showSaveAndClosePrompt() } // when the compose screen is dismissed interactively, close() isn't called, so we make sure to // complete the X-Callback-URL session and save the draft is automatic saving is enabled // (if automatic saving is off, the draft will get saved/discarded by the user when didAttemptToDismiss is called func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { if Preferences.shared.automaticallySaveDrafts { saveDraft() } xcbSession?.complete(with: .cancel) } }