From d7c73ee06d10a0ab0030554d4227b28ad51c8631 Mon Sep 17 00:00:00 2001 From: Justin Mazzocchi <2831158+jzzocc@users.noreply.github.com> Date: Tue, 15 Dec 2020 17:39:38 -0800 Subject: [PATCH] Posting media WIP --- DB/Sources/DB/Entities/Composition.swift | 20 +++- Data Sources/NewStatusDataSource.swift | 11 ++ Extensions/UIVIewController+Extensions.swift | 19 ++++ HTTP/Sources/HTTP/MultipartFormValue.swift | 27 +++++ HTTP/Sources/HTTP/Target.swift | 12 +++ Localizations/Localizable.strings | 1 + .../Sources/MastodonAPI/Endpoint.swift | 3 + .../Endpoints/AttachmentEndpoint.swift | 50 +++++++++ .../Endpoints/StatusEndpoint.swift | 32 +++++- .../MastodonAPI/MastodonAPITarget.swift | 2 + Metatext.xcodeproj/project.pbxproj | 12 +++ .../Services/IdentityService.swift | 22 ++++ .../Services/InstanceURLService.swift | 1 + .../Services/MediaProcessingService.swift | 100 ++++++++++++++++++ .../NewStatusViewController.swift | 96 ++++++++++++----- .../ViewModels/CompositionViewModel.swift | 28 ++++- .../ViewModels/NewStatusViewModel.swift | 61 ++++++++++- Views/CompositionInputAccessoryView.swift | 84 +++++++++++++++ Views/CompositionView.swift | 9 ++ 19 files changed, 557 insertions(+), 33 deletions(-) create mode 100644 Extensions/UIVIewController+Extensions.swift create mode 100644 HTTP/Sources/HTTP/MultipartFormValue.swift create mode 100644 MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift create mode 100644 ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift create mode 100644 Views/CompositionInputAccessoryView.swift diff --git a/DB/Sources/DB/Entities/Composition.swift b/DB/Sources/DB/Entities/Composition.swift index 4715021..fb5e8d0 100644 --- a/DB/Sources/DB/Entities/Composition.swift +++ b/DB/Sources/DB/Entities/Composition.swift @@ -1,20 +1,38 @@ // Copyright © 2020 Metabolist. All rights reserved. +import Combine import Foundation import GRDB +import Mastodon public class Composition { public let id: Id - public var text: String + @Published public var text: String + @Published public var attachments: [Attachment] public init(id: Id, text: String) { self.id = id self.text = text + attachments = [] } } 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/NewStatusDataSource.swift b/Data Sources/NewStatusDataSource.swift index 075894e..b44e8b0 100644 --- a/Data Sources/NewStatusDataSource.swift +++ b/Data Sources/NewStatusDataSource.swift @@ -4,6 +4,9 @@ import UIKit import ViewModels final class NewStatusDataSource: UICollectionViewDiffableDataSource { + private let updateQueue = + DispatchQueue(label: "com.metabolist.metatext.new-status-data-source.update-queue") + init(collectionView: UICollectionView, viewModelProvider: @escaping (IndexPath) -> CompositionViewModel) { let registration = UICollectionView.CellRegistration { $0.viewModel = $2 @@ -16,4 +19,12 @@ final class NewStatusDataSource: UICollectionViewDiffableDataSource, + animatingDifferences: Bool = true, + completion: (() -> Void)? = nil) { + updateQueue.async { + super.apply(snapshot, animatingDifferences: animatingDifferences, completion: completion) + } + } } diff --git a/Extensions/UIVIewController+Extensions.swift b/Extensions/UIVIewController+Extensions.swift new file mode 100644 index 0000000..1804419 --- /dev/null +++ b/Extensions/UIVIewController+Extensions.swift @@ -0,0 +1,19 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import UIKit +import ViewModels + +extension UIViewController { + func present(alertItem: AlertItem) { + let alertController = UIAlertController( + title: nil, + message: alertItem.error.localizedDescription, + preferredStyle: .alert) + + let okAction = UIAlertAction(title: NSLocalizedString("ok", comment: ""), style: .default) { _ in } + + alertController.addAction(okAction) + + present(alertController, animated: true) + } +} diff --git a/HTTP/Sources/HTTP/MultipartFormValue.swift b/HTTP/Sources/HTTP/MultipartFormValue.swift new file mode 100644 index 0000000..3b37be9 --- /dev/null +++ b/HTTP/Sources/HTTP/MultipartFormValue.swift @@ -0,0 +1,27 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation + +public enum MultipartFormValue { + case string(String) + case data(Data, filename: String, mimeType: String) +} + +extension MultipartFormValue { + func httpBodyComponent(boundary: String, key: String) -> Data { + switch self { + case let .string(value): + return Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(key)\"\r\n\r\n\(value)\r\n".utf8) + case let .data(data, filename, mimeType): + var component = Data() + + component.append(Data("--\(boundary)\r\n".utf8)) + component.append(Data("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(filename)\"\r\n".utf8)) + component.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8)) + component.append(data) + component.append(Data("\r\n".utf8)) + + return component + } + } +} diff --git a/HTTP/Sources/HTTP/Target.swift b/HTTP/Sources/HTTP/Target.swift index eecd25d..53c4b95 100644 --- a/HTTP/Sources/HTTP/Target.swift +++ b/HTTP/Sources/HTTP/Target.swift @@ -8,6 +8,7 @@ public protocol Target { var method: HTTPMethod { get } var queryParameters: [URLQueryItem] { get } var jsonBody: [String: Any]? { get } + var multipartFormData: [String: MultipartFormValue]? { get } var headers: [String: String]? { get } } @@ -35,6 +36,17 @@ public extension Target { if let jsonBody = jsonBody { urlRequest.httpBody = try? JSONSerialization.data(withJSONObject: jsonBody) urlRequest.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + } else if let multipartFormData = multipartFormData { + let boundary = "Boundary-\(UUID().uuidString)" + var httpBody = Data() + + for (key, value) in multipartFormData { + httpBody.append(value.httpBodyComponent(boundary: boundary, key: key)) + } + + httpBody.append(Data("--\(boundary)--".utf8)) + urlRequest.httpBody = httpBody + urlRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") } return urlRequest diff --git a/Localizations/Localizable.strings b/Localizations/Localizable.strings index b7f7952..e84e13c 100644 --- a/Localizations/Localizable.strings +++ b/Localizations/Localizable.strings @@ -58,6 +58,7 @@ "messages" = "Messages"; "ok" = "OK"; "pending.pending-confirmation" = "Your account is pending confirmation"; +"post" = "Post"; "preferences" = "Preferences"; "preferences.app" = "App Preferences"; "preferences.blocked-domains" = "Blocked Domains"; diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoint.swift index baaa55e..111abf7 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoint.swift @@ -11,6 +11,7 @@ public protocol Endpoint { var method: HTTPMethod { get } var queryParameters: [URLQueryItem] { get } var jsonBody: [String: Any]? { get } + var multipartFormData: [String: MultipartFormValue]? { get } var headers: [String: String]? { get } } @@ -33,5 +34,7 @@ public extension Endpoint { var jsonBody: [String: Any]? { nil } + var multipartFormData: [String: MultipartFormValue]? { nil } + var headers: [String: String]? { nil } } diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift new file mode 100644 index 0000000..8683d85 --- /dev/null +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/AttachmentEndpoint.swift @@ -0,0 +1,50 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Foundation +import HTTP +import Mastodon + +public enum AttachmentEndpoint { + case create(data: Data, mimeType: String, description: String?, focus: Attachment.Meta.Focus?) +} + +extension AttachmentEndpoint: Endpoint { + public typealias ResultType = Attachment + + public var context: [String] { + defaultContext + ["media"] + } + + public var pathComponentsInContext: [String] { + switch self { + case .create: + return [] + } + } + + public var multipartFormData: [String: MultipartFormValue]? { + switch self { + case let .create(data, mimeType, description, focus): + var params = [String: MultipartFormValue]() + + params["file"] = .data(data, filename: UUID().uuidString, mimeType: mimeType) + + if let description = description { + params["description"] = .string(description) + } + + if let focus = focus { + params["focus"] = .string("\(focus.x),\(focus.y)") + } + + return params + } + } + + public var method: HTTPMethod { + switch self { + case .create: + return .post + } + } +} diff --git a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift index 7d3e61a..0062ab0 100644 --- a/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift +++ b/MastodonAPI/Sources/MastodonAPI/Endpoints/StatusEndpoint.swift @@ -10,6 +10,25 @@ public enum StatusEndpoint { case unfavourite(id: Status.Id) case bookmark(id: Status.Id) case unbookmark(id: Status.Id) + case post(Components) +} + +public extension StatusEndpoint { + struct Components { + public var text: String? + + public init() {} + } +} + +extension StatusEndpoint.Components { + var jsonBody: [String: Any]? { + var params = [String: Any]() + + params["status"] = text + + return params + } } extension StatusEndpoint: Endpoint { @@ -31,6 +50,17 @@ extension StatusEndpoint: Endpoint { return [id, "bookmark"] case let .unbookmark(id): return [id, "unbookmark"] + case .post: + return [] + } + } + + public var jsonBody: [String: Any]? { + switch self { + case let .post(components): + return components.jsonBody + default: + return nil } } @@ -38,7 +68,7 @@ extension StatusEndpoint: Endpoint { switch self { case .status: return .get - case .favourite, .unfavourite, .bookmark, .unbookmark: + case .favourite, .unfavourite, .bookmark, .unbookmark, .post: return .post } } diff --git a/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift b/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift index 98c4cc2..5deb4b5 100644 --- a/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift +++ b/MastodonAPI/Sources/MastodonAPI/MastodonAPITarget.swift @@ -26,6 +26,8 @@ extension MastodonAPITarget: DecodableTarget { public var jsonBody: [String: Any]? { endpoint.jsonBody } + public var multipartFormData: [String: MultipartFormValue]? { endpoint.multipartFormData } + public var headers: [String: String]? { var headers = endpoint.headers diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 48e1b2c..c2a1f77 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -98,6 +98,10 @@ D0E5362024E3EB4D00FB1CE1 /* Notification Service Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = D0E5361924E3EB4D00FB1CE1 /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DA2529319100FA1D72 /* LoadMoreView.swift */; }; D0E569E0252931B100FA1D72 /* LoadMoreContentConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */; }; + D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */; }; + D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */; }; + D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */; }; + D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */; }; D0EA59402522AC8700804347 /* CardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA593F2522AC8700804347 /* CardView.swift */; }; D0EA59482522B8B600804347 /* ViewConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EA59472522B8B600804347 /* ViewConstants.swift */; }; D0F0B10E251A868200942152 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F0B10D251A868200942152 /* AccountView.swift */; }; @@ -252,6 +256,8 @@ D0E5362824E4A06B00FB1CE1 /* Notification Service Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Notification Service Extension.entitlements"; sourceTree = ""; }; D0E569DA2529319100FA1D72 /* LoadMoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreView.swift; sourceTree = ""; }; D0E569DF252931B100FA1D72 /* LoadMoreContentConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadMoreContentConfiguration.swift; sourceTree = ""; }; + D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIVIewController+Extensions.swift"; sourceTree = ""; }; + D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CompositionInputAccessoryView.swift; sourceTree = ""; }; D0EA593F2522AC8700804347 /* CardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardView.swift; sourceTree = ""; }; D0EA59472522B8B600804347 /* ViewConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewConstants.swift; sourceTree = ""; }; D0F0B10D251A868200942152 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; @@ -427,6 +433,7 @@ D0F0B10D251A868200942152 /* AccountView.swift */, D0C7D42424F76169001EBDBB /* AddIdentityView.swift */, D08E52E2257D747400FA2C5F /* CompositionContentConfiguration.swift */, + D0E9F9A9258450B300EF503D /* CompositionInputAccessoryView.swift */, D08E52DB257D742B00FA2C5F /* CompositionListCell.swift */, D08E52ED257D757100FA2C5F /* CompositionView.swift */, D007023D25562A2800F38136 /* ConversationAvatarsView.swift */, @@ -514,6 +521,7 @@ D0C7D46A24F76169001EBDBB /* String+Extensions.swift */, D08E512025786A6600FA2C5F /* UIButton+Extensions.swift */, D0C7D46C24F76169001EBDBB /* UIColor+Extensions.swift */, + D0E7AD3825870B13005F5E2D /* UIVIewController+Extensions.swift */, D0030981250C6C8500EACB32 /* URL+Extensions.swift */, D0C7D46F24F76169001EBDBB /* View+Extensions.swift */, ); @@ -755,6 +763,7 @@ D08E52E3257D747400FA2C5F /* CompositionContentConfiguration.swift in Sources */, D0F0B136251AA12700942152 /* CollectionItem+Extensions.swift in Sources */, D007023E25562A2800F38136 /* ConversationAvatarsView.swift in Sources */, + D0E7AD3925870B13005F5E2D /* UIVIewController+Extensions.swift in Sources */, D0625E5D250F0B5C00502611 /* StatusContentConfiguration.swift in Sources */, D0BEB1F324F8EE8C001B0F04 /* StatusAttachmentView.swift in Sources */, D0C7D49A24F7616A001EBDBB /* TableView.swift in Sources */, @@ -768,6 +777,7 @@ D08B8D42253F92B600B1EBEF /* ImagePageViewController.swift in Sources */, D01F41D924F880C400D55A2D /* TouchFallthroughTextView.swift in Sources */, D0C7D4D624F7616A001EBDBB /* NSMutableAttributedString+Extensions.swift in Sources */, + D0E9F9AA258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D0625E59250F092900502611 /* StatusListCell.swift in Sources */, D0E569DB2529319100FA1D72 /* LoadMoreView.swift in Sources */, D0C7D49D24F7616A001EBDBB /* PostingReadingPreferencesView.swift in Sources */, @@ -836,8 +846,10 @@ D08E52A6257C61C000FA2C5F /* ShareExtensionNavigationViewController.swift in Sources */, D08E52BD257C635800FA2C5F /* NewStatusViewController.swift in Sources */, D08E52D2257C811200FA2C5F /* ShareExtensionError+Extensions.swift in Sources */, + D0E9F9AB258450B300EF503D /* CompositionInputAccessoryView.swift in Sources */, D0F2D4DB257F018300986197 /* Array+Extensions.swift in Sources */, D08E52DD257D742B00FA2C5F /* CompositionListCell.swift in Sources */, + D0E7AD4225870C79005F5E2D /* UIVIewController+Extensions.swift in Sources */, D08E52EF257D757100FA2C5F /* CompositionView.swift in Sources */, D0F2D5452581ABAB00986197 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D08E52C7257C7AEE00FA2C5F /* ShareErrorViewController.swift in Sources */, diff --git a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift index 46842e6..757cc19 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/IdentityService.swift @@ -206,6 +206,28 @@ public extension IdentityService { .eraseToAnyPublisher() } + 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() +// var components = StatusEndpoint.Components() +// +// if !composition.text.isEmpty { +// components.text = composition.text +// } +// +// return mastodonAPIClient.request(StatusEndpoint.post(components)) +// .ignoreOutput() +// .eraseToAnyPublisher() + } + func service(timeline: Timeline) -> TimelineService { TimelineService(timeline: timeline, mastodonAPIClient: mastodonAPIClient, contentDatabase: contentDatabase) } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift index f1d6620..ec6d284 100644 --- a/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift +++ b/ServiceLayer/Sources/ServiceLayer/Services/InstanceURLService.swift @@ -75,6 +75,7 @@ private struct UpdatedFilterTarget: DecodableTarget { let method = HTTPMethod.get let queryParameters: [URLQueryItem] = [] let jsonBody: [String: Any]? = nil + let multipartFormData: [String: MultipartFormValue]? = nil let headers: [String: String]? = nil } diff --git a/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift b/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift new file mode 100644 index 0000000..38b4ba1 --- /dev/null +++ b/ServiceLayer/Sources/ServiceLayer/Services/MediaProcessingService.swift @@ -0,0 +1,100 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import Foundation +import ImageIO +import Mastodon +import UniformTypeIdentifiers + +enum MediaProcessingError: Error { + case invalidMimeType + case fileURLNotFound + case unsupportedType + case unableToCreateImageSource + case unableToDownsample + case unableToCreateImageDataDestination +} + +public struct MediaProcessingService {} + +public extension MediaProcessingService { + static func attachment(itemProvider: NSItemProvider) -> AnyPublisher { + 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) + }), + 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 { + return promise(.failure(error)) + } + + guard let url = url else { return promise(.failure(MediaProcessingError.fileURLNotFound)) } + + if uniformType.conforms(to: .image) { + return promise(imageData(url: url, type: uniformType)) + } else { + do { + return try promise(.success(Data(contentsOf: url))) + } catch { + return promise(.failure(error)) + } + } + } + } + .map { Composition.Attachment(data: $0, type: type, mimeType: mimeType) } + .eraseToAnyPublisher() + } +} + +private extension MediaProcessingService { + static let unuploadableMimeTypes: Set = [UTType.heic.preferredMIMEType!] + static let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + static let thumbnailOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 1280 + ] as CFDictionary + + static func imageData(url: URL, type: UTType) -> Result { + guard let source = CGImageSourceCreateWithURL(url as CFURL, Self.imageSourceOptions) else { + return .failure(MediaProcessingError.unableToCreateImageSource) + } + + guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else { + return .failure(MediaProcessingError.unableToDownsample) + } + + let data = NSMutableData() + + guard let imageDestination = CGImageDestinationCreateWithData(data, type.identifier as CFString, 1, nil) else { + return .failure(MediaProcessingError.unableToCreateImageDataDestination) + } + + CGImageDestinationAddImage(imageDestination, image, nil) + CGImageDestinationFinalize(imageDestination) + + return .success(data as Data) + } +} diff --git a/View Controllers/NewStatusViewController.swift b/View Controllers/NewStatusViewController.swift index 6b877ac..494a65d 100644 --- a/View Controllers/NewStatusViewController.swift +++ b/View Controllers/NewStatusViewController.swift @@ -2,12 +2,19 @@ import Combine import Kingfisher +import PhotosUI import UIKit import ViewModels class NewStatusViewController: UICollectionViewController { private let viewModel: NewStatusViewModel private let isShareExtension: Bool + private let postButton = UIBarButtonItem( + title: NSLocalizedString("post", comment: ""), + style: .done, + target: nil, + action: nil) + private var attachMediaTo: CompositionViewModel? private var cancellables = Set() private lazy var dataSource: NewStatusDataSource = { @@ -22,14 +29,6 @@ class NewStatusViewController: UICollectionViewController { let layout = UICollectionViewCompositionalLayout.list(using: configuration) super.init(collectionViewLayout: layout) - - viewModel.$identification - .sink { [weak self] in - guard let self = self else { return } - - self.setupBarButtonItems(identification: $0) - } - .store(in: &cancellables) } @available(*, unavailable) @@ -44,22 +43,49 @@ class NewStatusViewController: UICollectionViewController { view.backgroundColor = .systemBackground + postButton.primaryAction = UIAction(title: NSLocalizedString("post", comment: "")) { [weak self] _ in + self?.viewModel.post() + } + setupBarButtonItems(identification: viewModel.identification) + viewModel.$identification + .sink { [weak self] in + guard let self = self else { return } + + self.setupBarButtonItems(identification: $0) + } + .store(in: &cancellables) + viewModel.$compositionViewModels.sink { [weak self] in - self?.dataSource.apply([$0.map(\.composition.id)].snapshot()) { - DispatchQueue.main.async { - if let collectionView = self?.collectionView, - collectionView.indexPathsForSelectedItems?.isEmpty ?? false { - collectionView.selectItem( - at: collectionView.indexPathsForVisibleItems.first, - animated: false, - scrollPosition: .top) - } + guard let self = self else { return } + + let oldSnapshot = self.dataSource.snapshot() + let newSnapshot = [$0.map(\.composition.id)].snapshot() + let diff = newSnapshot.itemIdentifiers.difference(from: oldSnapshot.itemIdentifiers) + + self.dataSource.apply(newSnapshot) { + if case let .insert(_, id, _) = diff.insertions.first, + let indexPath = self.dataSource.indexPath(for: id) { + self.collectionView.selectItem(at: indexPath, animated: false, scrollPosition: .top) } } } .store(in: &cancellables) + + viewModel.$compositionViewModels + .flatMap { Publishers.MergeMany($0.map(\.composition.$text)) } + .sink { [weak self] _ in self?.collectionView.collectionViewLayout.invalidateLayout() } + .store(in: &cancellables) + + viewModel.$canPost.sink { [weak self] in self?.postButton.isEnabled = $0 }.store(in: &cancellables) + + viewModel.events.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables) + + viewModel.$alertItem + .compactMap { $0 } + .sink { [weak self] in self?.present(alertItem: $0) } + .store(in: &cancellables) } override func didMove(toParent parent: UIViewController?) { @@ -68,12 +94,6 @@ class NewStatusViewController: UICollectionViewController { setupBarButtonItems(identification: viewModel.identification) } - override func collectionView(_ collectionView: UICollectionView, - willDisplay cell: UICollectionViewCell, - forItemAt indexPath: IndexPath) { - ((cell as? CompositionListCell)?.contentView as? CompositionView)?.textView.delegate = self - } - func setupBarButtonItems(identification: Identification) { let target = isShareExtension ? self : parent let closeButton = UIBarButtonItem( @@ -84,6 +104,7 @@ class NewStatusViewController: UICollectionViewController { target?.navigationItem.titleView = viewModel.canChangeIdentity ? changeIdentityButton(identification: identification) : nil + target?.navigationItem.rightBarButtonItem = postButton } func dismiss() { @@ -95,9 +116,14 @@ class NewStatusViewController: UICollectionViewController { } } -extension NewStatusViewController: UITextViewDelegate { - func textViewDidChange(_ textView: UITextView) { - collectionView.collectionViewLayout.invalidateLayout() +extension NewStatusViewController: PHPickerViewControllerDelegate { + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + dismiss(animated: true) + + guard let result = results.first else { return } + + attachMediaTo?.attach(itemProvider: result.itemProvider) + attachMediaTo = nil } } @@ -139,4 +165,22 @@ private extension NewStatusViewController { return changeIdentityButton } + + func handle(event: CompositionViewModel.Event) { + switch event { + case let .presentMediaPicker(compositionViewModel): + attachMediaTo = compositionViewModel + + var configuration = PHPickerConfiguration() + + configuration.preferredAssetRepresentationMode = .current + + let picker = PHPickerViewController(configuration: configuration) + + picker.delegate = self + present(picker, animated: true) + default: + break + } + } } diff --git a/ViewModels/Sources/ViewModels/CompositionViewModel.swift b/ViewModels/Sources/ViewModels/CompositionViewModel.swift index 320ab75..9482bef 100644 --- a/ViewModels/Sources/ViewModels/CompositionViewModel.swift +++ b/ViewModels/Sources/ViewModels/CompositionViewModel.swift @@ -7,13 +7,39 @@ import ServiceLayer public final class CompositionViewModel: ObservableObject { public let composition: Composition + @Published public private(set) var isPostable = false @Published public private(set) var identification: Identification + private let eventsSubject: PassthroughSubject + init(composition: Composition, identification: Identification, - identificationPublisher: AnyPublisher) { + identificationPublisher: AnyPublisher, + eventsSubject: PassthroughSubject) { self.composition = composition self.identification = identification + self.eventsSubject = eventsSubject identificationPublisher.assign(to: &$identification) + composition.$text.map { !$0.isEmpty }.removeDuplicates().assign(to: &$isPostable) + } +} + +public extension CompositionViewModel { + enum Event { + case insertAfter(CompositionViewModel) + case presentMediaPicker(CompositionViewModel) + case attach(itemProvider: NSItemProvider, viewModel: CompositionViewModel) + } + + func presentMediaPicker() { + eventsSubject.send(.presentMediaPicker(self)) + } + + func insert() { + eventsSubject.send(.insertAfter(self)) + } + + func attach(itemProvider: NSItemProvider) { + eventsSubject.send(.attach(itemProvider: itemProvider, viewModel: self)) } } diff --git a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift index 9cb9ff7..938f51b 100644 --- a/ViewModels/Sources/ViewModels/NewStatusViewModel.swift +++ b/ViewModels/Sources/ViewModels/NewStatusViewModel.swift @@ -9,11 +9,16 @@ public final class NewStatusViewModel: ObservableObject { @Published public private(set) var compositionViewModels = [CompositionViewModel]() @Published public private(set) var identification: Identification @Published public private(set) var authenticatedIdentities = [Identity]() + @Published public var canPost = false @Published public var canChangeIdentity = true @Published public var alertItem: AlertItem? + @Published public private(set) var loading = false + public let events: AnyPublisher private let allIdentitiesService: AllIdentitiesService private let environment: AppEnvironment + private let eventsSubject = PassthroughSubject() + private let itemEventsSubject = PassthroughSubject() private var cancellables = Set() public init(allIdentitiesService: AllIdentitiesService, @@ -22,13 +27,18 @@ public final class NewStatusViewModel: ObservableObject { self.allIdentitiesService = allIdentitiesService self.identification = identification self.environment = environment - compositionViewModels = [CompositionViewModel( - composition: .init(id: environment.uuid(), text: ""), - identification: identification, - identificationPublisher: $identification.eraseToAnyPublisher())] + events = eventsSubject.eraseToAnyPublisher() + compositionViewModels = [newCompositionViewModel()] + itemEventsSubject.sink { [weak self] in self?.handle(event: $0) }.store(in: &cancellables) allIdentitiesService.authenticatedIdentitiesPublisher() .assignErrorsToAlertItem(to: \.alertItem, on: self) .assign(to: &$authenticatedIdentities) + $compositionViewModels.flatMap { Publishers.MergeMany($0.map(\.$isPostable)) } + .receive(on: DispatchQueue.main) // hack to punt to next run loop, consider refactoring + .compactMap { [weak self] _ in self?.compositionViewModels.allSatisfy(\.isPostable) } + .combineLatest($loading) + .map { $0 && !$1 } + .assign(to: &$canPost) } } @@ -55,4 +65,47 @@ public extension NewStatusViewModel { service: identityService, environment: environment) } + + func post() { + identification.service.post(compositions: compositionViewModels.map(\.composition)) + .receive(on: DispatchQueue.main) + .handleEvents( + receiveSubscription: { [weak self] _ in self?.loading = true }, + receiveCompletion: { [weak self] _ in self?.loading = false }) + .assignErrorsToAlertItem(to: \.alertItem, on: self) + .sink { _ in } + .store(in: &cancellables) + } +} + +private extension NewStatusViewModel { + func newCompositionViewModel() -> CompositionViewModel { + CompositionViewModel( + composition: .init(id: environment.uuid(), text: ""), + identification: identification, + identificationPublisher: $identification.eraseToAnyPublisher(), + eventsSubject: itemEventsSubject) + } + + func handle(event: CompositionViewModel.Event) { + switch event { + case let .insertAfter(viewModel): + guard let index = compositionViewModels.firstIndex(where: { $0 === viewModel }) else { return } + + let newViewModel = newCompositionViewModel() + + if index >= compositionViewModels.count - 1 { + compositionViewModels.append(newViewModel) + } 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) + default: + eventsSubject.send(event) + } + } } diff --git a/Views/CompositionInputAccessoryView.swift b/Views/CompositionInputAccessoryView.swift new file mode 100644 index 0000000..89feedf --- /dev/null +++ b/Views/CompositionInputAccessoryView.swift @@ -0,0 +1,84 @@ +// Copyright © 2020 Metabolist. All rights reserved. + +import Combine +import UIKit +import ViewModels + +class CompositionInputAccessoryView: UIView { + private let stackView = UIStackView() + private let viewModel: CompositionViewModel + private var cancellables = Set() + + init(viewModel: CompositionViewModel) { + self.viewModel = viewModel + + super.init(frame: .zero) + + initialSetup() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var intrinsicContentSize: CGSize { + stackView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + } +} + +private extension CompositionInputAccessoryView { + func initialSetup() { + autoresizingMask = .flexibleHeight + backgroundColor = .secondarySystemFill + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = .defaultSpacing + + let mediaButton = UIButton() + + stackView.addArrangedSubview(mediaButton) + mediaButton.setImage( + UIImage( + systemName: "photo", + withConfiguration: UIImage.SymbolConfiguration(scale: .medium)), + for: .normal) + mediaButton.addAction(UIAction { [weak self] _ in self?.viewModel.presentMediaPicker() }, for: .touchUpInside) + + let pollButton = UIButton() + + stackView.addArrangedSubview(pollButton) + pollButton.setImage( + UIImage( + systemName: "chart.bar.xaxis", + withConfiguration: UIImage.SymbolConfiguration(scale: .medium)), + for: .normal) + + stackView.addArrangedSubview(UIView()) + + let addButton = UIButton() + + stackView.addArrangedSubview(addButton) + addButton.setImage( + UIImage( + systemName: "plus.circle.fill", + withConfiguration: UIImage.SymbolConfiguration(scale: .medium)), + for: .normal) + addButton.addAction(UIAction { [weak self] _ in self?.viewModel.insert() }, for: .touchUpInside) + + for button in [mediaButton, pollButton, addButton] { + button.heightAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true + button.widthAnchor.constraint(greaterThanOrEqualToConstant: .minimumButtonDimension).isActive = true + } + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: leadingAnchor), + stackView.topAnchor.constraint(equalTo: topAnchor), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + + viewModel.$isPostable.sink { addButton.isEnabled = $0 }.store(in: &cancellables) + } +} diff --git a/Views/CompositionView.swift b/Views/CompositionView.swift index 9bbf62a..1f5630b 100644 --- a/Views/CompositionView.swift +++ b/Views/CompositionView.swift @@ -39,6 +39,12 @@ extension CompositionView: UIContentView { } } +extension CompositionView: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + compositionConfiguration.viewModel.composition.text = textView.text + } +} + private extension CompositionView { func initialSetup() { addSubview(avatarImageView) @@ -57,6 +63,9 @@ private extension CompositionView { textView.adjustsFontForContentSizeCategory = true textView.font = .preferredFont(forTextStyle: .body) textView.textContainer.lineFragmentPadding = 0 + textView.inputAccessoryView = CompositionInputAccessoryView(viewModel: compositionConfiguration.viewModel) + textView.inputAccessoryView?.sizeToFit() + textView.delegate = self let constraints = [ avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),