Tusker/Tusker/Screens/Compose/ComposeViewController.swift

532 lines
21 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 {
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..<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 = router.drafts()
draftsVC.delegate = self
router.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 }
// 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().requestImageData(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 = self.router.conversation(for: status.id)
self.router.push(conversationVC, animated: true)
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()
}
}
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()
}
}