A WIP iOS app for Mastodon and Pleroma.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

402 lines
15 KiB

//
// 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()
}
}