diff --git a/DB/Sources/DB/Entities/Composition.swift b/DB/Sources/DB/Entities/Composition.swift index fb5e8d0..977cf0f 100644 --- a/DB/Sources/DB/Entities/Composition.swift +++ b/DB/Sources/DB/Entities/Composition.swift @@ -19,20 +19,6 @@ public class Composition { public extension Composition { typealias Id = UUID - - struct Attachment { - public let data: Data - public let type: Mastodon.Attachment.AttachmentType - public let mimeType: String - public var description: String? - public var focus: Mastodon.Attachment.Meta.Focus? - - public init(data: Data, type: Mastodon.Attachment.AttachmentType, mimeType: String) { - self.data = data - self.type = type - self.mimeType = mimeType - } - } } extension Composition { diff --git a/Data Sources/CompositionAttachmentsDataSource.swift b/Data Sources/CompositionAttachmentsDataSource.swift new file mode 100644 index 0000000..bc6d786 --- /dev/null +++ b/Data Sources/CompositionAttachmentsDataSource.swift @@ -0,0 +1,13 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Mastodon +import UIKit +import ViewModels + +final class CompositionAttachmentsDataSource: UICollectionViewDiffableDataSource { +// init(collectionView: UICollectionView, composition: Composition) { +// super.init(collectionView: collectionView) { collectionView, indexPath, attachment in +// +// } +// } +} diff --git a/HTTP/Sources/HTTP/HTTPClient.swift b/HTTP/Sources/HTTP/HTTPClient.swift index dc98d8e..4331a05 100644 --- a/HTTP/Sources/HTTP/HTTPClient.swift +++ b/HTTP/Sources/HTTP/HTTPClient.swift @@ -19,14 +19,14 @@ open class HTTPClient { } open func dataTaskPublisher( - _ target: T) -> AnyPublisher<(data: Data, response: HTTPURLResponse), Error> { + _ target: T, progress: Progress? = nil) -> AnyPublisher<(data: Data, response: HTTPURLResponse), Error> { if let protocolClasses = session.configuration.protocolClasses { for protocolClass in protocolClasses { (protocolClass as? TargetProcessing.Type)?.process(target: target) } } - return session.dataTaskPublisher(for: target.urlRequest()) + return session.dataTaskPublisher(for: target.urlRequest(), progress: progress) .tryMap { data, response in guard let httpResponse = response as? HTTPURLResponse else { throw HTTPError.nonHTTPURLResponse(data: data, response: response) @@ -41,8 +41,8 @@ open class HTTPClient { .eraseToAnyPublisher() } - open func request(_ target: T) -> AnyPublisher { - dataTaskPublisher(target) + open func request(_ target: T, progress: Progress? = nil) -> AnyPublisher { + dataTaskPublisher(target, progress: progress) .map(\.data) .decode(type: T.ResultType.self, decoder: decoder) .eraseToAnyPublisher() diff --git a/HTTP/Sources/HTTP/URLSession+Extensions.swift b/HTTP/Sources/HTTP/URLSession+Extensions.swift new file mode 100644 index 0000000..c8766de --- /dev/null +++ b/HTTP/Sources/HTTP/URLSession+Extensions.swift @@ -0,0 +1,29 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation + +extension URLSession { + func dataTaskPublisher(for request: URLRequest, progress: Progress?) + -> AnyPublisher { + if let progress = progress { + return Deferred { + Future { promise in + let dataTask = self.dataTask(with: request) { data, response, error in + if let error = error { + promise(.failure(error)) + } else if let data = data, let response = response { + promise(.success((data, response))) + } + } + + progress.addChild(dataTask.progress, withPendingUnitCount: 1) + dataTask.resume() + } + } + .eraseToAnyPublisher() + } else { + return dataTaskPublisher(for: request).mapError { $0 as Error }.eraseToAnyPublisher() + } + } +} diff --git a/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift b/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift index cf0745d..beb434a 100644 --- a/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift +++ b/MastodonAPI/Sources/MastodonAPI/MastodonAPIClient.swift @@ -15,8 +15,8 @@ public final class MastodonAPIClient: HTTPClient { } public override func dataTaskPublisher( - _ target: T) -> AnyPublisher<(data: Data, response: HTTPURLResponse), Error> { - super.dataTaskPublisher(target) + _ target: T, progress: Progress? = nil) -> AnyPublisher<(data: Data, response: HTTPURLResponse), Error> { + super.dataTaskPublisher(target, progress: progress) .mapError { [weak self] error -> Error in if case let HTTPError.invalidStatusCode(data, _) = error, let apiError = try? self?.decoder.decode(APIError.self, from: data) { @@ -30,8 +30,8 @@ public final class MastodonAPIClient: HTTPClient { } extension MastodonAPIClient { - public func request(_ endpoint: E) -> AnyPublisher { - dataTaskPublisher(target(endpoint: endpoint)) + public func request(_ endpoint: E, progress: Progress? = nil) -> AnyPublisher { + dataTaskPublisher(target(endpoint: endpoint), progress: progress) .map(\.data) .decode(type: E.ResultType.self, decoder: decoder) .eraseToAnyPublisher() @@ -42,9 +42,10 @@ extension MastodonAPIClient { maxId: String? = nil, minId: String? = nil, sinceId: String? = nil, - limit: Int? = nil) -> AnyPublisher, Error> { + limit: Int? = nil, + progress: Progress? = nil) -> AnyPublisher, Error> { let pagedTarget = target(endpoint: Paged(endpoint, maxId: maxId, minId: minId, sinceId: sinceId, limit: limit)) - let dataTask = dataTaskPublisher(pagedTarget).share() + let dataTask = dataTaskPublisher(pagedTarget, progress: progress).share() let decoded = dataTask.map(\.data).decode(type: E.ResultType.self, decoder: decoder) let info = dataTask.map { _, response -> PagedResult.Info in var maxId: String? diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index c2a1f77..7d6bf8f 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -27,6 +27,10 @@ D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D04226FC2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift */; }; D0625E59250F092900502611 /* StatusListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E58250F092900502611 /* StatusListCell.swift */; }; D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */; }; + D065965B25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065965A25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift */; }; + D065966125899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065966025899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift */; }; + D065966225899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065966025899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift */; }; + D065966725899E910096AC5D /* CompositionAttachmentsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D065965A25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift */; }; D06B492324D4611300642749 /* KingfisherSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = D06B492224D4611300642749 /* KingfisherSwiftUI */; }; D06BC5E625202AD90079541D /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D06BC5E525202AD90079541D /* ProfileViewController.swift */; }; D08B8D3D253F929E00B1EBEF /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D08B8D3C253F929E00B1EBEF /* ImageViewController.swift */; }; @@ -91,6 +95,8 @@ D0C7D4D724F7616A001EBDBB /* UIColor+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */; }; D0C7D4D924F7616A001EBDBB /* KingfisherOptionsInfo+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */; }; D0C7D4DA24F7616A001EBDBB /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0C7D46F24F76169001EBDBB /* View+Extensions.swift */; }; + D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; }; + D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */; }; D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0DD50CA256B1F24004A04F7 /* ReportView.swift */; }; D0E1F583251F13EC00D45315 /* WebfingerIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E1F582251F13EC00D45315 /* WebfingerIndicatorView.swift */; }; D0E2C1D124FD97F000854680 /* ViewModels in Frameworks */ = {isa = PBXBuildFile; productRef = D0E2C1D024FD97F000854680 /* ViewModels */; }; @@ -180,6 +186,8 @@ D047FA8C24C3E21200AF17C5 /* Metatext.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metatext.app; sourceTree = BUILT_PRODUCTS_DIR; }; D0625E58250F092900502611 /* StatusListCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusListCell.swift; sourceTree = ""; }; D0625E5C250F0B5C00502611 /* StatusContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentConfiguration.swift; sourceTree = ""; }; + D065965A25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentsDataSource.swift; sourceTree = ""; }; + D065966025899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachmentCollectionViewCell.swift; sourceTree = ""; }; D0666A2124C677B400F3F04B /* Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D0666A2524C677B400F3F04B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; D06BC5E525202AD90079541D /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -245,6 +253,7 @@ D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIColor+Extensions.swift"; sourceTree = ""; }; D0C7D46E24F76169001EBDBB /* KingfisherOptionsInfo+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "KingfisherOptionsInfo+Extensions.swift"; sourceTree = ""; }; D0C7D46F24F76169001EBDBB /* View+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = ""; }; + D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentUploadView.swift; sourceTree = ""; }; D0D7C013250440610039AD6F /* CodableBloomFilter */ = {isa = PBXFileReference; lastKnownFileType = folder; path = CodableBloomFilter; sourceTree = ""; }; D0DD50CA256B1F24004A04F7 /* ReportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportView.swift; sourceTree = ""; }; D0E0F1E424FC49FC002C04BF /* Mastodon */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Mastodon; sourceTree = ""; }; @@ -410,6 +419,7 @@ children = ( D0A1F4F6252E7D4B004435BF /* TableViewDataSource.swift */, D0F2D4D0257EE84400986197 /* NewStatusDataSource.swift */, + D065965A25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift */, ); path = "Data Sources"; sourceTree = ""; @@ -432,6 +442,8 @@ D0F0B125251A90F400942152 /* AccountListCell.swift */, D0F0B10D251A868200942152 /* AccountView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, + D0CE9F86258B076900E3A6B6 /* AttachmentUploadView.swift */, + D065966025899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift */, D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */, D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */, D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */, @@ -772,6 +784,7 @@ D0F0B12E251A97E400942152 /* TableViewController.swift in Sources */, D0DD50CB256B1F24004A04F7 /* ReportView.swift in Sources */, D0FE1C8F253686F9003EF1EB /* PlayerView.swift in Sources */, + D0CE9F87258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */, D0F0B113251A86A000942152 /* AccountContentConfiguration.swift in Sources */, D036AA02254B6101009094DF /* NotificationListCell.swift in Sources */, D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */, @@ -806,6 +819,7 @@ D036AA17254CA824009094DF /* StatusBodyView.swift in Sources */, D08E512125786A6600FA2C5F /* UIButton+Extensions.swift in Sources */, D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */, + D065965B25899DAE0096AC5D /* CompositionAttachmentsDataSource.swift in Sources */, D04226FD2546AC0B000980A3 /* StartupAndSyncingPreferencesView.swift in Sources */, D036AA0C254B612B009094DF /* NotificationContentConfiguration.swift in Sources */, D0C7D49824F7616A001EBDBB /* CustomEmojiText.swift in Sources */, @@ -814,6 +828,7 @@ D08E52612579D2E100FA2C5F /* DomainBlocksView.swift in Sources */, D01F41E424F8889700D55A2D /* StatusAttachmentsView.swift in Sources */, D00702312555F4AE00F38136 /* ConversationView.swift in Sources */, + D065966125899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift in Sources */, D0BEB21124FA2A91001B0F04 /* EditFilterView.swift in Sources */, D08B8D4A253FC36500B1EBEF /* ImageNavigationController.swift in Sources */, D0070252255921B100F38136 /* AccountFieldView.swift in Sources */, @@ -851,12 +866,15 @@ D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */, D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */, D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */, + D0CE9F88258B076900E3A6B6 /* AttachmentUploadView.swift in Sources */, D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */, D08E52F8257D78BE00FA2C5F /* ViewConstants.swift in Sources */, D0F2D4D6257EED6100986197 /* NewStatusDataSource.swift in Sources */, + D065966725899E910096AC5D /* CompositionAttachmentsDataSource.swift in Sources */, D08E52FD257D78CB00FA2C5F /* UIColor+Extensions.swift in Sources */, D08E52E4257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */, + D065966225899E890096AC5D /* CompositionAttachmentCollectionViewCell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 757cc19..474512c 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -206,17 +206,26 @@ public extension IdentityService { .eraseToAnyPublisher() } + func uploadAttachment(data: Data, mimeType: String, progress: Progress) -> AnyPublisher { + mastodonAPIClient.request( + AttachmentEndpoint.create(data: data, mimeType: mimeType, description: nil, focus: nil), + progress: progress) + } + func post(compositions: [Composition]) -> AnyPublisher { - guard let composition = compositions.first else { fatalError() } - guard let attachment = composition.attachments.first else { fatalError() } - return mastodonAPIClient.request(AttachmentEndpoint.create( - data: attachment.data, - mimeType: attachment.mimeType, - description: attachment.description, - focus: attachment.focus)) - .print() - .ignoreOutput() - .eraseToAnyPublisher() + fatalError() +// guard let composition = compositions.first else { fatalError() } + +// guard let attachment = composition.attachments.first else { fatalError() } +// return mastodonAPIClient.request(AttachmentEndpoint.create( +// data: attachment.data, +// mimeType: attachment.mimeType, +// description: attachment.description, +// focus: attachment.focus)) +// .print() +// .ignoreOutput() +// .eraseToAnyPublisher() + // var components = StatusEndpoint.Components() // // if !composition.text.isEmpty { diff --git a/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift b/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift index 38b4ba1..706429e 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift @@ -3,7 +3,6 @@ import Combine import Foundation import ImageIO -import Mastodon import UniformTypeIdentifiers enum MediaProcessingError: Error { @@ -18,32 +17,18 @@ enum MediaProcessingError: Error { public struct MediaProcessingService {} public extension MediaProcessingService { - static func attachment(itemProvider: NSItemProvider) -> AnyPublisher { + static func dataAndMimeType(itemProvider: NSItemProvider) -> AnyPublisher<(data: Data, mimeType: String), Error> { let registeredTypes = itemProvider.registeredTypeIdentifiers.compactMap(UTType.init) guard let uniformType = registeredTypes.first(where: { guard let mimeType = $0.preferredMIMEType else { return false } - return !Self.unuploadableMimeTypes.contains(mimeType) + return Self.uploadableMimeTypes.contains(mimeType) }), let mimeType = uniformType.preferredMIMEType else { return Fail(error: MediaProcessingError.invalidMimeType).eraseToAnyPublisher() } - let type: Attachment.AttachmentType - - if uniformType.conforms(to: .image) { - type = .image - } else if uniformType.conforms(to: .movie) { - type = .video - } else if uniformType.conforms(to: .audio) { - type = .audio - } else if uniformType.conforms(to: .video), uniformType == .mpeg4Movie { - type = .gifv - } else { - type = .unknown - } - return Future { promise in itemProvider.loadFileRepresentation(forTypeIdentifier: uniformType.identifier) { url, error in if let error = error { @@ -63,13 +48,23 @@ public extension MediaProcessingService { } } } - .map { Composition.Attachment(data: $0, type: type, mimeType: mimeType) } + .map { (data: $0, mimeType: mimeType) } .eraseToAnyPublisher() } } private extension MediaProcessingService { - static let unuploadableMimeTypes: Set = [UTType.heic.preferredMIMEType!] + + static let uploadableMimeTypes = Set( + [UTType.png, + UTType.jpeg, + UTType.gif, + UTType.webP, + UTType.mpeg4Movie, + UTType.quickTimeMovie, + UTType.mp3, + UTType.wav] + .compactMap(\.preferredMIMEType)) static let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary static let thumbnailOptions = [ kCGImageSourceCreateThumbnailFromImageAlways: true, diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 494a65d..02b47bc 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -73,9 +73,14 @@ class NewStatusViewController: UICollectionViewController { } .store(in: &cancellables) + // Invalidate the collection view layout on anything that could change the height of a cell viewModel.$compositionViewModels .flatMap { Publishers.MergeMany($0.map(\.composition.$text)) } - .sink { [weak self] _ in self?.collectionView.collectionViewLayout.invalidateLayout() } + .map { _ in () } + .merge(with: viewModel.$compositionViewModels + .flatMap { Publishers.MergeMany($0.map(\.objectWillChange)) } + .map { _ in () }) + .sink { [weak self] in self?.collectionView.collectionViewLayout.invalidateLayout() } .store(in: &cancellables) viewModel.$canPost.sink { [weak self] in self?.postButton.isEnabled = $0 }.store(in: &cancellables) diff --git a/ViewModels/Sources/ViewModels/CompositionAttachmentViewModel.swift b/ViewModels/Sources/ViewModels/CompositionAttachmentViewModel.swift new file mode 100644 index 0000000..3ecdc67 --- /dev/null +++ b/ViewModels/Sources/ViewModels/CompositionAttachmentViewModel.swift @@ -0,0 +1,14 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import Mastodon +import ServiceLayer + +public final class CompositionAttachmentViewModel: ObservableObject { + public var attachment: Attachment + + init(attachment: Attachment) { + self.attachment = attachment + } +} diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift index 9482bef..b2897c5 100644 --- a/ViewModels/Sources/ViewModels/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -9,8 +9,10 @@ public final class CompositionViewModel: ObservableObject { public let composition: Composition @Published public private(set) var isPostable = false @Published public private(set) var identification: Identification + @Published public private(set) var attachmentUpload: AttachmentUpload? private let eventsSubject: PassthroughSubject + private var cancellables = Set() init(composition: Composition, identification: Identification, @@ -28,7 +30,13 @@ public extension CompositionViewModel { enum Event { case insertAfter(CompositionViewModel) case presentMediaPicker(CompositionViewModel) - case attach(itemProvider: NSItemProvider, viewModel: CompositionViewModel) + case error(Error) + } + + struct AttachmentUpload { + public let progress: Progress + public let data: Data + public let mimeType: String } func presentMediaPicker() { @@ -40,6 +48,30 @@ public extension CompositionViewModel { } func attach(itemProvider: NSItemProvider) { - eventsSubject.send(.attach(itemProvider: itemProvider, viewModel: self)) + let progress = Progress(totalUnitCount: 1) + + MediaProcessingService.dataAndMimeType(itemProvider: itemProvider) + .flatMap { [weak self] data, mimeType -> AnyPublisher in + guard let self = self else { return Empty().eraseToAnyPublisher() } + + DispatchQueue.main.async { + self.attachmentUpload = AttachmentUpload(progress: progress, data: data, mimeType: mimeType) + } + + return self.identification.service.uploadAttachment(data: data, mimeType: mimeType, progress: progress) + } + .print() + .sink { [weak self] in + DispatchQueue.main.async { + self?.attachmentUpload = nil + } + + if case let .failure(error) = $0 { + self?.eventsSubject.send(.error(error)) + } + } receiveValue: { [weak self] in + self?.composition.attachments.append($0) + } + .store(in: &cancellables) } } diff --git a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift index 938f51b..867f78e 100644 --- a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift @@ -99,11 +99,8 @@ private extension NewStatusViewModel { } else { compositionViewModels.insert(newViewModel, at: index + 1) } - case let .attach(itemProvider, viewModel): - MediaProcessingService.attachment(itemProvider: itemProvider) - .assignErrorsToAlertItem(to: \.alertItem, on: self) - .sink { viewModel.composition.attachments.append($0) } - .store(in: &cancellables) + case let .error(error): + alertItem = AlertItem(error: error) default: eventsSubject.send(event) } diff --git a/Views/AttachmentUploadView.swift b/Views/AttachmentUploadView.swift new file mode 100644 index 0000000..701ca16 --- /dev/null +++ b/Views/AttachmentUploadView.swift @@ -0,0 +1,41 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import UIKit +import ViewModels + +final class AttachmentUploadView: UIView { + let progressView = UIProgressView(progressViewStyle: .default) + private var progressCancellable: AnyCancellable? + + var attachmentUpload: CompositionViewModel.AttachmentUpload? { + didSet { + if let attachmentUpload = attachmentUpload { + progressCancellable = attachmentUpload.progress.publisher(for: \.fractionCompleted) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.progressView.progress = Float($0) } + isHidden = false + } else { + isHidden = true + } + } + } + + init() { + super.init(frame: .zero) + + addSubview(progressView) + progressView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + progressView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + progressView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + progressView.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Views/CompositionAttachmentCollectionViewCell.swift b/Views/CompositionAttachmentCollectionViewCell.swift new file mode 100644 index 0000000..3aaee27 --- /dev/null +++ b/Views/CompositionAttachmentCollectionViewCell.swift @@ -0,0 +1,7 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit + +class CompositionAttachmentCollectionViewCell: UICollectionViewCell { + +} diff --git a/Views/CompositionView.swift b/Views/CompositionView.swift index 1f5630b..357e8c5 100644 --- a/Views/CompositionView.swift +++ b/Views/CompositionView.swift @@ -7,6 +7,8 @@ import UIKit class CompositionView: UIView { let avatarImageView = UIImageView() let textView = UITextView() + let attachmentUploadView = AttachmentUploadView() +// let attachmentsCollectionView = UICollectionView() private var compositionConfiguration: CompositionContentConfiguration private var cancellables = Set() @@ -46,6 +48,8 @@ extension CompositionView: UITextViewDelegate { } private extension CompositionView { + static let attachmentsCollectionViewHeight: CGFloat = 100 + func initialSetup() { addSubview(avatarImageView) avatarImageView.translatesAutoresizingMaskIntoConstraints = false @@ -67,6 +71,10 @@ private extension CompositionView { textView.inputAccessoryView?.sizeToFit() textView.delegate = self +// stackView.addArrangedSubview(attachmentsCollectionView) + + stackView.addArrangedSubview(attachmentUploadView) + let constraints = [ avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension), avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension), @@ -76,7 +84,9 @@ private extension CompositionView { stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing), stackView.topAnchor.constraint(equalTo: readableContentGuide.topAnchor), stackView.trailingAnchor.constraint(equalTo: readableContentGuide.trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor) + stackView.bottomAnchor.constraint(equalTo: readableContentGuide.bottomAnchor), +// attachmentsCollectionView.heightAnchor.constraint(equalToConstant: Self.attachmentsCollectionViewHeight) + attachmentUploadView.heightAnchor.constraint(equalToConstant: Self.attachmentsCollectionViewHeight) ] for constraint in constraints { @@ -88,6 +98,10 @@ private extension CompositionView { compositionConfiguration.viewModel.$identification.map(\.identity.image) .sink { [weak self] in self?.avatarImageView.kf.setImage(with: $0) } .store(in: &cancellables) + + compositionConfiguration.viewModel.$attachmentUpload + .sink { [weak self] in self?.attachmentUploadView.attachmentUpload = $0 } + .store(in: &cancellables) } func applyCompositionConfiguration() {