diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 0b029e6..ab1aece 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -17,7 +17,7 @@ final class NewStatusViewController: UIViewController { private let postButton = UIBarButtonItem(title: nil, style: .done, target: nil, action: nil) private let mediaSelections = PassthroughSubject<[PHPickerResult], Never>() private let imagePickerResults = PassthroughSubject<[UIImagePickerController.InfoKey: Any]?, Never>() - private let documentPickerResuls = PassthroughSubject<[URL]?, Never>() + private let documentPickerResults = PassthroughSubject<[URL]?, Never>() private var cancellables = Set() init(viewModel: NewStatusViewModel, rootViewModel: RootViewModel?) { @@ -127,11 +127,11 @@ extension NewStatusViewController: UIImagePickerControllerDelegate { extension NewStatusViewController: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - documentPickerResuls.send(urls) + documentPickerResults.send(urls) } func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { - documentPickerResuls.send(nil) + documentPickerResults.send(nil) } } @@ -282,19 +282,21 @@ private extension NewStatusViewController { } func presentMediaPicker(compositionViewModel: CompositionViewModel) { - mediaSelections.first().sink { [weak self] results in - guard let self = self, let result = results.first else { return } - - self.viewModel.attach(itemProvider: result.itemProvider, to: compositionViewModel) + mediaSelections.first().sink { [weak self] in + self?.viewModel.attach(itemProviders: $0.map(\.itemProvider), to: compositionViewModel) } .store(in: &cancellables) var configuration = PHPickerConfiguration() configuration.preferredAssetRepresentationMode = .current + configuration.selectionLimit = CompositionViewModel.maxAttachmentCount if !compositionViewModel.canAddNonImageAttachment { configuration.filter = .images + configuration.selectionLimit = CompositionViewModel.maxAttachmentCount + - compositionViewModel.attachmentViewModels.count + - compositionViewModel.attachmentUploadViewModels.count } let picker = PHPickerViewController(configuration: configuration) @@ -332,9 +334,9 @@ private extension NewStatusViewController { guard let self = self, let info = $0 else { return } if let url = info[.mediaURL] as? URL, let itemProvider = NSItemProvider(contentsOf: url) { - self.viewModel.attach(itemProvider: itemProvider, to: compositionViewModel) + self.viewModel.attach(itemProviders: [itemProvider], to: compositionViewModel) } else if let image = info[.editedImage] as? UIImage ?? info[.originalImage] as? UIImage { - self.viewModel.attach(itemProvider: NSItemProvider(object: image), to: compositionViewModel) + self.viewModel.attach(itemProviders: [NSItemProvider(object: image)], to: compositionViewModel) } } .store(in: &cancellables) @@ -356,22 +358,27 @@ private extension NewStatusViewController { #endif func presentDocumentPicker(compositionViewModel: CompositionViewModel) { - documentPickerResuls.first().sink { [weak self] in - guard let self = self, - let result = $0?.first, - result.startAccessingSecurityScopedResource(), - let itemProvider = NSItemProvider(contentsOf: result) - else { return } + documentPickerResults.first().sink { [weak self] in + guard let self = self, let results = $0 else { return } - self.viewModel.attach(itemProvider: itemProvider, to: compositionViewModel) - result.stopAccessingSecurityScopedResource() + let itemProviders = results.compactMap { result -> NSItemProvider? in + guard result.startAccessingSecurityScopedResource() else { return nil } + + return NSItemProvider(contentsOf: result) + } + + self.viewModel.attach(itemProviders: itemProviders, to: compositionViewModel) + + for result in results { + result.stopAccessingSecurityScopedResource() + } } .store(in: &cancellables) let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image, .movie, .audio]) documentPickerController.delegate = self - documentPickerController.allowsMultipleSelection = false + documentPickerController.allowsMultipleSelection = true documentPickerController.modalPresentationStyle = .overFullScreen present(documentPickerController, animated: true) } diff --git a/ViewModels/Sources/ViewModels/Entities/AttachmentUpload.swift b/ViewModels/Sources/ViewModels/Entities/AttachmentUpload.swift index d1e6b72..39c5eb2 100644 --- a/ViewModels/Sources/ViewModels/Entities/AttachmentUpload.swift +++ b/ViewModels/Sources/ViewModels/Entities/AttachmentUpload.swift @@ -1,9 +1,37 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Combine import Foundation +import Mastodon +import ServiceLayer -public struct AttachmentUpload: Hashable { - public let progress: Progress - public let data: Data - public let mimeType: String +public class AttachmentUploadViewModel: ObservableObject { + public let id = Id() + public let progress = Progress(totalUnitCount: 1) + public let parentViewModel: NewStatusViewModel + + let data: Data + let mimeType: String + var cancellable: AnyCancellable? + + init(data: Data, mimeType: String, parentViewModel: NewStatusViewModel) { + self.data = data + self.mimeType = mimeType + self.parentViewModel = parentViewModel + } +} + +public extension AttachmentUploadViewModel { + typealias Id = UUID + + func upload() -> AnyPublisher { + parentViewModel.identityContext.service.uploadAttachment( + data: data, + mimeType: mimeType, + progress: progress) + } + + func cancel() { + cancellable?.cancel() + } } diff --git a/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift index e3b7ba3..8d0cad1 100644 --- a/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/CompositionViewModel.swift @@ -22,7 +22,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab @Published public private(set) var contentWarningAutocompleteQuery: String? @Published public private(set) var pollOptions = [PollOption(text: ""), PollOption(text: "")] @Published public private(set) var attachmentViewModels = [AttachmentViewModel]() - @Published public private(set) var attachmentUpload: AttachmentUpload? + @Published public private(set) var attachmentUploadViewModels = [AttachmentUploadViewModel]() @Published public private(set) var isPostable = false @Published public private(set) var canAddAttachment = true @Published public private(set) var canAddNonImageAttachment = true @@ -30,7 +30,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab public let canRemoveAttachments = true private let eventsSubject: PassthroughSubject - private var attachmentUploadCancellable: AnyCancellable? + private var cancellables = Set() init(eventsSubject: PassthroughSubject) { self.eventsSubject = eventsSubject @@ -44,12 +44,17 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab .assign(to: &$isPostable) $attachmentViewModels - .combineLatest($attachmentUpload, $displayPoll) - .map { $0.count < Self.maxAttachmentCount && $1 == nil && !$2 } + .combineLatest($attachmentUploadViewModels, $displayPoll) + .map { $0.count < Self.maxAttachmentCount && $1.isEmpty && !$2 } .assign(to: &$canAddAttachment) $attachmentViewModels.map(\.isEmpty).assign(to: &$canAddNonImageAttachment) + $attachmentUploadViewModels + .compactMap(\.first) + .sink { [weak self] in self?.upload(viewModel: $0) } + .store(in: &cancellables) + $text.map { let tokens = $0.components(separatedBy: " ") @@ -83,6 +88,7 @@ public final class CompositionViewModel: AttachmentsRenderingViewModel, Observab public extension CompositionViewModel { static let maxCharacters = 500 + static let maxAttachmentCount = 4 static let minPollOptionCount = 2 static let maxPollOptionCount = 4 @@ -172,7 +178,7 @@ public extension CompositionViewModel { self.text = text } } else if let itemProvider = inputItem.attachments?.first { - attach(itemProvider: itemProvider, parentViewModel: parentViewModel) + attach(itemProviders: [itemProvider], parentViewModel: parentViewModel) } } @@ -197,39 +203,39 @@ public extension CompositionViewModel { pollOptions.removeAll { $0 === pollOption } } - func attach(itemProvider: NSItemProvider, parentViewModel: NewStatusViewModel) { - attachmentUploadCancellable = MediaProcessingService.dataAndMimeType(itemProvider: itemProvider) - .flatMap { [weak self] data, mimeType -> AnyPublisher in - guard let self = self else { return Empty().eraseToAnyPublisher() } - - let progress = Progress(totalUnitCount: 1) - - DispatchQueue.main.async { - self.attachmentUpload = AttachmentUpload(progress: progress, data: data, mimeType: mimeType) + func attach(itemProviders: [NSItemProvider], parentViewModel: NewStatusViewModel) { + Publishers.MergeMany(itemProviders.map { + MediaProcessingService.dataAndMimeType(itemProvider: $0) + .receive(on: DispatchQueue.main) + .assignErrorsToAlertItem(to: \.alertItem, on: parentViewModel) + .map { result in + AttachmentUploadViewModel( + data: result.data, + mimeType: result.mimeType, + parentViewModel: parentViewModel) } + }) + .collect() + .assign(to: &$attachmentUploadViewModels) + } - return parentViewModel.identityContext.service.uploadAttachment( - data: data, - mimeType: mimeType, - progress: progress) - } + func upload(viewModel: AttachmentUploadViewModel) { + viewModel.cancellable = viewModel.upload() .receive(on: DispatchQueue.main) - .assignErrorsToAlertItem(to: \.alertItem, on: parentViewModel) - .handleEvents(receiveCancel: { [weak self] in self?.attachmentUpload = nil }) + .assignErrorsToAlertItem(to: \.alertItem, on: viewModel.parentViewModel) + .handleEvents(receiveCancel: { [weak self] in + self?.attachmentUploadViewModels.removeAll { $0 === viewModel } + }) .sink { [weak self] _ in - self?.attachmentUpload = nil + self?.attachmentUploadViewModels.removeAll { $0 === viewModel } } receiveValue: { [weak self] in self?.attachmentViewModels.append( AttachmentViewModel( attachment: $0, - identityContext: parentViewModel.identityContext)) + identityContext: viewModel.parentViewModel.identityContext)) } } - func cancelUpload() { - attachmentUploadCancellable?.cancel() - } - func update(attachmentViewModel: AttachmentViewModel) { let publisher = attachmentViewModel.updated() .receive(on: DispatchQueue.main) @@ -259,7 +265,6 @@ public extension CompositionViewModel.PollOption { } private extension CompositionViewModel { - static let maxAttachmentCount = 4 static let autocompleteQueryRegularExpression = #"([@#:]\S+)\z"# static let emojiOnlyAutocompleteQueryRegularExpression = #"(:\S+)\z"# diff --git a/ViewModels/Sources/ViewModels/View Models/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/View Models/NewStatusViewModel.swift index 4dfa4d7..25849a0 100644 --- a/ViewModels/Sources/ViewModels/View Models/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/View Models/NewStatusViewModel.swift @@ -187,8 +187,8 @@ public extension NewStatusViewModel { } } - func attach(itemProvider: NSItemProvider, to compositionViewModel: CompositionViewModel) { - compositionViewModel.attach(itemProvider: itemProvider, parentViewModel: self) + func attach(itemProviders: [NSItemProvider], to compositionViewModel: CompositionViewModel) { + compositionViewModel.attach(itemProviders: itemProviders, parentViewModel: self) } func post() { diff --git a/Views/UIKit/AttachmentUploadView.swift b/Views/UIKit/AttachmentUploadView.swift index b4a3092..a32752c 100644 --- a/Views/UIKit/AttachmentUploadView.swift +++ b/Views/UIKit/AttachmentUploadView.swift @@ -9,12 +9,10 @@ final class AttachmentUploadView: UIView { let cancelButton = UIButton(type: .system) let progressView = UIProgressView(progressViewStyle: .default) - private let viewModel: CompositionViewModel - private var progressCancellable: AnyCancellable? + private let viewModel: AttachmentUploadViewModel private var cancellables = Set() - // swiftlint:disable:next function_body_length - init(viewModel: CompositionViewModel) { + init(viewModel: AttachmentUploadViewModel) { self.viewModel = viewModel super.init(frame: .zero) @@ -33,7 +31,7 @@ final class AttachmentUploadView: UIView { cancelButton.titleLabel?.adjustsFontForContentSizeCategory = true cancelButton.titleLabel?.font = .preferredFont(forTextStyle: .callout) cancelButton.setTitle(NSLocalizedString("cancel", comment: ""), for: .normal) - cancelButton.addAction(UIAction { _ in viewModel.cancelUpload() }, for: .touchUpInside) + cancelButton.addAction(UIAction { _ in viewModel.cancel() }, for: .touchUpInside) cancelButton.accessibilityLabel = NSLocalizedString("compose.attachment.cancel-upload.accessibility-label", comment: "") @@ -53,21 +51,10 @@ final class AttachmentUploadView: UIView { progressView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor) ]) - viewModel.$attachmentUpload.sink { [weak self] attachmentUpload in - guard let self = self else { return } - - UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) { - if let attachmentUpload = attachmentUpload { - self.progressCancellable = attachmentUpload.progress.publisher(for: \.fractionCompleted) - .receive(on: DispatchQueue.main) - .sink { self.progressView.progress = Float($0) } - self.isHidden = false - } else { - self.isHidden = true - } - } - } - .store(in: &cancellables) + viewModel.progress.publisher(for: \.fractionCompleted) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.progressView.progress = Float($0) } + .store(in: &cancellables) } @available(*, unavailable) @@ -75,3 +62,7 @@ final class AttachmentUploadView: UIView { fatalError("init(coder:) has not been implemented") } } + +extension AttachmentUploadView { + var id: AttachmentUploadViewModel.Id { viewModel.id } +} diff --git a/Views/UIKit/CompositionInputAccessoryView.swift b/Views/UIKit/CompositionInputAccessoryView.swift index 7c284c4..9f915bf 100644 --- a/Views/UIKit/CompositionInputAccessoryView.swift +++ b/Views/UIKit/CompositionInputAccessoryView.swift @@ -205,8 +205,8 @@ private extension CompositionInputAccessoryView { .store(in: &cancellables) viewModel.$attachmentViewModels - .combineLatest(viewModel.$attachmentUpload) - .sink { pollButton.isEnabled = $0.isEmpty && $1 == nil } + .combineLatest(viewModel.$attachmentUploadViewModels) + .sink { pollButton.isEnabled = $0.isEmpty && $1.isEmpty } .store(in: &cancellables) viewModel.$remainingCharacters.sink { diff --git a/Views/UIKit/CompositionView.swift b/Views/UIKit/CompositionView.swift index d5c30c9..48b3e1c 100644 --- a/Views/UIKit/CompositionView.swift +++ b/Views/UIKit/CompositionView.swift @@ -15,7 +15,7 @@ final class CompositionView: UIView { let inReplyToView = UIView() let hasReplyFollowingView = UIView() let attachmentsView = AttachmentsView() - let attachmentUploadView: AttachmentUploadView + let attachmentUploadsStackView = UIStackView() let pollView: CompositionPollView let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView @@ -27,7 +27,6 @@ final class CompositionView: UIView { self.viewModel = viewModel self.parentViewModel = parentViewModel - attachmentUploadView = AttachmentUploadView(viewModel: viewModel) markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel) pollView = CompositionPollView(viewModel: viewModel, parentViewModel: parentViewModel) @@ -128,8 +127,9 @@ private extension CompositionView { stackView.addArrangedSubview(attachmentsView) attachmentsView.isHidden_stackViewSafe = true - stackView.addArrangedSubview(attachmentUploadView) - attachmentUploadView.isHidden_stackViewSafe = true + stackView.addArrangedSubview(attachmentUploadsStackView) + attachmentUploadsStackView.axis = .vertical + attachmentUploadsStackView.isHidden_stackViewSafe = true stackView.addArrangedSubview(markAttachmentsSensitiveView) markAttachmentsSensitiveView.isHidden_stackViewSafe = true stackView.addArrangedSubview(pollView) @@ -227,10 +227,22 @@ private extension CompositionView { .sink { [weak self] in self?.textView.canPasteImage = $0 } .store(in: &cancellables) + viewModel.$attachmentUploadViewModels + .throttle(for: .seconds(TimeInterval.zeroIfReduceMotion(.shortAnimationDuration)), + scheduler: DispatchQueue.main, + latest: true) + .sink { [weak self] attachmentUploadViewModels in + UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) { + self?.attachmentUploadsStackView.isHidden_stackViewSafe = attachmentUploadViewModels.isEmpty + self?.update(attachmentUploadViewModels: attachmentUploadViewModels) + } + } + .store(in: &cancellables) + textView.pastedItemProviders.sink { [weak self] in guard let self = self else { return } - self.viewModel.attach(itemProvider: $0, + self.viewModel.attach(itemProviders: [$0], parentViewModel: self.parentViewModel) } .store(in: &cancellables) @@ -366,4 +378,29 @@ private extension CompositionView { range: textToSelectedRangeRange) spoilerTextFieldEditingChanged() } + + func update(attachmentUploadViewModels: [AttachmentUploadViewModel]) { + let diff = attachmentUploadViewModels.map(\.id) + .difference(from: attachmentUploadsStackView + .arrangedSubviews + .compactMap { ($0 as? AttachmentUploadView)?.id }) + + for insertion in diff.insertions { + guard case let .insert(index, id, _) = insertion, + let attachmentUploadViewModel = attachmentUploadViewModels.first(where: { $0.id == id }) + else { continue } + + let attachmentUploadView = AttachmentUploadView(viewModel: attachmentUploadViewModel) + + attachmentUploadsStackView.insertArrangedSubview(attachmentUploadView, at: index) + } + + for removal in diff.removals { + guard case let .remove(_, id, _) = removal, + let index = attachmentUploadsStackView.arrangedSubviews.firstIndex(where: { ($0 as? AttachmentUploadView)?.id == id }) + else { continue } + + attachmentUploadsStackView.arrangedSubviews[index].removeFromSuperview() + } + } }