// // 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 { weak var mastodonController: MastodonController! var inReplyToID: String? var accountsToMention = [String]() var initialText: String? var contentWarningEnabled = false { didSet { contentWarningStateChanged() } } var visibility: Status.Visibility! { didSet { visibilityChanged() } } 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? var compositionState: CompositionState = .valid { didSet { postBarButtonItem.isEnabled = compositionState.isValid } } 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 inReplyToContainer: UIView! @IBOutlet weak var inReplyToLabel: UILabel! @IBOutlet weak var contentWarningContainerView: UIView! @IBOutlet weak var contentWarningTextField: UITextField! @IBOutlet weak var composeAttachmentsContainerView: UIView! @IBOutlet weak var postProgressView: SteppedProgressView! var composeAttachmentsViewController: ComposeAttachmentsViewController! init(inReplyTo inReplyToID: String? = nil, mentioningAcct: String? = nil, text: String? = nil, mastodonController: MastodonController) { self.mastodonController = mastodonController self.inReplyToID = inReplyToID if let inReplyToID = inReplyToID, let inReplyTo = mastodonController.cache.status(for: inReplyToID) { accountsToMention = [inReplyTo.account.acct] + inReplyTo.mentions.map { $0.acct } } else { accountsToMention = [] } if let mentioningAcct = mentioningAcct { accountsToMention.append(mentioningAcct) } 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)) contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label") visibilityBarButtonItem = UIBarButtonItem(image: UIImage(systemName: Preferences.shared.defaultPostVisibility.imageName), style: .plain, target: self, action: #selector(visibilityButtonPressed)) visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName) 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) } } updateInReplyTo() // we have to set the font here, because the monospaced digit font is not available in IB charactersRemainingLabel.font = .monospacedDigitSystemFont(ofSize: 17, weight: .regular) updatePlaceholder() // if the compose screen is opened via the home screen shortcut and app isn't running, // the msatodon instance may not have been loaded yet mastodonController.getOwnInstance { (_) in DispatchQueue.main.async { self.updateCharactersRemaining() } } composeAttachmentsViewController = ComposeAttachmentsViewController(attachments: currentDraft?.attachments ?? [], mastodonController: mastodonController) composeRequiresAttachmentDescriptionsDidChange() composeAttachmentsViewController.delegate = self composeAttachmentsViewController.tableView.isScrollEnabled = false composeAttachmentsViewController.tableView.translatesAutoresizingMaskIntoConstraints = false embedChild(composeAttachmentsViewController, in: composeAttachmentsContainerView) pasteConfiguration = composeAttachmentsViewController.pasteConfiguration NotificationCenter.default.addObserver(self, selector: #selector(contentWarningTextFieldDidChange), name: UITextField.textDidChangeNotification, object: contentWarningTextField) } func updateInReplyTo() { if let replyView = replyView { replyView.removeFromSuperview() } if let inReplyToID = inReplyToID { if let status = mastodonController.cache.status(for: inReplyToID) { updateInReplyTo(inReplyTo: status) } else { let loadingVC = LoadingViewController() embedChild(loadingVC) mastodonController.cache.status(for: inReplyToID) { (status) in guard let status = status else { return } DispatchQueue.main.async { self.updateInReplyTo(inReplyTo: status) loadingVC.removeViewAndController() } } } } else { visibility = Preferences.shared.defaultPostVisibility contentWarningEnabled = false inReplyToContainer.isHidden = true } } func updateInReplyTo(inReplyTo: Status) { 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.mastodonController = mastodonController 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 inReplyToContainer.isHidden = false // todo: update to use managed objects inReplyToLabel.text = "In reply to \(inReplyTo.account.displayName)" } 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) } func createFormattingButtons() -> [UIBarButtonItem] { guard Preferences.shared.statusContentType != .plain else { return [] } var formatButtons = StatusFormat.allCases.map { (format) -> UIBarButtonItem 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)! item.accessibilityLabel = format.accessibilityLabel return item } for i in (1.. Bool { return composeAttachmentsViewController.canPaste(itemProviders) } override func paste(itemProviders: [NSItemProvider]) { composeAttachmentsViewController.paste(itemProviders: itemProviders) } // 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: ComposeAttachmentsViewControllerDelegate { func composeSelectedAttachmentsDidChange() { currentDraft?.attachments = composeAttachmentsViewController.attachments } func composeRequiresAttachmentDescriptionsDidChange() { if composeAttachmentsViewController.requiresAttachmentDescriptions { compositionState.formUnion(.requiresAttachmentDescriptions) } else { compositionState.subtract(.requiresAttachmentDescriptions) } } } extension ComposeViewController: DraftsTableViewControllerDelegate { func draftSelectionCanceled() { } func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void) { if draft.inReplyToID != self.inReplyToID, hasChanges { let alertController = UIAlertController(title: "Different Reply", message: "The selected draft is a reply to a different status, do you wish to use it?", preferredStyle: .alert) alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: { (_) in completion(false) })) alertController.addAction(UIAlertAction(title: "Restore Draft", style: .default, handler: { (_) in completion(true) })) // we can't present the alert ourselves, since the compose VC is already presenting the draft selector // but presenting on the presented view controller seems hacky, is there a better way to do this? presentedViewController!.present(alertController, animated: true) } else { completion(true) } } func draftSelected(_ draft: DraftsManager.Draft) { if hasChanges { saveDraft() } self.currentDraft = draft inReplyToID = draft.inReplyToID updateInReplyTo() statusTextView.text = draft.text contentWarningEnabled = draft.contentWarning != nil contentWarningTextField.text = draft.contentWarning updatePlaceholder() updateCharactersRemaining() composeAttachmentsViewController.setAttachments(draft.attachments) } func draftSelectionCompleted() { // todo: I don't think this can actually happen any more? // check that all the assets from the draft have been added if let currentDraft = currentDraft, composeAttachmentsViewController.attachments.count < currentDraft.attachments.count { // some of the assets in the draft weren't loaded, so notify the user let difference = currentDraft.attachments.count - composeAttachmentsViewController.attachments.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) } }