// // ComposeViewController.swift // Tusker // // Created by Shadowfacts on 8/28/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Intents class ComposeViewController: UIViewController { let router: AppRouter @IBOutlet weak var scrollView: UIScrollView! @IBOutlet weak var inReplyToContainerView: UIView! @IBOutlet weak var inReplyToAvatarImageView: UIImageView! @IBOutlet weak var inReplyToDisplayNameLabel: UILabel! @IBOutlet weak var inReplyToUsernameLabel: UILabel! @IBOutlet weak var inReplyToContentLabel: StatusContentLabel! @IBOutlet weak var inReplyToLabel: UILabel! @IBOutlet weak var statusTextView: UITextView! @IBOutlet weak var placeholderLabel: UILabel! @IBOutlet weak var charactersRemainingLabel: UILabel! @IBOutlet weak var visibilityButton: UIButton! @IBOutlet weak var postButton: UIButton! @IBOutlet weak var contentWarningTextField: UITextField! @IBOutlet weak var mediaStackView: UIStackView! @IBOutlet weak var paddingView: UIView! @IBOutlet weak var progressView: SteppedProgressView! var scrolled = false var inReplyToID: String? // TODO: cleanup this var mentioningAcct: String? var text: String? var initialText: String? var draft: 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 contentWarning = false { didSet { contentWarningTextField.isHidden = !contentWarning } } var visibility = Preferences.shared.defaultPostVisibility { didSet { visibilityButton.setTitle(visibility.displayName, for: .normal) } } var status: Status? init(inReplyTo inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil, router: AppRouter) { self.inReplyToID = inReplyToID self.mentioningAcct = mentioningAcct self.text = text self.router = router super.init(nibName: "ComposeViewController", bundle: nil) navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(cancelPressed)) navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Drafts", style: .plain, target: self, action: #selector(draftsPressed)) } required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() statusTextView.layer.cornerRadius = 5 statusTextView.layer.masksToBounds = true statusTextView.delegate = self visibilityButton.setTitle(visibility.displayName, for: .normal) contentWarningTextField.delegate = self let toolbar = UIToolbar(frame: CGRect(x: 0, y: 0, width: view.frame.width, height: 30)) let flexSpace = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) let done = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(keyboardDoneButtonPressed)) toolbar.setItems([flexSpace, done], animated: false) toolbar.sizeToFit() statusTextView.inputAccessoryView = toolbar if let inReplyToID = inReplyToID, let inReplyTo = MastodonCache.status(for: inReplyToID) { inReplyToDisplayNameLabel.text = inReplyTo.account.realDisplayName inReplyToUsernameLabel.text = "@\(inReplyTo.account.username)" inReplyToContentLabel.statusID = inReplyToID inReplyToAvatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: inReplyToAvatarImageView) inReplyToAvatarImageView.layer.masksToBounds = true inReplyToAvatarImageView.image = nil ImageCache.avatars.get(inReplyTo.account.avatar) { (data) in guard let data = data else { return } DispatchQueue.main.async { self.inReplyToAvatarImageView.image = UIImage(data: data) } } inReplyToLabel.text = "In reply to \(inReplyTo.account.realDisplayName)" if inReplyTo.account != MastodonController.account { statusTextView.text = "@\(inReplyTo.account.acct) " } statusTextView.text += inReplyTo.mentions.filter({ $0.id != MastodonController.account.id }).map({ "@\($0.acct) " }).joined() contentWarning = inReplyTo.sensitive contentWarningTextField.text = inReplyTo.spoilerText visibility = inReplyTo.visibility } else { inReplyToLabel.isHidden = true inReplyToContainerView.isHidden = true } if let mentioningAcct = mentioningAcct { statusTextView.text += "@\(mentioningAcct) " } if let text = text { statusTextView.text += text } initialText = statusTextView.text.trimmingCharacters(in: .whitespacesAndNewlines) updateCharactersRemaining() updatePlaceholder() progressView.progress = 0 if let mentioningAcct = mentioningAcct { let req = MastodonController.client.searchForAccount(query: mentioningAcct) MastodonController.client.run(req) { [weak self] (response) in if case let .success(accounts, _) = response { self?.userActivity = UserActivityManager.newPostActivity(mentioning: accounts.first) } else { self?.userActivity = UserActivityManager.newPostActivity() } } } else { self.userActivity = UserActivityManager.newPostActivity() } } override func viewDidLayoutSubviews() { if inReplyToID != nil && !scrolled { scrollView.contentOffset = CGPoint(x: 0, y: inReplyToContainerView.bounds.height - 44) scrolled = true } } func addMedia(for image: UIImage) { let mediaView = ComposeMediaView(image: image) mediaView.delegate = self mediaStackView.addArrangedSubview(mediaView) } func updateCharactersRemaining() { let count = CharacterCounter.count(text: statusTextView.text) let remaining = (MastodonController.instance.maxStatusCharacters ?? 500) - count if remaining < 0 { charactersRemainingLabel.textColor = .red postButton.isEnabled = false } else { charactersRemainingLabel.textColor = .darkGray postButton.isEnabled = true } charactersRemainingLabel.text = remaining.description } func updatePlaceholder() { placeholderLabel.isHidden = !statusTextView.text.isEmpty } // MARK: - Navigation override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { statusTextView.resignFirstResponder() contentWarningTextField.resignFirstResponder() super.dismiss(animated: flag, completion: completion) } // MARK: - Interaction @IBAction func visibilityPressed(_ sender: Any) { let alertController = UIAlertController(currentVisibility: self.visibility) { (visibility) in guard let visibility = visibility else { return } UIView.performWithoutAnimation { self.visibility = visibility self.visibilityButton.layoutIfNeeded() } } present(alertController, animated: true) } @IBAction func contentWarningPressed(_ sender: Any) { contentWarning = !contentWarning } @IBAction func mediaPressed(_ sender: Any) { let imagePicker = UIImagePickerController() imagePicker.delegate = self let alertController = UIAlertController(title: "Choose Image Source", message: nil, preferredStyle: .actionSheet) if UIImagePickerController.isSourceTypeAvailable(.camera) { alertController.addAction(UIAlertAction(title: "Camera", style: .default, handler: { _ in imagePicker.sourceType = .camera self.present(imagePicker, animated: true) })) } if UIImagePickerController.isSourceTypeAvailable(.photoLibrary) { alertController.addAction(UIAlertAction(title: "Photo Library", style: .default, handler: { __ in imagePicker.sourceType = .photoLibrary self.present(imagePicker, animated: true) })) } alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) present(alertController, animated: true) } @IBAction func postPressed(_ sender: Any) { guard let text = statusTextView.text, !text.isEmpty else { return } postButton.isEnabled = false let contentWarning: String? if self.contentWarning, let text = contentWarningTextField.text, !text.isEmpty { contentWarning = text } else { contentWarning = nil } let sensitive = contentWarning != nil let visibility = self.visibility var attachments: [Attachment?] = [] let group = DispatchGroup() for view in mediaStackView.arrangedSubviews { guard let mediaView = view as? ComposeMediaView, let image = mediaView.image, let data = image.pngData() else { continue } let index = attachments.count attachments.append(nil) progressView.steps += 1 group.enter() let request = MastodonController.client.upload(attachment: FormAttachment(pngData: data), description: mediaView.mediaDescription) MastodonController.client.run(request) { response in guard case let .success(attachment, _) = response else { fatalError() } attachments[index] = attachment self.progressView.step() group.leave() } } progressView.steps = 2 + attachments.count progressView.currentStep = 1 group.notify(queue: .main) { let attachments = attachments.compactMap { $0 } let request = MastodonController.client.createStatus(text: text, inReplyTo: self.inReplyToID, media: attachments, sensitive: sensitive, spoilerText: contentWarning, visibility: visibility) MastodonController.client.run(request) { response in guard case let .success(status, _) = response else { fatalError() } self.status = status MastodonCache.add(status: status) if let draft = self.draft { DraftsManager.shared.remove(draft) } DispatchQueue.main.async { self.progressView.step() self.dismiss(animated: true) // TODO: reorganize routing/navigation (((self.presentingViewController as! MainTabBarViewController).selectedViewController as! UINavigationController).topViewController as! TuskerNavigationDelegate).selected(status: status.id) self.xcbSession?.complete(with: .success, additionalData: [ "statusURL": status.url?.absoluteString, "statusURI": status.uri ]) } } } } @objc func imagePressed(_ gesture: UITapGestureRecognizer) { gesture.view!.superview!.removeFromSuperview() } @objc func keyboardDoneButtonPressed() { statusTextView.endEditing(false) } func saveDraft() { if let draft = draft { draft.update(text: statusTextView.text) } else { DraftsManager.shared.create(text: statusTextView.text) } } func close() { dismiss(animated: true) xcbSession?.complete(with: .cancel) } @objc func cancelPressed() { 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 draft = self.draft { DraftsManager.shared.remove(draft) } self.close() })) alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in })) router.present(alert, animated: true) } @objc func draftsPressed() { let drafts = router.drafts() drafts.delegate = self router.present(UINavigationController(rootViewController: drafts), animated: true) } } extension ComposeViewController: UITextFieldDelegate { func textFieldShouldReturn(_ textField: UITextField) -> Bool { textField.endEditing(false) return true } } extension ComposeViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { updateCharactersRemaining() updatePlaceholder() } } extension ComposeViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) { if let selectedImage = info[.originalImage] as? UIImage { addMedia(for: selectedImage) dismiss(animated: true) } } } extension ComposeViewController: ComposeMediaViewDelegate { func editDescription(for media: ComposeMediaView) { let alertController = UIAlertController(title: "Media Description", message: nil, preferredStyle: .alert) alertController.addTextField { textField in textField.text = media.mediaDescription } alertController.addAction(UIAlertAction(title: "Confirm", style: .default, handler: { _ in let description = alertController.textFields![0].text media.mediaDescription = description })) alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) present(alertController, animated: true) } } extension ComposeViewController: DraftsTableViewControllerDelegate { func draftSelectionCanceled() { } func draftSelected(_ draft: DraftsManager.Draft) { self.draft = draft statusTextView.text = draft.text updatePlaceholder() } }