feat: implement image upload logic
This commit is contained in:
parent
296d29f3e0
commit
75d10b76c8
|
@ -217,6 +217,8 @@
|
||||||
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; };
|
DB9A487E2603456B008B817C /* UITextView+Placeholder in Frameworks */ = {isa = PBXBuildFile; productRef = DB9A487D2603456B008B817C /* UITextView+Placeholder */; };
|
||||||
DB9A488426034BD7008B817C /* APIService+Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488326034BD7008B817C /* APIService+Status.swift */; };
|
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 */; };
|
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 */; };
|
DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; };
|
||||||
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
|
DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; };
|
||||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.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 = "<group>"; };
|
DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = "<group>"; };
|
||||||
DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; };
|
DB9A488326034BD7008B817C /* APIService+Status.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Status.swift"; sourceTree = "<group>"; };
|
||||||
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = "<group>"; };
|
DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = "<group>"; };
|
||||||
|
DB9A488F26035963008B817C /* APIService+Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Media.swift"; sourceTree = "<group>"; };
|
||||||
|
DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAttachmentService+UploadState.swift"; sourceTree = "<group>"; };
|
||||||
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = "<group>"; };
|
||||||
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
|
DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = "<group>"; };
|
||||||
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
|
||||||
|
@ -738,12 +742,12 @@
|
||||||
children = (
|
children = (
|
||||||
DB45FB0425CA87B4005A8AC7 /* APIService */,
|
DB45FB0425CA87B4005A8AC7 /* APIService */,
|
||||||
DB49A61925FF327D00B98345 /* EmojiService */,
|
DB49A61925FF327D00B98345 /* EmojiService */,
|
||||||
|
DB9A489B26036E19008B817C /* MastodonAttachmentService */,
|
||||||
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
|
DB45FB0E25CA87D0005A8AC7 /* AuthenticationService.swift */,
|
||||||
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
|
DB4563BC25E11A24004DA0B9 /* KeyboardResponderService.swift */,
|
||||||
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
|
2D206B8B25F6015000143C56 /* AudioPlaybackService.swift */,
|
||||||
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
2DA6054625F716A2006356F9 /* PlaybackState.swift */,
|
||||||
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
|
5DF1054025F886D400D6C0D4 /* ViedeoPlaybackService.swift */,
|
||||||
DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */,
|
|
||||||
);
|
);
|
||||||
path = Service;
|
path = Service;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1036,6 +1040,7 @@
|
||||||
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
|
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */,
|
||||||
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
|
DB49A62A25FF36C700B98345 /* APIService+CustomEmoji.swift */,
|
||||||
DB9A488326034BD7008B817C /* APIService+Status.swift */,
|
DB9A488326034BD7008B817C /* APIService+Status.swift */,
|
||||||
|
DB9A488F26035963008B817C /* APIService+Media.swift */,
|
||||||
);
|
);
|
||||||
path = APIService;
|
path = APIService;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1292,6 +1297,15 @@
|
||||||
path = Generated;
|
path = Generated;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
DB9A489B26036E19008B817C /* MastodonAttachmentService */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
DB6B35172601FA3400DC1E11 /* MastodonAttachmentService.swift */,
|
||||||
|
DB9A48952603685D008B817C /* MastodonAttachmentService+UploadState.swift */,
|
||||||
|
);
|
||||||
|
path = MastodonAttachmentService;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
DB9D6BEE25E4F5370051B173 /* Search */ = {
|
DB9D6BEE25E4F5370051B173 /* Search */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
@ -1797,6 +1811,7 @@
|
||||||
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
2DF123A725C3B0210020F248 /* ActiveLabel.swift in Sources */,
|
||||||
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
DB59F11825EFA35B001F1DAB /* StripProgressView.swift in Sources */,
|
||||||
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */,
|
||||||
|
DB9A48962603685D008B817C /* MastodonAttachmentService+UploadState.swift in Sources */,
|
||||||
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
|
DB66728C25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift in Sources */,
|
||||||
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */,
|
||||||
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
|
2DF75B9B25D0E27500694EC8 /* StatusProviderFacade.swift in Sources */,
|
||||||
|
@ -1895,6 +1910,7 @@
|
||||||
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
DB0140AE25C40C7300F9F3CF /* MastodonPinBasedAuthenticationViewModel.swift in Sources */,
|
||||||
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
2D32EAAC25CB96DC00C9ED86 /* TimelineMiddleLoaderTableViewCell.swift in Sources */,
|
||||||
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
|
DB71FD2C25F86A5100512AE1 /* AvatarStackContainerButton.swift in Sources */,
|
||||||
|
DB9A489026035963008B817C /* APIService+Media.swift in Sources */,
|
||||||
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
|
2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */,
|
||||||
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
|
DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */,
|
||||||
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */,
|
||||||
|
|
|
@ -173,7 +173,7 @@ extension ComposeViewController {
|
||||||
})
|
})
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel.isComposeTootBarButtonItemEnabled
|
viewModel.isPublishBarButtonItemEnabled
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: \.isEnabled, on: publishBarButtonItem)
|
.assign(to: \.isEnabled, on: publishBarButtonItem)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
@ -486,7 +486,16 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
|
||||||
extension ComposeViewController: PHPickerViewControllerDelegate {
|
extension ComposeViewController: PHPickerViewControllerDelegate {
|
||||||
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
|
||||||
picker.dismiss(animated: true, completion: nil)
|
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
|
viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,8 +7,7 @@
|
||||||
|
|
||||||
import os.log
|
import os.log
|
||||||
import Foundation
|
import Foundation
|
||||||
import CoreData
|
import Combine
|
||||||
import CoreDataStack
|
|
||||||
import GameplayKit
|
import GameplayKit
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
|
@ -34,6 +33,9 @@ extension ComposeViewModel.PublishState {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Publishing: ComposeViewModel.PublishState {
|
class Publishing: ComposeViewModel.PublishState {
|
||||||
|
|
||||||
|
var publishingSubscription: AnyCancellable?
|
||||||
|
|
||||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
||||||
return stateClass == Fail.self || stateClass == Finish.self
|
return stateClass == Fail.self || stateClass == Finish.self
|
||||||
}
|
}
|
||||||
|
@ -46,11 +48,14 @@ extension ComposeViewModel.PublishState {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mediaIDs = viewModel.attachmentServices.value.compactMap { attachmentService in
|
||||||
|
attachmentService.attachment.value?.id
|
||||||
|
}
|
||||||
let query = Mastodon.API.Statuses.PublishStatusQuery(
|
let query = Mastodon.API.Statuses.PublishStatusQuery(
|
||||||
status: viewModel.composeStatusAttribute.composeContent.value,
|
status: viewModel.composeStatusAttribute.composeContent.value,
|
||||||
mediaIDs: nil
|
mediaIDs: mediaIDs
|
||||||
)
|
)
|
||||||
viewModel.context.apiService.publishStatus(
|
publishingSubscription = viewModel.context.apiService.publishStatus(
|
||||||
domain: mastodonAuthenticationBox.domain,
|
domain: mastodonAuthenticationBox.domain,
|
||||||
query: query,
|
query: query,
|
||||||
mastodonAuthenticationBox: mastodonAuthenticationBox
|
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)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
stateMachine.enter(Finish.self)
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -39,7 +39,7 @@ final class ComposeViewModel {
|
||||||
// UI & UX
|
// UI & UX
|
||||||
let title: CurrentValueSubject<String, Never>
|
let title: CurrentValueSubject<String, Never>
|
||||||
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
|
let shouldDismiss = CurrentValueSubject<Bool, Never>(true)
|
||||||
let isComposeTootBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false)
|
let isPublishBarButtonItemEnabled = CurrentValueSubject<Bool, Never>(false)
|
||||||
|
|
||||||
// custom emojis
|
// custom emojis
|
||||||
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
let customEmojiViewModel = CurrentValueSubject<EmojiService.CustomEmojiViewModel?, Never>(nil)
|
||||||
|
@ -47,7 +47,6 @@ final class ComposeViewModel {
|
||||||
// attachment
|
// attachment
|
||||||
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
|
let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([])
|
||||||
|
|
||||||
|
|
||||||
init(
|
init(
|
||||||
context: AppContext,
|
context: AppContext,
|
||||||
composeKind: ComposeStatusSection.ComposeKind
|
composeKind: ComposeStatusSection.ComposeKind
|
||||||
|
@ -89,13 +88,29 @@ final class ComposeViewModel {
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// bind compose bar button item UI state
|
// bind compose bar button item UI state
|
||||||
composeStatusAttribute.composeContent
|
let isComposeContentEmpty = composeStatusAttribute.composeContent
|
||||||
.receive(on: DispatchQueue.main)
|
.map { ($0 ?? "").isEmpty }
|
||||||
.map { content in
|
let isComposeContentValid = Just(true).eraseToAnyPublisher()
|
||||||
let content = content?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let isMediaEmpty = attachmentServices
|
||||||
return !content.isEmpty
|
.map { $0.isEmpty }
|
||||||
|
let isMediaUploadAllSuccess = attachmentServices
|
||||||
|
.map { services in
|
||||||
|
services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish }
|
||||||
}
|
}
|
||||||
.assign(to: \.value, on: isComposeTootBarButtonItemEnabled)
|
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)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// bind modal dismiss state
|
// bind modal dismiss state
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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<Mastodon.Response.Content<Mastodon.Entity.Attachment>, Error> {
|
||||||
|
let authorization = mastodonAuthenticationBox.userAuthorization
|
||||||
|
|
||||||
|
return Mastodon.API.Media.uploadMedia(
|
||||||
|
session: session,
|
||||||
|
domain: domain,
|
||||||
|
query: query,
|
||||||
|
authorization: authorization
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -7,10 +7,6 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
import CoreData
|
|
||||||
import CoreDataStack
|
|
||||||
import CommonOSLog
|
|
||||||
import DateToolsSwift
|
|
||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
extension APIService {
|
extension APIService {
|
||||||
|
|
|
@ -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<AnyCancellable>()
|
|
||||||
|
|
||||||
let identifier = UUID()
|
|
||||||
|
|
||||||
// input
|
|
||||||
let pickerResult: PHPickerResult
|
|
||||||
let description = CurrentValueSubject<String?, Never>(nil)
|
|
||||||
|
|
||||||
// output
|
|
||||||
let imageData = CurrentValueSubject<Data?, Never>(nil)
|
|
||||||
let error = CurrentValueSubject<Error?, Never>(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)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
|
@ -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<AnyCancellable>()
|
||||||
|
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<Data?, Never>(nil)
|
||||||
|
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
||||||
|
let description = CurrentValueSubject<String?, Never>(nil)
|
||||||
|
let error = CurrentValueSubject<Error?, Never>(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<MastodonAttachmentService.UploadState?, Never>(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -219,6 +219,7 @@ extension Mastodon.API.Account {
|
||||||
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value))
|
data.append(Data.multipart(key: "fields_attributes[value][]", value: fieldsAttribute.value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.append(Data.multipartEnd())
|
data.append(Data.multipartEnd())
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<Mastodon.Response.Content<Mastodon.Entity.Attachment>, 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -94,6 +94,7 @@ extension Mastodon.API {
|
||||||
public enum CustomEmojis { }
|
public enum CustomEmojis { }
|
||||||
public enum Favorites { }
|
public enum Favorites { }
|
||||||
public enum Instance { }
|
public enum Instance { }
|
||||||
|
public enum Media { }
|
||||||
public enum OAuth { }
|
public enum OAuth { }
|
||||||
public enum Onboarding { }
|
public enum Onboarding { }
|
||||||
public enum Polls { }
|
public enum Polls { }
|
||||||
|
|
Loading…
Reference in New Issue