// // 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 selectedAttachments: [CompositionAttachment] = [] { 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? 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 attachmentsStackView: UIStackView! @IBOutlet weak var addAttachmentButton: UIButton! @IBOutlet weak var postProgressView: SteppedProgressView! 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) updateCharactersRemaining() updateAttachmentDescriptionsRequired() updatePlaceholder() 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 inReplyToLabel.text = "In reply to \(inReplyTo.account.realDisplayName)" } 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)! item.accessibilityLabel = format.accessibilityLabel 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 updateAttachmentDescriptionsRequired() { if Preferences.shared.requireAttachmentDescriptions { for case let mediaView as ComposeMediaView in attachmentsStackView.arrangedSubviews { if mediaView.descriptionTextView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { compositionState.formUnion(.requiresAttachmentDescriptions) return } } } compositionState.subtract(.requiresAttachmentDescriptions) } func updateCharactersRemaining() { 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 compositionState.formUnion(.tooManyCharacters) } else { charactersRemainingLabel.textColor = .darkGray compositionState.subtract(.tooManyCharacters) } charactersRemainingLabel.text = String(remaining) charactersRemainingLabel.accessibilityLabel = String(format: NSLocalizedString("%d characters remaining", comment: "compose characters remaining accessibility label"), remaining) } 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 = selectedAttachments.count <= 4 && !selectedAttachments.contains(where: { $0.type == .video }) } } func updateAttachmentViews() { for view in attachmentsStackView.arrangedSubviews { if view is ComposeMediaView { view.removeFromSuperview() } } for attachment in selectedAttachments { let mediaView = ComposeMediaView.create() mediaView.delegate = self mediaView.update(attachment: attachment) attachmentsStackView.insertArrangedSubview(mediaView, at: attachmentsStackView.arrangedSubviews.count - 1) updateAddAttachmentButton() } } func contentWarningStateChanged() { contentWarningContainerView.isHidden = !contentWarningEnabled if contentWarningEnabled { contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Remove Content Warning", comment: "remove CW accessibility label") } else { contentWarningBarButtonItem.accessibilityLabel = NSLocalizedString("Add Content Warning", comment: "add CW accessibility label") } } func visibilityChanged() { visibilityBarButtonItem.image = UIImage(systemName: visibility.imageName) visibilityBarButtonItem.accessibilityLabel = String(format: NSLocalizedString("Visibility: %@", comment: "compose visiblity accessibility label"), Preferences.shared.defaultPostVisibility.displayName) } func saveDraft() { var attachments = [DraftsManager.DraftAttachment]() for case let mediaView as ComposeMediaView in attachmentsStackView.arrangedSubviews where mediaView.attachment.canSaveToDraft { let attachment = mediaView.attachment! let description = mediaView.descriptionTextView.text ?? "" attachments.append(.init(attachment: attachment, description: description)) } let cw = contentWarningEnabled ? contentWarningTextField.text : nil let account = mastodonController.accountInfo! if let currentDraft = self.currentDraft { currentDraft.update(accountID: account.id, text: self.statusTextView.text, contentWarning: cw, attachments: attachments) } else { self.currentDraft = DraftsManager.shared.create(accountID: account.id, text: self.statusTextView.text, contentWarning: cw, inReplyToID: inReplyToID, attachments: attachments) } DraftsManager.save() } @objc func close() { dismiss(animated: true) xcbSession?.complete(with: .cancel) } // 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: AssetPickerViewControllerDelegate { func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachment.AttachmentType) -> Bool { switch mastodonController.instance.instanceType { case .pleroma: return true case .mastodon: if (type == .video && selectedAttachments.count > 0) || selectedAttachments.contains(where: { $0.type == .video }) || assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) { return false } return selectedAttachments.count + assetPicker.currentCollectionSelectedAssets.count < 4 } } func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachment]) { selectedAttachments.append(contentsOf: attachments) updateAttachmentDescriptionsRequired() } } extension ComposeViewController: ComposeMediaViewDelegate { func didRemoveMedia(_ mediaView: ComposeMediaView) { let index = attachmentsStackView.arrangedSubviews.firstIndex(of: mediaView)! selectedAttachments.remove(at: index) updateAddAttachmentButton() updateAttachmentDescriptionsRequired() } func descriptionTextViewDidChange(_ mediaView: ComposeMediaView) { updateAttachmentDescriptionsRequired() } } extension ComposeViewController: DraftsTableViewControllerDelegate { func draftSelectionCanceled() { } func shouldSelectDraft(_ draft: DraftsManager.Draft, completion: @escaping (Bool) -> Void) { if draft.inReplyToID != self.inReplyToID { 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) { self.currentDraft = draft inReplyToID = draft.inReplyToID updateInReplyTo() statusTextView.text = draft.text contentWarningEnabled = draft.contentWarning != nil contentWarningTextField.text = draft.contentWarning updatePlaceholder() updateCharactersRemaining() selectedAttachments = draft.attachments.map { $0.attachment } updateAttachmentViews() for case let mediaView as ComposeMediaView in attachmentsStackView.arrangedSubviews { let attachment = draft.attachments.first(where: { $0.attachment == mediaView.attachment })! mediaView.descriptionTextView.text = attachment.description // call the delegate method manually, since setting the text property doesn't call it mediaView.textViewDidChange(mediaView.descriptionTextView) } updateAttachmentDescriptionsRequired() } func draftSelectionCompleted() { // check that all the assets from the draft have been added if let currentDraft = currentDraft, selectedAttachments.count < currentDraft.attachments.count { // some of the assets in the draft weren't loaded, so notify the user let difference = currentDraft.attachments.count - selectedAttachments.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) } }