// // 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 { let router: AppRouter 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 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, router: AppRouter) { self.router = router 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" navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelButtonPressed)) 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(barButtonSystemItem: .action, target: self, action: #selector(visbilityButtonPressed)) 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 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.translatesAutoresizingMaskIntoConstraints = false let replyLabel = UILabel() replyLabel.translatesAutoresizingMaskIntoConstraints = false replyLabel.text = "In reply to \(inReplyTo.account.realDisplayName)" replyLabel.textColor = .darkGray 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) } updateCharactersRemaining() updatePlaceholder() contentWarningEnabled = false NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField) if inReplyToID == nil { visibility = Preferences.shared.defaultPostVisibility } } 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) // } } func createFormattingButtons() -> [UIBarButtonItem] { guard Preferences.shared.statusContentType != .plain else { return [] } return StatusFormat.allCases.map { (format) in let (str, attributes) = format.title let item = UIBarButtonItem(title: str, style: .plain, target: self, action: #selector(formatButtonPressed(_:))) item.setTitleTextAttributes(attributes, for: .normal) item.setTitleTextAttributes(attributes, for: .highlighted) 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 updatePlaceholder() { placeholderLabel.isHidden = !statusTextView.text.isEmpty } func updateAddAttachmentButton() { addAttachmentButton.isEnabled = selectedAssets.count < 5 // 4 attachments + 1 button } 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 contentWarningBarButtonItem.style = contentWarningEnabled ? .done : .plain if contentWarningEnabled { contentWarningTextField.becomeFirstResponder() } else { statusTextView.becomeFirstResponder() } } func visibilityChanged() { // TODO: update visiblity button image } func saveDraft() { // TODO: save attachmenst to drafts // TODO: save CW to draft if let currentDraft = currentDraft { currentDraft.update(text: statusTextView.text) } else { DraftsManager.shared.create(text: statusTextView.text) } } @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 cancelButtonPressed() { 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)) router.present(alert, animated: true) } @objc func contentWarningButtonPressed() { contentWarningEnabled = !contentWarningEnabled } @objc func contentWarningTextFieldDidChange() { updateCharactersRemaining() } @objc func visbilityButtonPressed() { 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() } } 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 { 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 updatePlaceholder() updateCharactersRemaining() } }