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.

342 lines
14 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?
var mentioningAcct: String?
var text: String?
// 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))
}
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) { (image) in
DispatchQueue.main.async {
self.inReplyToAvatarImageView.image = image
}
}
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
}
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(title: "Post Visibility", message: nil, preferredStyle: .actionSheet)
for visibility in Status.Visibility.allCases {
let action = UIAlertAction(title: visibility.displayName, style: .default, handler: { _ in
UIView.performWithoutAnimation {
self.visibility = visibility
self.visibilityButton.layoutIfNeeded()
}
})
if visibility == self.visibility {
action.setValue(true, forKey: "checked")
}
alertController.addAction(action)
}
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
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)
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)
}
@objc func cancelPressed() {
dismiss(animated: true)
xcbSession?.complete(with: .cancel)
}
}
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)
}
}