mirror of
https://github.com/metabolist/metatext.git
synced 2024-12-19 20:26:28 +00:00
Select multiple images for upload at once
This commit is contained in:
parent
d0709f718c
commit
1b95b493a1
7 changed files with 147 additions and 79 deletions
|
@ -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<AnyCancellable>()
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -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<Attachment, Error> {
|
||||
parentViewModel.identityContext.service.uploadAttachment(
|
||||
data: data,
|
||||
mimeType: mimeType,
|
||||
progress: progress)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancellable?.cancel()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Event, Never>
|
||||
private var attachmentUploadCancellable: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(eventsSubject: PassthroughSubject<Event, Never>) {
|
||||
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<Attachment, Error> 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"#
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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<AnyCancellable>()
|
||||
|
||||
// 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 }
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue