From 75d10b76c8c63f31d28e7d6a8a9a08875759b796 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 18 Mar 2021 19:42:26 +0800 Subject: [PATCH] feat: implement image upload logic --- Mastodon.xcodeproj/project.pbxproj | 18 ++- .../Scene/Compose/ComposeViewController.swift | 13 ++- .../ComposeViewModel+PublishState.swift | 18 +-- Mastodon/Scene/Compose/ComposeViewModel.swift | 41 +++++-- .../Service/APIService/APIService+Media.swift | 29 +++++ .../APIService/APIService+Status.swift | 4 - .../Service/MastodonAttachmentService.swift | 58 ---------- ...astodonAttachmentService+UploadState.swift | 104 +++++++++++++++++ .../MastodonAttachmentService.swift | 107 ++++++++++++++++++ .../Mastodon+API+Account+Credentials.swift | 1 + .../MastodonSDK/API/Mastodon+API+Media.swift | 88 ++++++++++++++ .../MastodonSDK/API/Mastodon+API.swift | 1 + 12 files changed, 401 insertions(+), 81 deletions(-) create mode 100644 Mastodon/Service/APIService/APIService+Media.swift delete mode 100644 Mastodon/Service/MastodonAttachmentService.swift create mode 100644 Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift create mode 100644 Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift create mode 100644 MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index c898d3efb..684141b1b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -217,6 +217,8 @@ DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; }; DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; }; DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; + DB9A489026035963008B817C /* APIService+Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488F26035963008B817C /* APIService+Media.swift */; }; + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -505,6 +507,8 @@ DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = ""; }; DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; + DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = ""; }; + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -738,12 +742,12 @@ children = ( DB45FB0425CA87B4005A8AC7 /* APIService */, DB49A61925FF327D00B98345 /* EmojiService */, + DB9A489B26036E19008B817C /* MastodonAttachmentService */, DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */, DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */, 2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */, 2DA6054625F716A2006356F9 /* PlaybackState.swift */, 5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */, - DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, ); path = Service; sourceTree = ""; @@ -1036,6 +1040,7 @@ DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */, DB9A488326034BD7008B817C /* APIService+Status.swift */, + DB9A488F26035963008B817C /* APIService+Media.swift */, ); path = APIService; sourceTree = ""; @@ -1292,6 +1297,15 @@ path = Generated; sourceTree = ""; }; + DB9A489B26036E19008B817C /* MastodonAttachmentService */ = { + isa = PBXGroup; + children = ( + DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */, + DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */, + ); + path = MastodonAttachmentService; + sourceTree = ""; + }; DB9D6BEE25E4F5370051B173 /* Search */ = { isa = PBXGroup; children = ( @@ -1797,6 +1811,7 @@ 2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */, DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */, DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */, + DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */, DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */, @@ -1895,6 +1910,7 @@ DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */, 2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */, DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */, + DB9A489026035963008B817C /* APIService+Media.swift in Sources */, 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 2870239ec..254cd5835 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -173,7 +173,7 @@ extension ComposeViewController { }) .store(in: &disposeBag) - viewModel.isComposeTootBarButtonItemEnabled + viewModel.isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) @@ -486,7 +486,16 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { extension ComposeViewController: PHPickerViewControllerDelegate { func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true, completion: nil) - let attachmentServices = results.map { MastodonAttachmentService(pickerResult: $0) } + + let attachmentServices: [MastodonAttachmentService] = results.map { result in + let service = MastodonAttachmentService( + context: context, + pickerResult: result, + initalAuthenticationBox: viewModel.activeAuthenticationBox.value + ) + service.delegate = viewModel + return service + } viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift index 0033bff37..8b1cc0c97 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift @@ -7,8 +7,7 @@ import os.log import Foundation -import CoreData -import CoreDataStack +import Combine import GameplayKit import MastodonSDK @@ -34,6 +33,9 @@ extension ComposeViewModel.PublishState { } class Publishing: ComposeViewModel.PublishState { + + var publishingSubscription: AnyCancellable? + override func isValidNextState(_ stateClass: AnyClass) -> Bool { return stateClass == Fail.self || stateClass == Finish.self } @@ -46,11 +48,14 @@ extension ComposeViewModel.PublishState { return } + let mediaIDs = viewModel.attachmentServices.value.compactMap { attachmentService in + attachmentService.attachment.value?.id + } let query = Mastodon.API.Statuses.PublishStatusQuery( status: viewModel.composeStatusAttribute.composeContent.value, - mediaIDs: nil + mediaIDs: mediaIDs ) - viewModel.context.apiService.publishStatus( + publishingSubscription = viewModel.context.apiService.publishStatus( domain: mastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: mastodonAuthenticationBox @@ -65,10 +70,9 @@ extension ComposeViewModel.PublishState { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) stateMachine.enter(Finish.self) } - } receiveValue: { status in - + } receiveValue: { response in + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) } - .store(in: &viewModel.disposeBag) } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 67d3adb42..2d6dc728d 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -39,7 +39,7 @@ final class ComposeViewModel { // UI & UX let title: CurrentValueSubject let shouldDismiss = CurrentValueSubject(true) - let isComposeTootBarButtonItemEnabled = CurrentValueSubject(false) + let isPublishBarButtonItemEnabled = CurrentValueSubject(false) // custom emojis let customEmojiViewModel = CurrentValueSubject(nil) @@ -47,7 +47,6 @@ final class ComposeViewModel { // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) - init( context: AppContext, composeKind: ComposeStatusSection.ComposeKind @@ -89,14 +88,30 @@ final class ComposeViewModel { .store(in: &disposeBag) // bind compose bar button item UI state - composeStatusAttribute.composeContent - .receive(on: DispatchQueue.main) - .map { content in - let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return !content.isEmpty + let isComposeContentEmpty = composeStatusAttribute.composeContent + .map { ($0 ?? "").isEmpty } + let isComposeContentValid = Just(true).eraseToAnyPublisher() + let isMediaEmpty = attachmentServices + .map { $0.isEmpty } + let isMediaUploadAllSuccess = attachmentServices + .map { services in + services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } } - .assign(to: \.value, on: isComposeTootBarButtonItemEnabled) - .store(in: &disposeBag) + Publishers.CombineLatest4( + isComposeContentEmpty.eraseToAnyPublisher(), + isComposeContentValid.eraseToAnyPublisher(), + isMediaEmpty.eraseToAnyPublisher(), + isMediaUploadAllSuccess.eraseToAnyPublisher() + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .assign(to: \.value, on: isPublishBarButtonItemEnabled) + .store(in: &disposeBag) // bind modal dismiss state composeStatusAttribute.composeContent @@ -143,3 +158,11 @@ final class ComposeViewModel { } } + +// MARK: - MastodonAttachmentServiceDelegate +extension ComposeViewModel: MastodonAttachmentServiceDelegate { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { + // trigger new output event + attachmentServices.value = attachmentServices.value + } +} diff --git a/Mastodon/Service/APIService/APIService+Media.swift b/Mastodon/Service/APIService/APIService+Media.swift new file mode 100644 index 000000000..b1c0fed75 --- /dev/null +++ b/Mastodon/Service/APIService/APIService+Media.swift @@ -0,0 +1,29 @@ +// +// APIService+Media.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine +import MastodonSDK + +extension APIService { + + func uploadMedia( + domain: String, + query: Mastodon.API.Media.UploadMeidaQuery, + mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox + ) -> AnyPublisher, Error> { + let authorization = mastodonAuthenticationBox.userAuthorization + + return Mastodon.API.Media.uploadMedia( + session: session, + domain: domain, + query: query, + authorization: authorization + ) + } + +} diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index dee775476..ece794320 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -7,10 +7,6 @@ import Foundation import Combine -import CoreData -import CoreDataStack -import CommonOSLog -import DateToolsSwift import MastodonSDK extension APIService { diff --git a/Mastodon/Service/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService.swift deleted file mode 100644 index e29a04408..000000000 --- a/Mastodon/Service/MastodonAttachmentService.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// MastodonAttachmentService.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-17. -// - -import UIKit -import Combine -import PhotosUI - -final class MastodonAttachmentService { - - var disposeBag = Set() - - let identifier = UUID() - - // input - let pickerResult: PHPickerResult - let description = CurrentValueSubject(nil) - - // output - let imageData = CurrentValueSubject(nil) - let error = CurrentValueSubject(nil) - - init(pickerResult: PHPickerResult) { - self.pickerResult = pickerResult - // end init - - PHPickerResultLoader.loadImageData(from: pickerResult) - .sink { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure(let error): - self.error.value = error - case .finished: - break - } - } receiveValue: { [weak self] imageData in - guard let self = self else { return } - self.imageData.value = imageData - } - .store(in: &disposeBag) - } - -} - -extension MastodonAttachmentService: Equatable, Hashable { - - static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { - return lhs.identifier == rhs.identifier - } - - func hash(into hasher: inout Hasher) { - hasher.combine(identifier) - } - -} diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift new file mode 100644 index 000000000..91f6f5ba1 --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService+UploadState.swift @@ -0,0 +1,104 @@ +// +// MastodonAttachmentService+UploadState.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import os.log +import Foundation +import GameplayKit +import Kingfisher +import MastodonSDK + +extension MastodonAttachmentService { + class UploadState: GKState { + weak var service: MastodonAttachmentService? + + init(service: MastodonAttachmentService) { + self.service = service + } + + override func didEnter(from previousState: GKState?) { + os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription) + service?.uploadStateMachineSubject.send(self) + } + } +} + +extension MastodonAttachmentService.UploadState { + + class Initial: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard service?.authenticationBox != nil else { return false } + guard service?.imageData.value != nil else { return false } + return stateClass == Uploading.self + } + } + + class Uploading: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return stateClass == Fail.self || stateClass == Finish.self + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + guard let service = service, let stateMachine = stateMachine else { return } + guard let authenticationBox = service.authenticationBox else { return } + guard let imageData = service.imageData.value else { return } + + let file: Mastodon.Query.MediaAttachment = { + if imageData.kf.imageFormat == .PNG { + return .png(imageData) + } else { + return .jpeg(imageData) + } + }() + let description = service.description.value + let query = Mastodon.API.Media.UploadMeidaQuery( + file: file, + thumbnail: nil, + description: description, + focus: nil + ) + + service.context.apiService.uploadMedia( + domain: authenticationBox.domain, + query: query, + mastodonAuthenticationBox: authenticationBox + ) + .receive(on: DispatchQueue.main) + .sink { completion in + switch completion { + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + service.error.send(error) + case .finished: + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: upload attachment success", ((#file as NSString).lastPathComponent), #line, #function) + + break + } + } receiveValue: { response in + service.attachment.value = response.value + stateMachine.enter(Finish.self) + } + .store(in: &service.disposeBag) + } + } + + class Fail: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + // allow discard publishing + return stateClass == Uploading.self || stateClass == Finish.self + } + } + + class Finish: MastodonAttachmentService.UploadState { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + return false + } + } + +} + diff --git a/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift new file mode 100644 index 000000000..cccb2bc4f --- /dev/null +++ b/Mastodon/Service/MastodonAttachmentService/MastodonAttachmentService.swift @@ -0,0 +1,107 @@ +// +// MastodonAttachmentService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-17. +// + +import UIKit +import Combine +import PhotosUI +import Kingfisher +import GameplayKit +import MastodonSDK + +protocol MastodonAttachmentServiceDelegate: class { + func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) +} + +final class MastodonAttachmentService { + + var disposeBag = Set() + weak var delegate: MastodonAttachmentServiceDelegate? + + let identifier = UUID() + + // input + let context: AppContext + let pickerResult: PHPickerResult + var authenticationBox: AuthenticationService.MastodonAuthenticationBox? + + // output + // TODO: handle video/GIF/Audio data + let imageData = CurrentValueSubject(nil) + let attachment = CurrentValueSubject(nil) + let description = CurrentValueSubject(nil) + let error = CurrentValueSubject(nil) + + private(set) lazy var uploadStateMachine: GKStateMachine = { + // exclude timeline middle fetcher state + let stateMachine = GKStateMachine(states: [ + UploadState.Initial(service: self), + UploadState.Uploading(service: self), + UploadState.Fail(service: self), + UploadState.Finish(service: self), + ]) + stateMachine.enter(UploadState.Initial.self) + return stateMachine + }() + lazy var uploadStateMachineSubject = CurrentValueSubject(nil) + + init( + context: AppContext, + pickerResult: PHPickerResult, + initalAuthenticationBox: AuthenticationService.MastodonAuthenticationBox? + ) { + self.context = context + self.pickerResult = pickerResult + self.authenticationBox = initalAuthenticationBox + // end init + + uploadStateMachineSubject + .sink { [weak self] state in + guard let self = self else { return } + self.delegate?.mastodonAttachmentService(self, uploadStateDidChange: state) + } + .store(in: &disposeBag) + + PHPickerResultLoader.loadImageData(from: pickerResult) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): + self.error.value = error + case .finished: + break + } + } receiveValue: { [weak self] imageData in + guard let self = self else { return } + self.imageData.value = imageData + + // Try pre-upload attachment for current active user + self.uploadStateMachine.enter(UploadState.Uploading.self) + } + .store(in: &disposeBag) + } + +} + +extension MastodonAttachmentService { + // FIXME: needs reset state for multiple account posting support + func uploading(mastodonAuthenticationBox: AuthenticationService.MastodonAuthenticationBox) -> Bool { + authenticationBox = mastodonAuthenticationBox + return uploadStateMachine.enter(UploadState.self) + } +} + +extension MastodonAttachmentService: Equatable, Hashable { + + static func == (lhs: MastodonAttachmentService, rhs: MastodonAttachmentService) -> Bool { + return lhs.identifier == rhs.identifier + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift index 04273188b..c0ecd1aa3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Credentials.swift @@ -219,6 +219,7 @@ extension Mastodon.API.Account { data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value)) } } + data.append(Data.multipartEnd()) return data } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift new file mode 100644 index 000000000..e550d9069 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -0,0 +1,88 @@ +// +// Mastodon+API+Media.swift +// +// +// Created by MainasuK Cirno on 2021-3-18. +// + +import Foundation +import Combine + +extension Mastodon.API.Media { + + static func uploadMediaEndpointURL(domain: String) -> URL { + return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("media") + } + + /// Upload media as attachment + /// + /// Creates an attachment to be used with a new status. + /// + /// - Since: 0.0.0 + /// - Version: 3.3.0 + /// # Last Update + /// 2021/3/18 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/media/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: `UploadMediaQuery` + /// - authorization: User token + /// - Returns: `AnyPublisher` contains `Attachment` nested in the response + public static func uploadMedia( + session: URLSession, + domain: String, + query: UploadMeidaQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + var request = Mastodon.API.post( + url: uploadMediaEndpointURL(domain: domain), + query: query, + authorization: authorization + ) + request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct UploadMeidaQuery: PostQuery { + public let file: Mastodon.Query.MediaAttachment? + public let thumbnail: Mastodon.Query.MediaAttachment? + public let description: String? + public let focus: String? + + public init( + file: Mastodon.Query.MediaAttachment?, + thumbnail: Mastodon.Query.MediaAttachment?, + description: String?, + focus: String? + ) { + self.file = file + self.thumbnail = thumbnail + self.description = description + self.focus = focus + } + + var contentType: String? { + return Self.multipartContentType() + } + + var body: Data? { + var data = Data() + + file.flatMap { data.append(Data.multipart(key: "file", value: $0)) } + thumbnail.flatMap { data.append(Data.multipart(key: "thumbnail", value: $0)) } + description.flatMap { data.append(Data.multipart(key: "description", value: $0)) } + focus.flatMap { data.append(Data.multipart(key: "focus", value: $0)) } + + data.append(Data.multipartEnd()) + return data + } + } + +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift index dfba19bf8..d96768878 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API.swift @@ -94,6 +94,7 @@ extension Mastodon.API { public enum CustomEmojis { } public enum Favorites { } public enum Instance { } + public enum Media { } public enum OAuth { } public enum Onboarding { } public enum Polls { }