Tusker/Tusker/Screens/Compose/ComposeViewController.swift

659 lines
28 KiB
Swift

//
// 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..<end]
statusTextView.insertText(String(insertionResult.prefix + selectedText + insertionResult.suffix))
statusTextView.selectedRange = NSRange(location: currentSelectedRange.location + insertionResult.prefix.count, length: currentSelectedRange.length)
}
}
@objc func draftsButtonPressed() {
let draftsVC = DraftsTableViewController()
draftsVC.delegate = self
present(UINavigationController(rootViewController: draftsVC), animated: true)
}
@IBAction func addAttachmentPressed(_ sender: Any) {
let picker = GMImagePickerController()
picker.delegate = self
picker.toolbarTintColor = view.tintColor
picker.navigationBarTintColor = view.tintColor
picker.title = "Choose Attachment"
present(picker, animated: true)
}
@objc func postButtonPressed() {
guard let text = statusTextView.text,
!text.isEmpty else { return }
// save a draft before posting the status, so if a crash occurs during posting, the status won't be lost
saveDraft()
// disable post button while sending post request
postBarButtonItem.isEnabled = false
let contentWarning: String?
if contentWarningEnabled, let cwText = contentWarningTextField.text, !cwText.isEmpty {
contentWarning = cwText
} else {
contentWarning = nil
}
let sensitive = contentWarning != nil
let visibility = self.visibility!
let group = DispatchGroup()
var attachments: [Attachment?] = []
for asset in selectedAssets {
let index = attachments.count
attachments.append(nil)
let mediaView = attachmentsStackView.arrangedSubviews[index] as! ComposeMediaView
let description = mediaView.descriptionTextView.text
group.enter()
let options = PHImageRequestOptions()
options.version = .current
options.deliveryMode = .highQualityFormat
options.resizeMode = .none
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { (data, dataUTI, orientation, info) in
guard let data = data, let dataUTI = dataUTI else { fatalError() }
let mimeType = UTTypeCopyPreferredTagWithClass(dataUTI as CFString, kUTTagClassMIMEType)!.takeRetainedValue() as String
self.postProgressView.step()
let request = MastodonController.client.upload(attachment: FormAttachment(mimeType: mimeType, data: data, fileName: "file"), description: description)
MastodonController.client.run(request) { (response) in
guard case let .success(attachment, _) = response else { fatalError() }
attachments[index] = attachment
self.postProgressView.step()
group.leave()
}
}
}
postProgressView.steps = 2 + (attachments.count * 2) // 2 steps (request data, then upload) for each attachment
postProgressView.currentStep = 1
group.notify(queue: .main) {
let attachments = attachments.compactMap { $0 }
let request = MastodonController.client.createStatus(text: text,
contentType: Preferences.shared.statusContentType,
inReplyTo: self.inReplyToID,
media: attachments,
sensitive: sensitive,
spoilerText: contentWarning,
visibility: visibility,
language: nil)
MastodonController.client.run(request) { (response) in
guard case let .success(status, _) = response else { fatalError() }
self.postedStatus = status
MastodonCache.add(status: status)
if let draft = self.currentDraft {
DraftsManager.shared.remove(draft)
}
DispatchQueue.main.async {
self.postProgressView.step()
self.dismiss(animated: true)
let conversationVC = ConversationTableViewController(for: status.id)
self.show(conversationVC, sender: self)
self.xcbSession?.complete(with: .success, additionalData: [
"statusURL": status.url?.absoluteString,
"statusURI": status.uri
])
}
}
}
}
}
extension ComposeViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let replyView = replyView else { return }
var constant: CGFloat = 8
if scrollView.contentOffset.y < 0 {
constant -= scrollView.contentOffset.y
replyAvatarImageViewTopConstraint?.constant = 8 - scrollView.contentOffset.y
} else if scrollView.contentOffset.y > 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)
}
}