forked from shadowfacts/Tusker
528 lines
23 KiB
Swift
528 lines
23 KiB
Swift
//
|
|
// ComposeAttachmentsViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 3/11/20.
|
|
// Copyright © 2020 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Pachyderm
|
|
import MobileCoreServices
|
|
import PencilKit
|
|
|
|
protocol ComposeAttachmentsViewControllerDelegate: class {
|
|
func composeSelectedAttachmentsDidChange()
|
|
func composeRequiresAttachmentDescriptionsDidChange()
|
|
}
|
|
|
|
class ComposeAttachmentsViewController: UITableViewController {
|
|
|
|
weak var mastodonController: MastodonController!
|
|
weak var delegate: ComposeAttachmentsViewControllerDelegate?
|
|
|
|
private var heightConstraint: NSLayoutConstraint!
|
|
|
|
var attachments: [CompositionAttachment] = [] {
|
|
didSet {
|
|
delegate?.composeSelectedAttachmentsDidChange()
|
|
delegate?.composeRequiresAttachmentDescriptionsDidChange()
|
|
updateAddAttachmentsButtonEnabled()
|
|
}
|
|
}
|
|
|
|
var requiresAttachmentDescriptions: Bool {
|
|
if Preferences.shared.requireAttachmentDescriptions {
|
|
return attachments.contains { $0.attachmentDescription.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var currentlyEditedDrawingIndex: Int?
|
|
|
|
init(attachments: [CompositionAttachment], mastodonController: MastodonController) {
|
|
self.attachments = attachments
|
|
self.mastodonController = mastodonController
|
|
super.init(style: .plain)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
tableView.rowHeight = UITableView.automaticDimension
|
|
tableView.estimatedRowHeight = 96
|
|
|
|
tableView.register(UINib(nibName: "AddAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "addAttachment")
|
|
tableView.register(UINib(nibName: "ComposeAttachmentTableViewCell", bundle: .main), forCellReuseIdentifier: "composeAttachment")
|
|
|
|
// you would think the table view could handle this itself, but no, using a constraint on the table view's contentLayoutGuide doesn't work
|
|
// add extra space, so when dropping items, the add attachment cell doesn't disappear
|
|
heightConstraint = tableView.heightAnchor.constraint(equalToConstant: tableView.contentSize.height + 80)
|
|
heightConstraint.isActive = true
|
|
|
|
// prevents extra separator lines from appearing when the height of the table view is greater than the height of the content
|
|
tableView.tableFooterView = UIView()
|
|
|
|
pasteConfiguration = UIPasteConfiguration(forAccepting: CompositionAttachment.self)
|
|
|
|
// enable dragging on iPhone to allow reordering
|
|
tableView.dragInteractionEnabled = true
|
|
tableView.dragDelegate = self
|
|
tableView.dropDelegate = self
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
updateHeightConstraint()
|
|
}
|
|
|
|
func setAttachments(_ attachments: [CompositionAttachment]) {
|
|
tableView.performBatchUpdates({
|
|
tableView.deleteRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic)
|
|
self.attachments = attachments
|
|
tableView.insertRows(at: self.attachments.indices.map { IndexPath(row: $0, section: 0) }, with: .automatic)
|
|
})
|
|
updateHeightConstraint()
|
|
delegate?.composeRequiresAttachmentDescriptionsDidChange()
|
|
}
|
|
|
|
private func updateHeightConstraint() {
|
|
// add extra space, so when dropping items, the add attachment cell doesn't disappear
|
|
heightConstraint.constant = tableView.contentSize.height + 80
|
|
}
|
|
|
|
private func isAddAttachmentsButtonEnabled() -> Bool {
|
|
switch mastodonController.instance.instanceType {
|
|
case .pleroma:
|
|
return true
|
|
case .mastodon:
|
|
return !attachments.contains(where: { $0.data.type == .video }) && attachments.count < 4
|
|
}
|
|
}
|
|
|
|
private func updateAddAttachmentsButtonEnabled() {
|
|
guard let cell = tableView.cellForRow(at: IndexPath(row: 0, section: 1)) as? AddAttachmentTableViewCell else { return }
|
|
cell.setEnabled(isAddAttachmentsButtonEnabled())
|
|
}
|
|
|
|
override func canPaste(_ itemProviders: [NSItemProvider]) -> Bool {
|
|
guard itemProviders.allSatisfy({ $0.canLoadObject(ofClass: CompositionAttachment.self) }) else {
|
|
return false
|
|
}
|
|
|
|
switch mastodonController.instance.instanceType {
|
|
case .pleroma:
|
|
return true
|
|
case .mastodon:
|
|
return itemProviders.count + attachments.count <= 4
|
|
}
|
|
}
|
|
|
|
override func paste(itemProviders: [NSItemProvider]) {
|
|
for provider in itemProviders {
|
|
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
|
|
if let error = error {
|
|
fatalError("Couldn't load image from NSItemProvider: \(error)")
|
|
}
|
|
guard let attachment = object as? CompositionAttachment else {
|
|
fatalError("Couldn't convert object from NSItemProvider to CompositionAttachment")
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
self.attachments.append(attachment)
|
|
self.tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
|
|
self.updateHeightConstraint()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func presentComposeDrawingViewController(editingAttachmentAt attachmentIndex: Int? = nil) {
|
|
let drawingVC: ComposeDrawingViewController
|
|
|
|
if let index = attachmentIndex,
|
|
case let .drawing(drawing) = attachments[index].data {
|
|
drawingVC = ComposeDrawingViewController(editing: drawing)
|
|
currentlyEditedDrawingIndex = index
|
|
} else {
|
|
drawingVC = ComposeDrawingViewController()
|
|
}
|
|
|
|
drawingVC.delegate = self
|
|
let nav = UINavigationController(rootViewController: drawingVC)
|
|
nav.modalPresentationStyle = .fullScreen
|
|
present(nav, animated: true)
|
|
}
|
|
|
|
func uploadAll(stepProgress: @escaping () -> Void, completion: @escaping (_ success: Bool, _ uploadedAttachments: [Attachment]) -> Void) {
|
|
let group = DispatchGroup()
|
|
|
|
var anyFailed = false
|
|
var uploadedAttachments: [Result<Attachment, Error>?] = []
|
|
|
|
for (index, compAttachment) in attachments.enumerated() {
|
|
group.enter()
|
|
|
|
uploadedAttachments.append(nil)
|
|
|
|
compAttachment.data.getData { (data, mimeType) in
|
|
stepProgress()
|
|
|
|
let formAttachment = FormAttachment(mimeType: mimeType, data: data, fileName: "file")
|
|
let request = Client.upload(attachment: formAttachment, description: compAttachment.attachmentDescription)
|
|
self.mastodonController.run(request) { (response) in
|
|
switch response {
|
|
case let .failure(error):
|
|
uploadedAttachments[index] = .failure(error)
|
|
anyFailed = true
|
|
case let .success(attachment, _):
|
|
uploadedAttachments[index] = .success(attachment)
|
|
}
|
|
|
|
stepProgress()
|
|
group.leave()
|
|
}
|
|
}
|
|
}
|
|
|
|
group.notify(queue: .main) {
|
|
if anyFailed {
|
|
let errors: [(Int, Error)] = uploadedAttachments.enumerated().compactMap { (index, result) in
|
|
switch result {
|
|
case let .failure(error):
|
|
return (index, error)
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
}
|
|
let title: String
|
|
var message: String
|
|
if errors.count == 1 {
|
|
title = NSLocalizedString("Could not upload attachment", comment: "single attachment upload failed alert title")
|
|
message = errors[0].1.localizedDescription
|
|
} else {
|
|
title = NSLocalizedString("Could not upload the following attachments", comment: "multiple attachment upload failures alert title")
|
|
message = ""
|
|
for (index, error) in errors {
|
|
message.append("Attachment \(index + 1): \(error.localizedDescription)")
|
|
}
|
|
}
|
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: { (_) in
|
|
completion(false, [])
|
|
}))
|
|
} else {
|
|
let uploadedAttachments: [Attachment] = uploadedAttachments.compactMap {
|
|
switch $0 {
|
|
case let .success(attachment):
|
|
return attachment
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
completion(true, uploadedAttachments)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Table view data source
|
|
|
|
override func numberOfSections(in tableView: UITableView) -> Int {
|
|
return 2
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
|
switch section {
|
|
case 0:
|
|
return attachments.count
|
|
case 1:
|
|
return 1
|
|
default:
|
|
fatalError("invalid section \(section)")
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
|
switch indexPath.section {
|
|
case 0:
|
|
let attachment = attachments[indexPath.row]
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "composeAttachment", for: indexPath) as! ComposeAttachmentTableViewCell
|
|
cell.delegate = self
|
|
cell.updateUI(for: attachment)
|
|
cell.setEnabled(true)
|
|
return cell
|
|
case 1:
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "addAttachment", for: indexPath) as! AddAttachmentTableViewCell
|
|
cell.setEnabled(isAddAttachmentsButtonEnabled())
|
|
return cell
|
|
default:
|
|
fatalError("invalid section \(indexPath.section)")
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
|
guard sourceIndexPath != destinationIndexPath, sourceIndexPath.section == 0, destinationIndexPath.section == 0 else { return }
|
|
|
|
attachments.insert(attachments.remove(at: sourceIndexPath.row), at: destinationIndexPath.row)
|
|
}
|
|
|
|
// MARK: Table view delegate
|
|
|
|
override func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
|
|
if indexPath.section == 1, isAddAttachmentsButtonEnabled() {
|
|
return indexPath
|
|
}
|
|
return nil
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
tableView.deselectRow(at: indexPath, animated: true)
|
|
|
|
if indexPath.section == 1 {
|
|
addAttachmentPressed()
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
|
if indexPath.section == 0 {
|
|
let attachment = attachments[indexPath.row]
|
|
// cast to NSIndexPath because identifier needs to conform to NSCopying
|
|
return UIContextMenuConfiguration(identifier: indexPath as NSIndexPath, previewProvider: { () -> UIViewController? in
|
|
return AssetPreviewViewController(attachment: attachment.data)
|
|
}) { (_) -> UIMenu? in
|
|
var actions = [UIAction]()
|
|
|
|
switch attachment.data {
|
|
case .drawing(_):
|
|
actions.append(UIAction(title: "Edit Drawing", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
|
self.presentComposeDrawingViewController(editingAttachmentAt: indexPath.row)
|
|
}))
|
|
default:
|
|
break
|
|
}
|
|
|
|
if actions.isEmpty {
|
|
return nil
|
|
} else {
|
|
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: actions)
|
|
}
|
|
}
|
|
} else if indexPath.section == 1 {
|
|
guard isAddAttachmentsButtonEnabled() else {
|
|
return nil
|
|
}
|
|
// show context menu for drawing/file uploads
|
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { (_) -> UIMenu? in
|
|
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: [
|
|
UIAction(title: "Draw Something", image: UIImage(systemName: "hand.draw"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
|
|
self.presentComposeDrawingViewController()
|
|
})
|
|
])
|
|
}
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func targetedPreview(forConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
if let indexPath = (configuration.identifier as? NSIndexPath) as IndexPath?,
|
|
indexPath.section == 0,
|
|
let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell {
|
|
let parameters = UIPreviewParameters()
|
|
parameters.backgroundColor = .black
|
|
return UITargetedPreview(view: cell.assetImageView, parameters: parameters)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
return targetedPreview(forConfiguration: configuration)
|
|
}
|
|
|
|
override func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
|
|
return targetedPreview(forConfiguration: configuration)
|
|
}
|
|
|
|
// MARK: Interaction
|
|
|
|
func addAttachmentPressed() {
|
|
if traitCollection.horizontalSizeClass == .compact {
|
|
let sheetContainer = AssetPickerSheetContainerViewController()
|
|
sheetContainer.assetPicker.assetPickerDelegate = self
|
|
present(sheetContainer, animated: true)
|
|
} else {
|
|
let picker = AssetPickerViewController()
|
|
picker.assetPickerDelegate = self
|
|
picker.overrideUserInterfaceStyle = .dark
|
|
picker.modalPresentationStyle = .popover
|
|
present(picker, animated: true)
|
|
if let presentationController = picker.presentationController as? UIPopoverPresentationController {
|
|
presentationController.sourceView = tableView.cellForRow(at: IndexPath(row: 0, section: 1))
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension ComposeAttachmentsViewController: UITableViewDragDelegate {
|
|
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
|
|
guard indexPath.section == 0 else { return [] }
|
|
|
|
let attachment = attachments[indexPath.row]
|
|
let provider = NSItemProvider(object: attachment)
|
|
let dragItem = UIDragItem(itemProvider: provider)
|
|
dragItem.localObject = attachment
|
|
return [dragItem]
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
|
|
guard indexPath.section == 0 else { return [] }
|
|
|
|
let attachment = attachments[indexPath.row]
|
|
let provider = NSItemProvider(object: attachment)
|
|
let dragItem = UIDragItem(itemProvider: provider)
|
|
dragItem.localObject = attachment
|
|
return [dragItem]
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, dragPreviewParametersForRowAt indexPath: IndexPath) -> UIDragPreviewParameters? {
|
|
guard indexPath.section == 0 else { return nil }
|
|
|
|
let cell = tableView.cellForRow(at: indexPath) as! ComposeAttachmentTableViewCell
|
|
let rect = cell.convert(cell.assetImageView.bounds, from: cell.assetImageView)
|
|
let path = UIBezierPath(roundedRect: rect, cornerRadius: cell.assetImageView.layer.cornerRadius)
|
|
let params = UIDragPreviewParameters()
|
|
params.visiblePath = path
|
|
return params
|
|
}
|
|
}
|
|
|
|
extension ComposeAttachmentsViewController: UITableViewDropDelegate {
|
|
func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
|
|
return session.canLoadObjects(ofClass: CompositionAttachment.self)
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
|
|
// if items were dragged out of ourself, then the items are only being moved
|
|
if tableView.hasActiveDrag {
|
|
// todo: should moving multiple items actually be prohibited?
|
|
if session.items.count > 1 {
|
|
return UITableViewDropProposal(operation: .cancel)
|
|
} else {
|
|
return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath)
|
|
}
|
|
} else {
|
|
return UITableViewDropProposal(operation: .copy, intent: .insertAtDestinationIndexPath)
|
|
}
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
|
|
let destinationIndexPath = coordinator.destinationIndexPath ?? IndexPath(row: attachments.count, section: 0)
|
|
|
|
// we don't need to handle local items here, when the .move operation is used returned from the tableView(_:dropSessionDidUpdate:withDestinationIndexPath:) method,
|
|
// the table view will handle animating and call the normal data source tableView(_:moveRowAt:to:)
|
|
|
|
for (index, item) in coordinator.items.enumerated() {
|
|
let provider = item.dragItem.itemProvider
|
|
|
|
if provider.canLoadObject(ofClass: CompositionAttachment.self) {
|
|
let indexPath = IndexPath(row: destinationIndexPath.row + index, section: 0)
|
|
let placeholder = UITableViewDropPlaceholder(insertionIndexPath: indexPath, reuseIdentifier: "composeAttachment", rowHeight: 96)
|
|
placeholder.cellUpdateHandler = { (cell) in
|
|
let cell = cell as! ComposeAttachmentTableViewCell
|
|
cell.setEnabled(false)
|
|
}
|
|
let placeholderContext = coordinator.drop(item.dragItem, to: placeholder)
|
|
|
|
provider.loadObject(ofClass: CompositionAttachment.self) { (object, error) in
|
|
DispatchQueue.main.async {
|
|
if let attachment = object as? CompositionAttachment {
|
|
placeholderContext.commitInsertion { (insertionIndexPath) in
|
|
self.attachments.insert(attachment, at: insertionIndexPath.row)
|
|
}
|
|
} else {
|
|
placeholderContext.deletePlaceholder()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
updateHeightConstraint()
|
|
}
|
|
}
|
|
|
|
extension ComposeAttachmentsViewController: AssetPickerViewControllerDelegate {
|
|
func assetPicker(_ assetPicker: AssetPickerViewController, shouldAllowAssetOfType type: CompositionAttachmentData.AttachmentType) -> Bool {
|
|
switch mastodonController.instance.instanceType {
|
|
case .pleroma:
|
|
return true
|
|
case .mastodon:
|
|
if (type == .video && attachments.count > 0) ||
|
|
attachments.contains(where: { $0.data.type == .video }) ||
|
|
assetPicker.currentCollectionSelectedAssets.contains(where: { $0.type == .video }) {
|
|
return false
|
|
}
|
|
return attachments.count + assetPicker.currentCollectionSelectedAssets.count < 4
|
|
}
|
|
}
|
|
|
|
func assetPicker(_ assetPicker: AssetPickerViewController, didSelectAttachments attachments: [CompositionAttachmentData]) {
|
|
let attachments = attachments.map {
|
|
CompositionAttachment(data: $0)
|
|
}
|
|
let indexPaths = attachments.indices.map { IndexPath(row: $0 + self.attachments.count, section: 0) }
|
|
self.attachments.append(contentsOf: attachments)
|
|
tableView.insertRows(at: indexPaths, with: .automatic)
|
|
updateHeightConstraint()
|
|
}
|
|
}
|
|
|
|
extension ComposeAttachmentsViewController: ComposeAttachmentTableViewCellDelegate {
|
|
func removeAttachment(_ cell: ComposeAttachmentTableViewCell) {
|
|
guard let indexPath = tableView.indexPath(for: cell) else { return }
|
|
attachments.remove(at: indexPath.row)
|
|
tableView.performBatchUpdates({
|
|
tableView.deleteRows(at: [indexPath], with: .automatic)
|
|
}, completion: { (_) in
|
|
// when removing cells, we don't trigger the container height update until after the animation has completed
|
|
// otherwise, during the animation, the height is too short and the last row briefly disappears
|
|
self.updateHeightConstraint()
|
|
})
|
|
}
|
|
|
|
func attachmentDescriptionChanged(_ cell: ComposeAttachmentTableViewCell) {
|
|
delegate?.composeRequiresAttachmentDescriptionsDidChange()
|
|
}
|
|
}
|
|
|
|
extension ComposeAttachmentsViewController: ComposeDrawingViewControllerDelegate {
|
|
func composeDrawingViewControllerClose(_ drawingController: ComposeDrawingViewController) {
|
|
dismiss(animated: true)
|
|
currentlyEditedDrawingIndex = nil
|
|
}
|
|
|
|
func composeDrawingViewController(_ drawingController: ComposeDrawingViewController, saveDrawing drawing: PKDrawing) {
|
|
let newAttachment = CompositionAttachment(data: .drawing(drawing))
|
|
|
|
if let currentlyEditedDrawingIndex = currentlyEditedDrawingIndex {
|
|
attachments[currentlyEditedDrawingIndex] = newAttachment
|
|
tableView.reloadRows(at: [IndexPath(row: currentlyEditedDrawingIndex, section: 0)], with: .automatic)
|
|
} else {
|
|
attachments.append(newAttachment)
|
|
tableView.insertRows(at: [IndexPath(row: self.attachments.count - 1, section: 0)], with: .automatic)
|
|
updateHeightConstraint()
|
|
}
|
|
|
|
dismiss(animated: true)
|
|
currentlyEditedDrawingIndex = nil
|
|
}
|
|
}
|