// // ComposeAttachmentsViewController.swift // Tusker // // Created by Shadowfacts on 3/11/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import MobileCoreServices import PencilKit import Photos 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 if mastodonController.instance == nil { mastodonController.getOwnInstance { [weak self] (_) in guard let self = self else { return } DispatchQueue.main.async { self.updateAddAttachmentsButtonEnabled() } } } } 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 nil: return false 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?] = [] 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) })) case .asset(_), .image(_): if attachment.data.type == .image, let cell = tableView.cellForRow(at: indexPath) as? ComposeAttachmentTableViewCell { let title = NSLocalizedString("Recognize Text", comment: "recognize image attachment text menu item title") actions.append(UIAction(title: title, image: UIImage(systemName: "doc.text.viewfinder"), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in cell.recognizeTextFromImage() })) } 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() { PHPhotoLibrary.requestAuthorization { (status) in guard status == .authorized else { return } DispatchQueue.main.async { if self.traitCollection.horizontalSizeClass == .compact { let sheetContainer = AssetPickerSheetContainerViewController() sheetContainer.assetPicker.assetPickerDelegate = self self.present(sheetContainer, animated: true) } else { let picker = AssetPickerViewController() picker.assetPickerDelegate = self picker.overrideUserInterfaceStyle = .dark picker.modalPresentationStyle = .popover self.present(picker, animated: true) if let presentationController = picker.presentationController as? UIPopoverPresentationController { presentationController.sourceView = self.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 composeAttachment(_ cell: ComposeAttachmentTableViewCell, present viewController: UIViewController, animated: Bool) { self.present(viewController, animated: animated) } 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() } func composeAttachmentDescriptionHeightChanged(_ cell: ComposeAttachmentTableViewCell) { tableView.performBatchUpdates(nil) { (_) in self.updateHeightConstraint() } } } 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 } }