From a7d5e23406593b108f09391c171f2e43d80bda0a Mon Sep 17 00:00:00 2001 From: CMK Date: Mon, 31 Oct 2022 20:41:19 +0800 Subject: [PATCH] feat: [WIP] restore compose status publish function with background task support --- .../xcshareddata/swiftpm/Package.resolved | 9 + .../Scene/Compose/ComposeViewController.swift | 121 +++--- Mastodon/Scene/Compose/ComposeViewModel.swift | 2 +- ...tachmentContainerView+EmptyStateView.swift | 245 ++++++----- .../View/AttachmentContainerView.swift | 84 ++-- .../HomeTimelineViewController.swift | 5 +- ...eTimelineNavigationBarTitleViewModel.swift | 93 ++-- MastodonSDK/Package.swift | 2 + .../Sources/MastodonCore/AppContext.swift | 5 +- .../Sources/MastodonCore/AppError.swift | 13 + .../MastodonCore/Extension/FileManager.swift | 28 ++ .../Extension/NSItemProvider.swift | 145 +++++++ .../Extension/NSKeyValueObservation.swift | 15 + .../Model/Poll/PollComposeItem.swift | 4 +- .../MastodonCore/Service/API/APIService.swift | 2 +- .../PublisherService/PublisherService.swift | 109 +++++ .../StatusPublishResult.swift | 13 + .../PublisherService/StatusPublisher.swift | 14 + .../StatusPublisherReactor.swift | 10 + .../StatusPublisherState.swift | 14 + .../MastodonSDK/API/Mastodon+API+Media.swift | 20 + .../MastodonSDK/Query/MediaAttachment.swift | 12 + .../MastodonSDK/Query/SerialStream.swift | 16 +- .../Extension/UIAlertController.swift | 37 ++ .../Attachment/AttachmentView.swift | 246 +++++++++++ .../AttachmentViewModel+Upload.swift | 316 ++++++++++++++ .../Attachment/AttachmentViewModel.swift | 401 ++++++++++++++++++ .../ComposeContentViewModel.swift | 117 ++++- .../Publisher/MastodonStatusPublisher.swift | 180 ++++++++ .../View/Container/AttachmentView.swift | 29 -- 30 files changed, 2013 insertions(+), 294 deletions(-) create mode 100644 MastodonSDK/Sources/MastodonCore/AppError.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Extension/FileManager.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Extension/NSItemProvider.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Extension/NSKeyValueObservation.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/PublisherService/PublisherService.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisher.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherReactor.swift create mode 100644 MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherState.swift create mode 100644 MastodonSDK/Sources/MastodonUI/Extension/UIAlertController.swift create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift create mode 100644 MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift delete mode 100644 MastodonSDK/Sources/MastodonUI/View/Container/AttachmentView.swift diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index 9b6674434..34ffd227b 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -72,6 +72,15 @@ "version" : "4.2.2" } }, + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "44e891bdb61426a95e31492a67c7c0dfad1f87c5", + "version" : "7.4.1" + } + }, { "identity" : "metatextkit", "kind" : "remoteSourceControl", diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 54f3903b6..bf9145d6c 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -57,36 +57,35 @@ final class ComposeViewController: UIViewController, NeedsDependency { return barButtonItem }() -// let publishButton: UIButton = { -// let button = RoundedEdgesButton(type: .custom) -// button.cornerRadius = 10 -// button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height -// button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) -// button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) -// return button -// }() -// private(set) lazy var publishBarButtonItem: UIBarButtonItem = { -// configurePublishButtonApperance() -// let shadowBackgroundContainer = ShadowBackgroundContainer() -// publishButton.translatesAutoresizingMaskIntoConstraints = false -// shadowBackgroundContainer.addSubview(publishButton) -// NSLayoutConstraint.activate([ -// publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor), -// publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor), -// publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor), -// publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor), -// ]) -// let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) -// return barButtonItem -// }() -// -// private func configurePublishButtonApperance() { -// publishButton.adjustsImageWhenHighlighted = false -// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) -// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) -// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) -// publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) -// } + let publishButton: UIButton = { + let button = RoundedEdgesButton(type: .custom) + button.cornerRadius = 10 + button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height + button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) + button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) + return button + }() + private(set) lazy var publishBarButtonItem: UIBarButtonItem = { + configurePublishButtonApperance() + let shadowBackgroundContainer = ShadowBackgroundContainer() + publishButton.translatesAutoresizingMaskIntoConstraints = false + shadowBackgroundContainer.addSubview(publishButton) + NSLayoutConstraint.activate([ + publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor), + publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor), + publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor), + publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor), + ]) + let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) + return barButtonItem + }() + private func configurePublishButtonApperance() { + publishButton.adjustsImageWhenHighlighted = false + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal) + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) + publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) + publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) + } // var systemKeyboardHeight: CGFloat = .zero { // didSet { @@ -106,7 +105,6 @@ final class ComposeViewController: UIViewController, NeedsDependency { // var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! // let composeToolbarBackgroundView = UIView() // - // // private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { // let viewController = AutoCompleteViewController() @@ -142,20 +140,20 @@ extension ComposeViewController { super.viewDidLoad() navigationItem.leftBarButtonItem = cancelBarButtonItem - // navigationItem.rightBarButtonItem = publishBarButtonItem - // viewModel.traitCollectionDidChangePublisher - // .receive(on: DispatchQueue.main) - // .sink { [weak self] _ in - // guard let self = self else { return } - // guard self.traitCollection.userInterfaceIdiom == .pad else { return } - // var items = [self.publishBarButtonItem] - // if self.traitCollection.horizontalSizeClass == .regular { - // items.append(self.characterCountBarButtonItem) - // } - // self.navigationItem.rightBarButtonItems = items - // } - // .store(in: &disposeBag) - // publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) + navigationItem.rightBarButtonItem = publishBarButtonItem + viewModel.traitCollectionDidChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.traitCollection.userInterfaceIdiom == .pad else { return } + var items = [self.publishBarButtonItem] + if self.traitCollection.horizontalSizeClass == .regular { + items.append(self.characterCountBarButtonItem) + } + self.navigationItem.rightBarButtonItems = items + } + .store(in: &disposeBag) + publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) addChild(composeContentViewController) composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false @@ -602,8 +600,8 @@ extension ComposeViewController { dismiss(animated: true, completion: nil) } -// @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) // do { // try viewModel.checkAttachmentPrecondition() // } catch { @@ -613,17 +611,32 @@ extension ComposeViewController { // coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) // return // } -// + // guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { // // TODO: handle error // return // } -// -// // context.statusPublishService.publish(composeViewModel: viewModel) -// assertionFailure() -// -// dismiss(animated: true, completion: nil) -// } + + // context.statusPublishService.publish(composeViewModel: viewModel) + + do { + let statusPublisher = try composeContentViewModel.statusPublisher() + // let result = try await statusPublisher.publish(api: context.apiService, authContext: viewModel.authContext) + // if let reactor = presentingViewController?.topMostNotModal as? StatusPublisherReactor { + // statusPublisher.reactor = reactor + // } + viewModel.context.publisherService.enqueue( + statusPublisher: statusPublisher, + authContext: viewModel.authContext + ) + } catch { + let alertController = UIAlertController.standardAlert(of: error) + present(alertController, animated: true) + return + } + + dismiss(animated: true, completion: nil) + } } diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 6e9a50fcc..45c9f1e93 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -44,7 +44,7 @@ final class ComposeViewModel: NSObject { // @Published var autoCompleteRetryLayoutTimes = 0 // @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil -// let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make initial event emit + let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make initial event emit // var isViewAppeared = false // output diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift index 0dabe7790..d976cbff7 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView+EmptyStateView.swift @@ -11,128 +11,127 @@ import MastodonCore import MastodonUI import MastodonLocalization -extension AttachmentContainerView { - final class EmptyStateView: UIView { - - static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate) - static let videoSplashImage: UIImage = { - let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64)) - return image - }() - - let imageView: UIImageView = { - let imageView = UIImageView() - imageView.tintColor = Asset.Colors.Label.secondary.color - imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage - return imageView - }() - let label: UILabel = { - let label = UILabel() - label.font = .preferredFont(forTextStyle: .body) - label.textColor = Asset.Colors.Label.secondary.color - label.textAlignment = .center - label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) - label.numberOfLines = 2 - label.adjustsFontSizeToFitWidth = true - label.minimumScaleFactor = 0.3 - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - - } -} +//extension AttachmentContainerView { +// final class EmptyStateView: UIView { +// +// static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate) +// static let videoSplashImage: UIImage = { +// let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64)) +// return image +// }() +// +// let imageView: UIImageView = { +// let imageView = UIImageView() +// imageView.tintColor = Asset.Colors.Label.secondary.color +// imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage +// return imageView +// }() +// let label: UILabel = { +// let label = UILabel() +// label.font = .preferredFont(forTextStyle: .body) +// label.textColor = Asset.Colors.Label.secondary.color +// label.textAlignment = .center +// label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) +// label.numberOfLines = 2 +// label.adjustsFontSizeToFitWidth = true +// label.minimumScaleFactor = 0.3 +// return label +// }() +// +// override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +// } +//} -extension AttachmentContainerView.EmptyStateView { - private func _init() { - layer.masksToBounds = true - layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius - layer.cornerCurve = .continuous - backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor - - let stackView = UIStackView() - stackView.axis = .vertical - stackView.alignment = .center - stackView.translatesAutoresizingMaskIntoConstraints = false - addSubview(stackView) - NSLayoutConstraint.activate([ - stackView.topAnchor.constraint(equalTo: topAnchor), - stackView.leadingAnchor.constraint(equalTo: leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: trailingAnchor), - stackView.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - let topPaddingView = UIView() - let middlePaddingView = UIView() - let bottomPaddingView = UIView() - - topPaddingView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(topPaddingView) - imageView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(imageView) - NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), - imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh), - ]) - imageView.setContentHuggingPriority(.required - 1, for: .vertical) - middlePaddingView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(middlePaddingView) - stackView.addArrangedSubview(label) - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - stackView.addArrangedSubview(bottomPaddingView) - NSLayoutConstraint.activate([ - topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), - bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), - ]) - } -} - -#if canImport(SwiftUI) && DEBUG -import SwiftUI - -struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider { - - static var previews: some View { - Group { - UIViewPreview(width: 375) { - let emptyStateView = AttachmentContainerView.EmptyStateView() - NSLayoutConstraint.activate([ - emptyStateView.heightAnchor.constraint(equalToConstant: 205) - ]) - return emptyStateView - } - .previewLayout(.fixed(width: 375, height: 205)) - UIViewPreview(width: 375) { - let emptyStateView = AttachmentContainerView.EmptyStateView() - NSLayoutConstraint.activate([ - emptyStateView.heightAnchor.constraint(equalToConstant: 205) - ]) - return emptyStateView - } - .preferredColorScheme(.dark) - .previewLayout(.fixed(width: 375, height: 205)) - UIViewPreview(width: 375) { - let emptyStateView = AttachmentContainerView.EmptyStateView() - emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage - emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) - - NSLayoutConstraint.activate([ - emptyStateView.heightAnchor.constraint(equalToConstant: 205) - ]) - return emptyStateView - } - .previewLayout(.fixed(width: 375, height: 205)) - } - } - -} - -#endif +//extension AttachmentContainerView.EmptyStateView { +// private func _init() { +// layer.masksToBounds = true +// layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius +// layer.cornerCurve = .continuous +// backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor +// +// let stackView = UIStackView() +// stackView.axis = .vertical +// stackView.alignment = .center +// stackView.translatesAutoresizingMaskIntoConstraints = false +// addSubview(stackView) +// NSLayoutConstraint.activate([ +// stackView.topAnchor.constraint(equalTo: topAnchor), +// stackView.leadingAnchor.constraint(equalTo: leadingAnchor), +// stackView.trailingAnchor.constraint(equalTo: trailingAnchor), +// stackView.bottomAnchor.constraint(equalTo: bottomAnchor), +// ]) +// let topPaddingView = UIView() +// let middlePaddingView = UIView() +// let bottomPaddingView = UIView() +// +// topPaddingView.translatesAutoresizingMaskIntoConstraints = false +// stackView.addArrangedSubview(topPaddingView) +// imageView.translatesAutoresizingMaskIntoConstraints = false +// stackView.addArrangedSubview(imageView) +// NSLayoutConstraint.activate([ +// imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), +// imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh), +// ]) +// imageView.setContentHuggingPriority(.required - 1, for: .vertical) +// middlePaddingView.translatesAutoresizingMaskIntoConstraints = false +// stackView.addArrangedSubview(middlePaddingView) +// stackView.addArrangedSubview(label) +// bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false +// stackView.addArrangedSubview(bottomPaddingView) +// NSLayoutConstraint.activate([ +// topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), +// bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), +// ]) +// } +//} +//#if canImport(SwiftUI) && DEBUG +//import SwiftUI +// +//struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider { +// +// static var previews: some View { +// Group { +// UIViewPreview(width: 375) { +// let emptyStateView = AttachmentContainerView.EmptyStateView() +// NSLayoutConstraint.activate([ +// emptyStateView.heightAnchor.constraint(equalToConstant: 205) +// ]) +// return emptyStateView +// } +// .previewLayout(.fixed(width: 375, height: 205)) +// UIViewPreview(width: 375) { +// let emptyStateView = AttachmentContainerView.EmptyStateView() +// NSLayoutConstraint.activate([ +// emptyStateView.heightAnchor.constraint(equalToConstant: 205) +// ]) +// return emptyStateView +// } +// .preferredColorScheme(.dark) +// .previewLayout(.fixed(width: 375, height: 205)) +// UIViewPreview(width: 375) { +// let emptyStateView = AttachmentContainerView.EmptyStateView() +// emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage +// emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video) +// +// NSLayoutConstraint.activate([ +// emptyStateView.heightAnchor.constraint(equalToConstant: 205) +// ]) +// return emptyStateView +// } +// .previewLayout(.fixed(width: 375, height: 205)) +// } +// } +// +//} +// +//#endif diff --git a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift index dd6a2b0a9..4e8fe4e7c 100644 --- a/Mastodon/Scene/Compose/View/AttachmentContainerView.swift +++ b/Mastodon/Scene/Compose/View/AttachmentContainerView.swift @@ -9,10 +9,10 @@ import UIKit import SwiftUI import MastodonUI -final class AttachmentContainerView: UIView { - - static let containerViewCornerRadius: CGFloat = 4 - +//final class AttachmentContainerView: UIView { +// +// static let containerViewCornerRadius: CGFloat = 4 +// // var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? // // let activityIndicatorView: UIActivityIndicatorView = { @@ -60,35 +60,35 @@ final class AttachmentContainerView: UIView { // textView.returnKeyType = .done // return textView // }() - - private(set) lazy var contentView = AttachmentView(viewModel: viewModel) - public var viewModel: AttachmentView.ViewModel! - - override init(frame: CGRect) { - super.init(frame: frame) - _init() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - _init() - } - -} +// +// private(set) lazy var contentView = AttachmentView(viewModel: viewModel) +// public var viewModel: AttachmentView.ViewModel! +// +// override init(frame: CGRect) { +// super.init(frame: frame) +// _init() +// } +// +// required init?(coder: NSCoder) { +// super.init(coder: coder) +// _init() +// } +// +//} -extension AttachmentContainerView { - - private func _init() { - let hostingViewController = UIHostingController(rootView: contentView) - hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false - addSubview(hostingViewController.view) - NSLayoutConstraint.activate([ - hostingViewController.view.topAnchor.constraint(equalTo: topAnchor), - hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor), - hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor), - hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) - +//extension AttachmentContainerView { +// +// private func _init() { +// let hostingViewController = UIHostingController(rootView: contentView) +// hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false +// addSubview(hostingViewController.view) +// NSLayoutConstraint.activate([ +// hostingViewController.view.topAnchor.constraint(equalTo: topAnchor), +// hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor), +// hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor), +// hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor), +// ]) +// // previewImageView.translatesAutoresizingMaskIntoConstraints = false // addSubview(previewImageView) // NSLayoutConstraint.activate([ @@ -144,24 +144,24 @@ extension AttachmentContainerView { // activityIndicatorView.startAnimating() // // descriptionTextView.delegate = self - } - -// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { +// } +// +//// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { // super.traitCollectionDidChange(previousTraitCollection) // // setupBroader() // } - -} - -extension AttachmentContainerView { - +// +//} +// +//extension AttachmentContainerView { +// // private func setupBroader() { // emptyStateView.layer.borderWidth = 1 // emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil // } - -} +// +//} //// MARK: - UITextViewDelegate //extension AttachmentContainerView: UITextViewDelegate { diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index b287f3b71..3efcd5cbe 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -165,6 +165,7 @@ extension HomeTimelineViewController { tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) + // // layout publish progress publishProgressView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(publishProgressView) NSLayoutConstraint.activate([ @@ -204,10 +205,12 @@ extension HomeTimelineViewController { } .store(in: &disposeBag) - viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress + context.publisherService.$currentPublishProgress .receive(on: DispatchQueue.main) .sink { [weak self] progress in guard let self = self else { return } + let progress = Float(progress) + guard progress > 0 else { let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) dismissAnimator.addAnimations { diff --git a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift index 09e750ed1..79f568edf 100644 --- a/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/View/HomeTimelineNavigationBarTitleViewModel.swift @@ -49,6 +49,29 @@ final class HomeTimelineNavigationBarTitleViewModel { .assign(to: \.value, on: isOffline) .store(in: &disposeBag) + Publishers.CombineLatest( + context.publisherService.$statusPublishers, + context.publisherService.statusPublishResult.prepend(.failure(AppError.badRequest)) + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] statusPublishers, publishResult in + guard let self = self else { return } + + if statusPublishers.isEmpty { + self.isPublishingPost.value = false + self.isPublished.value = false + } else { + self.isPublishingPost.value = true + switch publishResult { + case .success: + self.isPublished.value = true + case .failure: + self.isPublished.value = false + } + } + } + .store(in: &disposeBag) + // context.statusPublishService.latestPublishingComposeViewModel // .receive(on: DispatchQueue.main) // .sink { [weak self] composeViewModel in @@ -82,19 +105,19 @@ final class HomeTimelineNavigationBarTitleViewModel { .assign(to: \.value, on: state) .store(in: &disposeBag) - state - .removeDuplicates() - .receive(on: DispatchQueue.main) - .sink { [weak self] state in - guard let self = self else { return } - switch state { - case .publishingPostLabel: - self.setupPublishingProgress() - default: - self.suspendPublishingProgress() - } - } - .store(in: &disposeBag) +// state +// .removeDuplicates() +// .receive(on: DispatchQueue.main) +// .sink { [weak self] state in +// guard let self = self else { return } +// switch state { +// case .publishingPostLabel: +// self.setupPublishingProgress() +// default: +// self.suspendPublishingProgress() +// } +// } +// .store(in: &disposeBag) } } @@ -150,26 +173,26 @@ extension HomeTimelineNavigationBarTitleViewModel { } // MARK: Publish post state -extension HomeTimelineNavigationBarTitleViewModel { - - func setupPublishingProgress() { - let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS - .autoconnect() - .share() - .eraseToAnyPublisher() - - publishingProgressSubscription = progressUpdatePublisher - .map { _ in Float(0) } - .scan(0.0) { progress, _ -> Float in - return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS) - } - .subscribe(publishingProgress) - } - - func suspendPublishingProgress() { - publishingProgressSubscription = nil - publishingProgress.send(0) - } - -} +//extension HomeTimelineNavigationBarTitleViewModel { +// +// func setupPublishingProgress() { +// let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS +// .autoconnect() +// .share() +// .eraseToAnyPublisher() +// +// publishingProgressSubscription = progressUpdatePublisher +// .map { _ in Float(0) } +// .scan(0.0) { progress, _ -> Float in +// return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS) +// } +// .subscribe(publishingProgress) +// } +// +// func suspendPublishingProgress() { +// publishingProgressSubscription = nil +// publishingProgress.send(0) +// } +// +//} diff --git a/MastodonSDK/Package.swift b/MastodonSDK/Package.swift index 0d3e562c0..a9c9ab9db 100644 --- a/MastodonSDK/Package.swift +++ b/MastodonSDK/Package.swift @@ -49,6 +49,7 @@ let package = Package( .package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"), .package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"), .package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"), + .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.4.1"), ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -124,6 +125,7 @@ let package = Package( .product(name: "CropViewController", package: "TOCropViewController"), .product(name: "PanModal", package: "PanModal"), .product(name: "Stripes", package: "Stripes"), + .product(name: "Kingfisher", package: "Kingfisher"), ] ), .testTarget( diff --git a/MastodonSDK/Sources/MastodonCore/AppContext.swift b/MastodonSDK/Sources/MastodonCore/AppContext.swift index 1965a91e2..d44c1ea5a 100644 --- a/MastodonSDK/Sources/MastodonCore/AppContext.swift +++ b/MastodonSDK/Sources/MastodonCore/AppContext.swift @@ -24,7 +24,8 @@ public class AppContext: ObservableObject { public let apiService: APIService public let authenticationService: AuthenticationService public let emojiService: EmojiService - public let statusPublishService = StatusPublishService() + // public let statusPublishService = StatusPublishService() + public let publisherService: PublisherService public let notificationService: NotificationService public let settingService: SettingService public let instanceService: InstanceService @@ -67,6 +68,8 @@ public class AppContext: ObservableObject { apiService: apiService ) + publisherService = .init(apiService: _apiService) + let _notificationService = NotificationService( apiService: _apiService, authenticationService: _authenticationService diff --git a/MastodonSDK/Sources/MastodonCore/AppError.swift b/MastodonSDK/Sources/MastodonCore/AppError.swift new file mode 100644 index 000000000..a8aea55b9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/AppError.swift @@ -0,0 +1,13 @@ +// +// AppError.swift +// +// +// Created by MainasuK on 2022-8-8. +// + +import Foundation + +public enum AppError: Error { + case badRequest + case badAuthentication +} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/FileManager.swift b/MastodonSDK/Sources/MastodonCore/Extension/FileManager.swift new file mode 100644 index 000000000..9a7ee6601 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Extension/FileManager.swift @@ -0,0 +1,28 @@ +// +// FileManager.swift +// +// +// Created by MainasuK on 2022-1-15. +// + +import os.log +import Foundation + +extension FileManager { + static let logger = Logger(subsystem: "FileManager", category: "File") + + public func createTemporaryFileURL( + filename: String, + pathExtension: String + ) throws -> URL { + let tempDirectoryURL = FileManager.default.temporaryDirectory + let fileURL = tempDirectoryURL + .appendingPathComponent(filename) + .appendingPathExtension(pathExtension) + try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) + + Self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): create temporary file at: \(fileURL.debugDescription)") + + return fileURL + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/NSItemProvider.swift b/MastodonSDK/Sources/MastodonCore/Extension/NSItemProvider.swift new file mode 100644 index 000000000..c6fbff4f5 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Extension/NSItemProvider.swift @@ -0,0 +1,145 @@ +// +// NSItemProvider.swift +// +// +// Created by MainasuK on 2021/11/19. +// + +import os.log +import Foundation +import UniformTypeIdentifiers +import MobileCoreServices +import PhotosUI + +// load image with low memory usage +// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/ + +extension NSItemProvider { + + static let logger = Logger(subsystem: "NSItemProvider", category: "Logic") + + public struct ImageLoadResult { + public let data: Data + public let type: UTType? + + public init(data: Data, type: UTType?) { + self.data = data + self.type = type + } + } + + public func loadImageData() async throws -> ImageLoadResult? { + try await withCheckedThrowingContinuation { continuation in + loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in + if let error = error { + continuation.resume(with: .failure(error)) + return + } + + guard let url = url else { + continuation.resume(with: .success(nil)) + assertionFailure() + return + } + + let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary + guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else { + return + } + + #if APP_EXTENSION + let maxPixelSize: Int = 4096 // not limit but may upload fail + #else + let maxPixelSize: Int = 1536 // fit 120MB RAM limit + #endif + + let downsampleOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] as CFDictionary + + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else { + continuation.resume(with: .success(nil)) + return + } + + let data = NSMutableData() + guard let imageDestination = CGImageDestinationCreateWithData(data, kUTTypeJPEG, 1, nil) else { + continuation.resume(with: .success(nil)) + assertionFailure() + return + } + + let isPNG: Bool = { + guard let utType = cgImage.utType else { return false } + return (utType as String) == UTType.png.identifier + + }() + + let destinationProperties = [ + kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75 + ] as CFDictionary + + CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties) + CGImageDestinationFinalize(imageDestination) + + let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory) + NSItemProvider.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load image \(dataSize)") + + let result = ImageLoadResult( + data: data as Data, + type: cgImage.utType.flatMap { UTType($0 as String) } + ) + + continuation.resume(with: .success(result)) + } + } + } + +} + +extension NSItemProvider { + + public struct VideoLoadResult { + public let url: URL + public let sizeInBytes: UInt64 + } + + public func loadVideoData() async throws -> VideoLoadResult? { + try await withCheckedThrowingContinuation { continuation in + loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in + if let error = error { + continuation.resume(with: .failure(error)) + return + } + + guard let url = url, + let attribute = try? FileManager.default.attributesOfItem(atPath: url.path), + let sizeInBytes = attribute[.size] as? UInt64 + else { + continuation.resume(with: .success(nil)) + assertionFailure() + return + } + + do { + let fileURL = try FileManager.default.createTemporaryFileURL( + filename: UUID().uuidString, + pathExtension: url.pathExtension + ) + try FileManager.default.copyItem(at: url, to: fileURL) + let result = VideoLoadResult( + url: fileURL, + sizeInBytes: sizeInBytes + ) + + continuation.resume(with: .success(result)) + } catch { + continuation.resume(with: .failure(error)) + } + } // end loadFileRepresentation + } // end try await withCheckedThrowingContinuation + } // end func + +} diff --git a/MastodonSDK/Sources/MastodonCore/Extension/NSKeyValueObservation.swift b/MastodonSDK/Sources/MastodonCore/Extension/NSKeyValueObservation.swift new file mode 100644 index 000000000..9d1088ead --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Extension/NSKeyValueObservation.swift @@ -0,0 +1,15 @@ +// +// NSKeyValueObservation.swift +// Twidere +// +// Created by Cirno MainasuK on 2020-7-20. +// Copyright © 2020 Twidere. All rights reserved. +// + +import Foundation + +extension NSKeyValueObservation { + public func store(in set: inout Set) { + set.insert(self) + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift index 53f25a036..384cb49d2 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/Poll/PollComposeItem.swift @@ -94,12 +94,14 @@ extension PollComposeItem { public final class MultipleConfiguration: Hashable, ObservableObject { private let id = UUID() - @Published public var isMultiple = false + @Published public var isMultiple: Option = false public init() { // end init } + public typealias Option = Bool + public static func == (lhs: MultipleConfiguration, rhs: MultipleConfiguration) -> Bool { return lhs.id == rhs.id && lhs.isMultiple == rhs.isMultiple diff --git a/MastodonSDK/Sources/MastodonCore/Service/API/APIService.swift b/MastodonSDK/Sources/MastodonCore/Service/API/APIService.swift index d89760576..44eb9938e 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/API/APIService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/API/APIService.swift @@ -25,7 +25,7 @@ public final class APIService { let session: URLSession // input - let backgroundManagedObjectContext: NSManagedObjectContext + public let backgroundManagedObjectContext: NSManagedObjectContext // output public let error = PassthroughSubject() diff --git a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/PublisherService.swift b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/PublisherService.swift new file mode 100644 index 000000000..1c62a38d2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/PublisherService.swift @@ -0,0 +1,109 @@ +// +// PublisherService.swift +// +// +// Created by MainasuK on 2021-12-2. +// + +import os.log +import UIKit +import Combine + +public final class PublisherService { + + var disposeBag = Set() + + let logger = Logger(subsystem: "PublisherService", category: "Service") + + // input + let apiService: APIService + + @Published public private(set) var statusPublishers: [StatusPublisher] = [] + + // output + public let statusPublishResult = PassthroughSubject, Never>() + + var currentPublishProgressObservation: NSKeyValueObservation? + @Published public var currentPublishProgress: Double = 0 + + public init( + apiService: APIService + ) { + self.apiService = apiService + + $statusPublishers + .receive(on: DispatchQueue.main) + .sink { [weak self] publishers in + guard let self = self else { return } + guard let last = publishers.last else { + self.currentPublishProgressObservation = nil + return + } + + self.currentPublishProgressObservation = last.progress + .observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in + guard let self = self else { return } + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)") + self.currentPublishProgress = progress.fractionCompleted + } + } + .store(in: &disposeBag) + + $statusPublishers + .filter { $0.isEmpty } + .delay(for: 1, scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.currentPublishProgress = 0 + } + .store(in: &disposeBag) + + statusPublishResult + .receive(on: DispatchQueue.main) + .sink { result in + switch result { + case .success: + break + // TODO: + // update store review count trigger + // UserDefaults.shared.storeReviewInteractTriggerCount += 1 + case .failure: + break + } + } + .store(in: &disposeBag) + } + +} + +extension PublisherService { + + @MainActor + public func enqueue(statusPublisher publisher: StatusPublisher, authContext: AuthContext) { + guard !statusPublishers.contains(where: { $0 === publisher }) else { + assertionFailure() + return + } + statusPublishers.append(publisher) + + Task { + do { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish status…") + let result = try await publisher.publish(api: apiService, authContext: authContext) + + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish status success") + self.statusPublishResult.send(.success(result)) + self.statusPublishers.removeAll(where: { $0 === publisher }) + + } catch is CancellationError { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish cancelled") + self.statusPublishers.removeAll(where: { $0 === publisher }) + + } catch { + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish failure: \(error.localizedDescription)") + self.statusPublishResult.send(.failure(error)) + self.currentPublishProgress = 0 + } + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift new file mode 100644 index 000000000..63b8650f2 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublishResult.swift @@ -0,0 +1,13 @@ +// +// StatusPublishResult.swift +// +// +// Created by MainasuK on 2021-11-26. +// + +import Foundation +import MastodonSDK + +public enum StatusPublishResult { + case mastodon(Mastodon.Response.Content) +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisher.swift b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisher.swift new file mode 100644 index 000000000..e87a6bad1 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisher.swift @@ -0,0 +1,14 @@ +// +// StatusPublisher.swift +// +// +// Created by MainasuK on 2021-11-26. +// + +import Foundation + +public protocol StatusPublisher: ProgressReporting { + var state: Published.Publisher { get } + var reactor: StatusPublisherReactor? { get set } + func publish(api: APIService, authContext: AuthContext) async throws -> StatusPublishResult +} diff --git a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherReactor.swift b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherReactor.swift new file mode 100644 index 000000000..03d65f94c --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherReactor.swift @@ -0,0 +1,10 @@ +// +// StatusPublisherReactor.swift +// +// +// Created by MainasuK on 2022/10/27. +// + +import Foundation + +public protocol StatusPublisherReactor: AnyObject { } diff --git a/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherState.swift b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherState.swift new file mode 100644 index 000000000..745217ee4 --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Service/PublisherService/StatusPublisherState.swift @@ -0,0 +1,14 @@ +// +// StatusPublisherState.swift +// +// +// Created by MainasuK on 2021-11-26. +// + +import Foundation + +public enum StatusPublisherState { + case pending + case failure(Error) + case success +} diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift index da77c65a1..1bb4014df 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Media.swift @@ -44,6 +44,20 @@ extension Mastodon.API.Media { request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment let serialStream = query.serialStream request.httpBodyStream = serialStream.boundStreams.input + + // total unit count in bytes count + // will small than actally count due to multipart protocol meta + serialStream.progress.totalUnitCount = { + var size = 0 + size += query.file?.sizeInByte ?? 0 + size += query.thumbnail?.sizeInByte ?? 0 + return Int64(size) + }() + query.progress.addChild( + serialStream.progress, + withPendingUnitCount: query.progress.totalUnitCount + ) + return session.dataTaskPublisher(for: request) .tryMap { data, response in let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) @@ -62,6 +76,12 @@ extension Mastodon.API.Media { public let description: String? public let focus: String? + public let progress: Progress = { + let progress = Progress() + progress.totalUnitCount = 100 + return progress + }() + public init( file: Mastodon.Query.MediaAttachment?, thumbnail: Mastodon.Query.MediaAttachment?, diff --git a/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift index ca9388cac..f1fdac8bb 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/MediaAttachment.swift @@ -53,6 +53,18 @@ extension Mastodon.Query.MediaAttachment { var base64EncondedString: String? { return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() } } + + var sizeInByte: Int? { + switch self { + case .jpeg(let data), .gif(let data), .png(let data): + return data?.count + case .other(let url, _, _): + guard let url = url else { return nil } + guard let attribute = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil } + guard let size = attribute[.size] as? UInt64 else { return nil } + return Int(size) + } + } } extension Mastodon.Query.MediaAttachment: MultipartFormValue { diff --git a/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift b/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift index cde09b71b..5d806b6ba 100644 --- a/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift +++ b/MastodonSDK/Sources/MastodonSDK/Query/SerialStream.swift @@ -15,6 +15,10 @@ import Combine // - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3 final class SerialStream: NSObject { + + let logger = Logger(subsystem: "SerialStream", category: "Stream") + + public let progress = Progress() var writingTimerSubscriber: AnyCancellable? // serial stream source @@ -70,10 +74,14 @@ final class SerialStream: NSObject { var baseAddress = 0 var remainsBytes = readBytesCount while remainsBytes > 0 { - let result = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes) - baseAddress += result - remainsBytes -= result - os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, result) + let writeResult = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes) + baseAddress += writeResult + remainsBytes -= writeResult + + os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, writeResult) + + self.progress.completedUnitCount += Int64(writeResult) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): estimate progress: \(self.progress.completedUnitCount)/\(self.progress.totalUnitCount)") } } diff --git a/MastodonSDK/Sources/MastodonUI/Extension/UIAlertController.swift b/MastodonSDK/Sources/MastodonUI/Extension/UIAlertController.swift new file mode 100644 index 000000000..5467a026b --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Extension/UIAlertController.swift @@ -0,0 +1,37 @@ +// +// UIAlertController.swift +// TwidereX +// +// Created by Cirno MainasuK on 2020-7-1. +// Copyright © 2020 Dimension. All rights reserved. +// + +import UIKit + +extension UIAlertController { + + public static func standardAlert(of error: Error) -> UIAlertController { + let title: String? = { + if let error = error as? LocalizedError { + return error.errorDescription + } else { + return "Error" + } + }() + + let message: String? = { + if let error = error as? LocalizedError { + return [error.failureReason, error.recoverySuggestion].compactMap { $0 }.joined(separator: "\n") + } else { + return error.localizedDescription + } + }() + + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + let okAction = UIAlertAction(title: "OK", style: .default, handler: nil) + alertController.addAction(okAction) + + return alertController + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift new file mode 100644 index 000000000..f4d1397a9 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentView.swift @@ -0,0 +1,246 @@ +// +// AttachmentView.swift +// +// +// Created by MainasuK on 2022-5-20. +// + +import os.log +import UIKit +import SwiftUI +import Introspect +import AVKit + +public struct AttachmentView: View { + + static let size = CGSize(width: 56, height: 56) + static let cornerRadius: CGFloat = 8 + + @ObservedObject var viewModel: AttachmentViewModel + + let action: (Action) -> Void + + @State var isCaptionEditorPresented = false + @State var caption = "" + + public var body: some View { + Text("Hello") +// Menu { +// menu +// } label: { +// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) +// Image(uiImage: image) +// .resizable() +// .aspectRatio(contentMode: .fill) +// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height) +// .overlay { +// ZStack { +// // spinner +// if viewModel.output == nil { +// Color.clear +// .background(.ultraThinMaterial) +// ProgressView() +// .progressViewStyle(CircularProgressViewStyle()) +// .foregroundStyle(.regularMaterial) +// } +// // border +// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius) +// .stroke(Color.black.opacity(0.05)) +// } +// .transition(.opacity) +// } +// .overlay(alignment: .bottom) { +// HStack(alignment: .bottom) { +// // alt +// VStack(spacing: 2) { +// switch viewModel.output { +// case .video: +// Image(uiImage: Asset.Media.playerRectangle.image) +// .resizable() +// .frame(width: 16, height: 12) +// default: +// EmptyView() +// } +// if !viewModel.caption.isEmpty { +// Image(uiImage: Asset.Media.altRectangle.image) +// .resizable() +// .frame(width: 16, height: 12) +// } +// } +// Spacer() +// // option +// Image(systemName: "ellipsis") +// .resizable() +// .frame(width: 12, height: 12) +// .symbolVariant(.circle) +// .symbolVariant(.fill) +// .symbolRenderingMode(.palette) +// .foregroundStyle(.white, .black) +// } +// .padding(6) +// } +// .cornerRadius(AttachmentView.cornerRadius) +// } // end Menu +// .sheet(isPresented: $isCaptionEditorPresented) { +// captionSheet +// } // end caption sheet +// .sheet(isPresented: $viewModel.isPreviewPresented) { +// previewSheet +// } // end preview sheet + + } // end body + +// var menu: some View { +// Group { +// Button( +// action: { +// action(.preview) +// }, +// label: { +// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo") +// } +// ) +// // caption +// let canAddCaption: Bool = { +// switch viewModel.output { +// case .image: return true +// case .video: return false +// case .none: return false +// } +// }() +// if canAddCaption { +// Button( +// action: { +// action(.caption) +// caption = viewModel.caption +// isCaptionEditorPresented.toggle() +// }, +// label: { +// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update +// Label(title, systemImage: "text.bubble") +// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu +// // add caption subtitle +// } +// ) +// } +// Divider() +// // remove +// Button( +// role: .destructive, +// action: { +// action(.remove) +// }, +// label: { +// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle") +// } +// ) +// } +// } + +// var captionSheet: some View { +// NavigationView { +// ScrollView(.vertical) { +// VStack { +// // preview +// switch viewModel.output { +// case .image: +// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) +// Image(uiImage: image) +// .resizable() +// .aspectRatio(contentMode: .fill) +// case .video(let url, _): +// let player = AVPlayer(url: url) +// VideoPlayer(player: player) +// .frame(height: 300) +// case .none: +// EmptyView() +// } +// // caption textField +// TextField( +// text: $caption, +// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage) +// ) { +// Text(L10n.Scene.Compose.Media.Caption.update) +// } +// .padding() +// .introspectTextField { textField in +// textField.becomeFirstResponder() +// } +// } +// } +// .navigationTitle(L10n.Scene.Compose.Media.Caption.update) +// .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem(placement: .navigationBarLeading) { +// Button { +// isCaptionEditorPresented.toggle() +// } label: { +// Image(systemName: "xmark.circle.fill") +// .resizable() +// .frame(width: 30, height: 30, alignment: .center) +// .symbolRenderingMode(.hierarchical) +// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel)) +// } +// } +// ToolbarItem(placement: .navigationBarTrailing) { +// Button { +// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines) +// isCaptionEditorPresented.toggle() +// } label: { +// Text(L10n.Common.Controls.Actions.save) +// } +// } +// } +// } // end NavigationView +// } + + // design for share extension + // preferred UIKit preview in app +// var previewSheet: some View { +// NavigationView { +// ScrollView(.vertical) { +// VStack { +// // preview +// switch viewModel.output { +// case .image: +// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3) +// Image(uiImage: image) +// .resizable() +// .aspectRatio(contentMode: .fill) +// case .video(let url, _): +// let player = AVPlayer(url: url) +// VideoPlayer(player: player) +// .frame(height: 300) +// case .none: +// EmptyView() +// } +// Spacer() +// } +// } +// .navigationTitle(L10n.Scene.Compose.Media.preview) +// .navigationBarTitleDisplayMode(.inline) +// .toolbar { +// ToolbarItem(placement: .navigationBarLeading) { +// Button { +// viewModel.isPreviewPresented.toggle() +// } label: { +// Image(systemName: "xmark.circle.fill") +// .resizable() +// .frame(width: 30, height: 30, alignment: .center) +// .symbolRenderingMode(.hierarchical) +// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel)) +// } +// } +// } +// } // end NavigationView +// } + +} + +extension AttachmentView { + public enum Action: Hashable { + case preview + case caption + case remove + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift new file mode 100644 index 000000000..0a4aadec3 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel+Upload.swift @@ -0,0 +1,316 @@ +// +// AttachmentViewModel+Upload.swift +// +// +// Created by MainasuK on 2021-11-26. +// + +import os.log +import UIKit +import Kingfisher +import UniformTypeIdentifiers +import MastodonCore +import MastodonSDK + +// objc.io +// ref: https://talk.objc.io/episodes/S01E269-swift-concurrency-async-sequences-part-1 +struct Chunked: AsyncSequence where Base.Element == UInt8 { + var base: Base + var chunkSize: Int = 1 * 1024 * 1024 // 1 MiB + typealias Element = Data + + struct AsyncIterator: AsyncIteratorProtocol { + var base: Base.AsyncIterator + var chunkSize: Int + + mutating func next() async throws -> Data? { + var result = Data() + while let element = try await base.next() { + result.append(element) + if result.count == chunkSize { return result } + } + return result.isEmpty ? nil : result + } + } + + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(base: base.makeAsyncIterator(), chunkSize: chunkSize) + } +} + +extension AsyncSequence where Element == UInt8 { + var chunked: Chunked { + Chunked(base: self) + } +} + +extension Data { + fileprivate func chunks(size: Int) -> [Data] { + return stride(from: 0, to: count, by: size).map { + Data(self[$0.. +// let chunkCount: Int +// let type: UTType +// let sizeInBytes: UInt64 +// +// public init?( +// url: URL, +// type: UTType +// ) { +// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil } +// let _sizeInBytes: UInt64? = { +// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path) +// return attribute?[.size] as? UInt64 +// }() +// guard let sizeInBytes = _sizeInBytes else { return nil } +// +// self.fileURL = url +// self.chunks = chunks +// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes) +// self.type = type +// self.sizeInBytes = sizeInBytes +// } +// +// public init?( +// imageData: Data, +// type: UTType +// ) { +// let _fileURL = try? FileManager.default.createTemporaryFileURL( +// filename: UUID().uuidString, +// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg" +// ) +// guard let fileURL = _fileURL else { return nil } +// +// do { +// try imageData.write(to: fileURL) +// } catch { +// return nil +// } +// +// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else { +// return nil +// } +// let sizeInBytes = UInt64(imageData.count) +// +// self.fileURL = fileURL +// self.chunks = chunks +// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes) +// self.type = type +// self.sizeInBytes = sizeInBytes +// } +// +// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int { +// guard sizeInBytes > 0 else { return 0 } +// let count = sizeInBytes / chunkSize +// let remains = sizeInBytes % chunkSize +// let result = remains > 0 ? count + 1 : count +// return Int(result) +// } +// +// } +// +// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? { +// // needs execute in background +// assert(!Thread.isMainThread) +// +// // try png then use JPEG compress with Q=0.8 +// // then slice into 1MiB chunks +// switch output { +// case .image(let data, _): +// let maxPayloadSizeInBytes = sizeLimit.image +// +// // use processed imageData to remove EXIF +// guard let image = UIImage(data: data), +// var imageData = image.pngData() +// else { return nil } +// +// var didRemoveEXIF = false +// repeat { +// guard let image = KFCrossPlatformImage(data: imageData) else { return nil } +// if imageData.kf.imageFormat == .PNG { +// // A. png image +// guard let pngData = image.pngData() else { return nil } +// didRemoveEXIF = true +// if pngData.count > maxPayloadSizeInBytes { +// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil } +// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024) +// imageData = compressedJpegData +// } else { +// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024) +// imageData = pngData +// } +// } else { +// // B. other image +// if !didRemoveEXIF { +// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil } +// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024) +// imageData = jpegData +// didRemoveEXIF = true +// } else { +// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8) +// let scaledImage = image.af.imageScaled(to: targetSize) +// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil } +// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024) +// imageData = compressedJpegData +// } +// } +// } while (imageData.count > maxPayloadSizeInBytes) +// +// return SliceResult( +// imageData: imageData, +// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg +// ) +// +//// case .gif(let url): +//// fatalError() +// case .video(let url, _): +// return SliceResult( +// url: url, +// type: .movie +// ) +// } +// } +//} + +extension AttachmentViewModel { + struct UploadContext { + let apiService: APIService + let authContext: AuthContext + } + + enum UploadResult { + case mastodon(Mastodon.Response.Content) + } +} + +extension AttachmentViewModel { + func upload(context: UploadContext) async throws -> UploadResult { + return try await uploadMastodonMedia( + context: context + ) + } + + private func uploadMastodonMedia( + context: UploadContext + ) async throws -> UploadResult { + guard let output = self.output else { + throw AppError.badRequest + } + + let attachment = output.asAttachment + + let query = Mastodon.API.Media.UploadMediaQuery( + file: attachment, + thumbnail: nil, + description: { + let caption = caption.trimmingCharacters(in: .whitespacesAndNewlines) + return caption.isEmpty ? nil : caption + }(), + focus: nil // TODO: + ) + + // upload + N * check upload + // upload : check = 9 : 1 + let uploadTaskCount: Int64 = 540 + let checkUploadTaskCount: Int64 = 1 + let checkUploadTaskRetryLimit: Int64 = 60 + + progress.totalUnitCount = uploadTaskCount + checkUploadTaskCount * checkUploadTaskRetryLimit + progress.completedUnitCount = 0 + + let attachmentUploadResponse: Mastodon.Response.Content = try await { + do { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [V2] upload attachment...") + + progress.addChild(query.progress, withPendingUnitCount: uploadTaskCount) + return try await context.apiService.uploadMedia( + domain: context.authContext.mastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox, + needsFallback: false + ).singleOutput() + } catch { + // check needs fallback + guard let apiError = error as? Mastodon.API.Error, + apiError.httpResponseStatus == .notFound + else { throw error } + + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [V1] upload attachment...") + + progress.addChild(query.progress, withPendingUnitCount: uploadTaskCount) + return try await context.apiService.uploadMedia( + domain: context.authContext.mastodonAuthenticationBox.domain, + query: query, + mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox, + needsFallback: true + ).singleOutput() + } + }() + + // check needs wait processing (until get the `url`) + if attachmentUploadResponse.statusCode == 202 { + // note: + // the Mastodon server append the attachments in order by upload time + // can not upload concurrency + let waitProcessRetryLimit = checkUploadTaskRetryLimit + var waitProcessRetryCount: Int64 = 0 + + repeat { + defer { + // make sure always count + 1 + waitProcessRetryCount += checkUploadTaskCount + } + + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): check attachment process status") + + let attachmentStatusResponse = try await context.apiService.getMedia( + attachmentID: attachmentUploadResponse.value.id, + mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox + ).singleOutput() + progress.completedUnitCount += checkUploadTaskCount + + if let url = attachmentStatusResponse.value.url { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment process finish: \(url)") + + // escape here + progress.completedUnitCount = progress.totalUnitCount + return .mastodon(attachmentStatusResponse) + + } else { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)") + await Task.sleep(1_000_000_000 * 3) // 3s + } + } while waitProcessRetryCount < waitProcessRetryLimit + + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing result discard due to exceed retry limit") + throw AppError.badRequest + } else { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "")") + + return .mastodon(attachmentUploadResponse) + } + } +} + +extension AttachmentViewModel.Output { + var asAttachment: Mastodon.Query.MediaAttachment { + switch self { + case .image(let data, let kind): + switch kind { + case .png: return .png(data) + case .jpg: return .jpeg(data) + } + case .video(let url, _): + return .other(url, fileExtension: url.pathExtension, mimeType: "video/mp4") + } + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift new file mode 100644 index 000000000..7d0e8c859 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -0,0 +1,401 @@ +// +// AttachmentViewModel.swift +// +// +// Created by MainasuK on 2021/11/19. +// + +import os.log +import UIKit +import Combine +import PhotosUI +import Kingfisher +import MastodonCore + +final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable { + + static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel") + + public let id = UUID() + + var disposeBag = Set() + var observations = Set() + + // input + public let input: Input + @Published var caption = "" + @Published var sizeLimit = SizeLimit() + @Published public var isPreviewPresented = false + + // output + @Published public private(set) var output: Output? + @Published public private(set) var thumbnail: UIImage? // original size image thumbnail + @Published var error: Error? + let progress = Progress() // upload progress + + public init(input: Input) { + self.input = input + super.init() + // end init + + defer { + load(input: input) + } + + $output + .map { output -> UIImage? in + switch output { + case .image(let data, _): + return UIImage(data: data) + case .video(let url, _): + return AttachmentViewModel.createThumbnailForVideo(url: url) + case .none: + return nil + } + } + .assign(to: &$thumbnail) + } + + deinit { + switch output { + case .image: + // FIXME: + break + case .video(let url, _): + try? FileManager.default.removeItem(at: url) + case nil : + break + } + } +} + +extension AttachmentViewModel { + public enum Input: Hashable { + case image(UIImage) + case url(URL) + case pickerResult(PHPickerResult) + case itemProvider(NSItemProvider) + } + + public enum Output { + case image(Data, imageKind: ImageKind) + // case gif(Data) + case video(URL, mimeType: String) // assert use file for video only + + public enum ImageKind { + case png + case jpg + } + + public var twitterMediaCategory: TwitterMediaCategory { + switch self { + case .image: return .image + case .video: return .amplifyVideo + } + } + } + + public struct SizeLimit { + public let image: Int + public let gif: Int + public let video: Int + + public init( + image: Int = 5 * 1024 * 1024, // 5 MiB, + gif: Int = 15 * 1024 * 1024, // 15 MiB, + video: Int = 512 * 1024 * 1024 // 512 MiB + ) { + self.image = image + self.gif = gif + self.video = video + } + } + + public enum AttachmentError: Error { + case invalidAttachmentType + case attachmentTooLarge + } + + public enum TwitterMediaCategory: String { + case image = "TWEET_IMAGE" + case GIF = "TWEET_GIF" + case video = "TWEET_VIDEO" + case amplifyVideo = "AMPLIFY_VIDEO" + } +} + +extension AttachmentViewModel { + + private func load(input: Input) { + switch input { + case .image(let image): + guard let data = image.pngData() else { + error = AttachmentError.invalidAttachmentType + return + } + output = .image(data, imageKind: .png) + case .url(let url): + Task { @MainActor in + do { + let output = try await AttachmentViewModel.load(url: url) + self.output = output + } catch { + self.error = error + } + } // end Task + case .pickerResult(let pickerResult): + Task { @MainActor in + do { + let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider) + self.output = output + } catch { + self.error = error + } + } // end Task + case .itemProvider(let itemProvider): + Task { @MainActor in + do { + let output = try await AttachmentViewModel.load(itemProvider: itemProvider) + self.output = output + } catch { + self.error = error + } + } // end Task + } + } + + private static func load(url: URL) async throws -> Output { + guard let uti = UTType(filenameExtension: url.pathExtension) else { + throw AttachmentError.invalidAttachmentType + } + + if uti.conforms(to: .image) { + guard url.startAccessingSecurityScopedResource() else { + throw AttachmentError.invalidAttachmentType + } + defer { url.stopAccessingSecurityScopedResource() } + let imageData = try Data(contentsOf: url) + return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg) + } else if uti.conforms(to: .movie) { + guard url.startAccessingSecurityScopedResource() else { + throw AttachmentError.invalidAttachmentType + } + defer { url.stopAccessingSecurityScopedResource() } + + let fileName = UUID().uuidString + let tempDirectoryURL = FileManager.default.temporaryDirectory + let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension) + try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.copyItem(at: url, to: fileURL) + return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4") + } else { + throw AttachmentError.invalidAttachmentType + } + } + + private static func load(itemProvider: NSItemProvider) async throws -> Output { + if itemProvider.isImage() { + guard let result = try await itemProvider.loadImageData() else { + throw AttachmentError.invalidAttachmentType + } + let imageKind: Output.ImageKind = { + if let type = result.type { + if type == UTType.png { + return .png + } + if type == UTType.jpeg { + return .jpg + } + } + + let imageData = result.data + + if imageData.kf.imageFormat == .PNG { + return .png + } + if imageData.kf.imageFormat == .JPEG { + return .jpg + } + + assertionFailure("unknown image kind") + return .jpg + }() + return .image(result.data, imageKind: imageKind) + } else if itemProvider.isMovie() { + guard let result = try await itemProvider.loadVideoData() else { + throw AttachmentError.invalidAttachmentType + } + return .video(result.url, mimeType: "video/mp4") + } else { + assertionFailure() + throw AttachmentError.invalidAttachmentType + } + } + +} + +extension AttachmentViewModel { + static func createThumbnailForVideo(url: URL) -> UIImage? { + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let asset = AVURLAsset(url: url) + let assetImageGenerator = AVAssetImageGenerator(asset: asset) + assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation + do { + let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil) + let image = UIImage(cgImage: cgImage) + return image + } catch { + AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)") + return nil + } + } +} + +// MARK: - TypeIdentifiedItemProvider +extension AttachmentViewModel: TypeIdentifiedItemProvider { + public static var typeIdentifier: String { + // must in UTI format + // https://developer.apple.com/library/archive/qa/qa1796/_index.html + return "com.twidere.AttachmentViewModel" + } +} + +// MARK: - NSItemProviderWriting +extension AttachmentViewModel: NSItemProviderWriting { + + + /// Attachment uniform type idendifiers + /// + /// The latest one for in-app drag and drop. + /// And use generic `image` and `movie` type to + /// allows transformable media in different formats + public static var writableTypeIdentifiersForItemProvider: [String] { + return [ + UTType.image.identifier, + UTType.movie.identifier, + AttachmentViewModel.typeIdentifier, + ] + } + + public var writableTypeIdentifiersForItemProvider: [String] { + // should append elements in priority order from high to low + var typeIdentifiers: [String] = [] + + // FIXME: check jpg or png + switch input { + case .image: + typeIdentifiers.append(UTType.png.identifier) + case .url(let url): + let _uti = UTType(filenameExtension: url.pathExtension) + if let uti = _uti { + if uti.conforms(to: .image) { + typeIdentifiers.append(UTType.png.identifier) + } else if uti.conforms(to: .movie) { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + } + case .pickerResult(let item): + if item.itemProvider.isImage() { + typeIdentifiers.append(UTType.png.identifier) + } else if item.itemProvider.isMovie() { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + case .itemProvider(let itemProvider): + if itemProvider.isImage() { + typeIdentifiers.append(UTType.png.identifier) + } else if itemProvider.isMovie() { + typeIdentifiers.append(UTType.mpeg4Movie.identifier) + } + } + + typeIdentifiers.append(AttachmentViewModel.typeIdentifier) + + return typeIdentifiers + } + + public func loadData( + withTypeIdentifier typeIdentifier: String, + forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void + ) -> Progress? { + switch typeIdentifier { + case AttachmentViewModel.typeIdentifier: + do { + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + let data = archiver.encodedData + completionHandler(data, nil) + } catch { + assertionFailure() + completionHandler(nil, nil) + } + default: + break + } + + let loadingProgress = Progress(totalUnitCount: 100) + + Publishers.CombineLatest( + $output, + $error + ) + .sink { [weak self] output, error in + guard let self = self else { return } + + // continue when load completed + guard output != nil || error != nil else { return } + + switch output { + case .image(let data, _): + switch typeIdentifier { + case UTType.png.identifier: + loadingProgress.completedUnitCount = 100 + completionHandler(data, nil) + default: + completionHandler(nil, nil) + } + case .video(let url, _): + switch typeIdentifier { + case UTType.png.identifier: + let _image = AttachmentViewModel.createThumbnailForVideo(url: url) + let _data = _image?.pngData() + loadingProgress.completedUnitCount = 100 + completionHandler(_data, nil) + case UTType.mpeg4Movie.identifier: + let task = URLSession.shared.dataTask(with: url) { data, response, error in + completionHandler(data, error) + } + task.progress.observe(\.fractionCompleted) { progress, change in + loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted) + } + .store(in: &self.observations) + task.resume() + default: + completionHandler(nil, nil) + } + case nil: + completionHandler(nil, error) + } + } + .store(in: &disposeBag) + + return loadingProgress + } + +} + +extension NSItemProvider { + fileprivate func isImage() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.image.identifier, + fileOptions: [] + ) + } + + fileprivate func isMovie() -> Bool { + return hasRepresentationConforming( + toTypeIdentifier: UTType.movie.identifier, + fileOptions: [] + ) + } +} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index 53db9ab30..73272b419 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -9,10 +9,11 @@ import os.log import UIKit import Combine import CoreDataStack -import MastodonCore import Meta -import MastodonMeta import MetaTextKit +import MastodonMeta +import MastodonCore +import MastodonSDK public final class ComposeContentViewModel: NSObject, ObservableObject { @@ -29,6 +30,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { let kind: Kind @Published var viewLayoutFrame = ViewLayoutFrame() + + // author (me) @Published var authContext: AuthContext // output @@ -67,6 +70,11 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { @Published var name: MetaContent = PlaintextMetaContent(string: "") @Published var username: String = "" + // attachment + @Published public var attachmentViewModels: [AttachmentViewModel] = [] + @Published public var maxMediaAttachmentLimit = 4 + // @Published public internal(set) var isMediaValid = true + // poll @Published var isPollActive = false @Published public var pollOptions: [PollComposeItem.Option] = { @@ -77,11 +85,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { return options }() @Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay + @Published public var pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option = false + @Published public var maxPollOptionLimit = 4 // emoji @Published var isEmojiActive = false + // visibility + @Published var visibility: Mastodon.Entity.Status.Visibility + // UI & UX @Published var replyToCellFrame: CGRect = .zero @Published var contentCellFrame: CGRect = .zero @@ -96,6 +109,41 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { self.context = context self.authContext = authContext self.kind = kind + self.visibility = { + // default private when user locked + var visibility: Mastodon.Entity.Status.Visibility = { + guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user else { + return .public + } + return author.locked ? .private : .public + }() + // set visibility for reply post + switch kind { + case .reply(let record): + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { + assertionFailure() + return + } + let repliedStatusVisibility = status.visibility + switch repliedStatusVisibility { + case .public, .unlisted: + // keep default + break + case .private: + visibility = .private + case .direct: + visibility = .direct + case ._other: + assertionFailure() + break + } + } + default: + break + } + return visibility + }() super.init() // end init @@ -162,6 +210,71 @@ extension ComposeContentViewModel { } } +extension ComposeContentViewModel { + public enum ComposeError: LocalizedError { + case pollHasEmptyOption + + public var errorDescription: String? { + switch self { + case .pollHasEmptyOption: + return "The post poll is invalid" // TODO: i18n + } + } + + public var failureReason: String? { + switch self { + case .pollHasEmptyOption: + return "The poll has empty option" // TODO: i18n + } + } + } + + public func statusPublisher() throws -> StatusPublisher { + let authContext = self.authContext + + // author + let managedObjectContext = self.context.managedObjectContext + var _author: ManagedObjectRecord? + managedObjectContext.performAndWait { + _author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user.asRecrod + } + guard let author = _author else { + throw AppError.badAuthentication + } + + // poll + _ = try { + guard isPollActive else { return } + let isAllNonEmpty = pollOptions + .map { $0.text } + .allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard isAllNonEmpty else { + throw ComposeError.pollHasEmptyOption + } + }() + + return MastodonStatusPublisher( + author: author, + replyTo: { + switch self.kind { + case .reply(let status): return status + default: return nil + } + }(), + isContentWarningComposing: isContentWarningActive, + contentWarning: contentWarning, + content: content, + isMediaSensitive: isContentWarningActive, + attachmentViewModels: attachmentViewModels, + isPollComposing: isPollActive, + pollOptions: pollOptions, + pollExpireConfigurationOption: pollExpireConfigurationOption, + pollMultipleConfigurationOption: pollMultipleConfigurationOption, + visibility: visibility + ) + } // end func publisher() +} + // MARK: - UITextViewDelegate extension ComposeContentViewModel: UITextViewDelegate { public func textViewDidBeginEditing(_ textView: UITextView) { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift new file mode 100644 index 000000000..ea3be18a8 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Publisher/MastodonStatusPublisher.swift @@ -0,0 +1,180 @@ +// +// MastodonStatusPublisher.swift +// +// +// Created by MainasuK on 2021-12-1. +// + +import os.log +import Foundation +import CoreData +import CoreDataStack +import MastodonCore +import MastodonSDK + +public final class MastodonStatusPublisher: NSObject, ProgressReporting { + + let logger = Logger(subsystem: "MastodonStatusPublisher", category: "Publisher") + + // Input + + // author + public let author: ManagedObjectRecord + // refer + public let replyTo: ManagedObjectRecord? + // content warning + public let isContentWarningComposing: Bool + public let contentWarning: String + // status content + public let content: String + // media + public let isMediaSensitive: Bool + public let attachmentViewModels: [AttachmentViewModel] + // poll + public let isPollComposing: Bool + public let pollOptions: [PollComposeItem.Option] + public let pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option + public let pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option + // visibility + public let visibility: Mastodon.Entity.Status.Visibility + + // Output + let _progress = Progress() + public var progress: Progress { _progress } + @Published var _state: StatusPublisherState = .pending + public var state: Published.Publisher { $_state } + + public var reactor: StatusPublisherReactor? + + public init( + author: ManagedObjectRecord, + replyTo: ManagedObjectRecord?, + isContentWarningComposing: Bool, + contentWarning: String, + content: String, + isMediaSensitive: Bool, + attachmentViewModels: [AttachmentViewModel], + isPollComposing: Bool, + pollOptions: [PollComposeItem.Option], + pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option, + pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option, + visibility: Mastodon.Entity.Status.Visibility + ) { + self.author = author + self.replyTo = replyTo + self.isContentWarningComposing = isContentWarningComposing + self.contentWarning = contentWarning + self.content = content + self.isMediaSensitive = isMediaSensitive + self.attachmentViewModels = attachmentViewModels + self.isPollComposing = isPollComposing + self.pollOptions = pollOptions + self.pollExpireConfigurationOption = pollExpireConfigurationOption + self.pollMultipleConfigurationOption = pollMultipleConfigurationOption + self.visibility = visibility + } + +} + +// MARK: - StatusPublisher +extension MastodonStatusPublisher: StatusPublisher { + + public func publish( + api: APIService, + authContext: AuthContext + ) async throws -> StatusPublishResult { + let idempotencyKey = UUID().uuidString + + let publishStatusTaskStartDelayWeight: Int64 = 20 + let publishStatusTaskStartDelayCount: Int64 = publishStatusTaskStartDelayWeight + + let publishAttachmentTaskWeight: Int64 = 100 + let publishAttachmentTaskCount: Int64 = Int64(attachmentViewModels.count) * publishAttachmentTaskWeight + + let publishStatusTaskWeight: Int64 = 20 + let publishStatusTaskCount: Int64 = publishStatusTaskWeight + + let taskCount = [ + publishStatusTaskStartDelayCount, + publishAttachmentTaskCount, + publishStatusTaskCount + ].reduce(0, +) + progress.totalUnitCount = taskCount + progress.completedUnitCount = 0 + + // start delay + try? await Task.sleep(nanoseconds: 1 * .second) + progress.completedUnitCount += publishStatusTaskStartDelayWeight + + // Task: attachment + + let uploadContext = AttachmentViewModel.UploadContext( + apiService: api, + authContext: authContext + ) + + var attachmentIDs: [Mastodon.Entity.Attachment.ID] = [] + for attachmentViewModel in attachmentViewModels { + // set progress + progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight) + // upload media + do { + let result = try await attachmentViewModel.upload(context: uploadContext) + guard case let .mastodon(response) = result else { + assertionFailure() + continue + } + let attachmentID = response.value.id + attachmentIDs.append(attachmentID) + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)") + _state = .failure(error) + throw error + } + } + + let pollOptions: [String]? = { + guard self.isPollComposing else { return nil } + let options = self.pollOptions.compactMap { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) } + return options.isEmpty ? nil : options + }() + let pollExpiresIn: Int? = { + guard self.isPollComposing else { return nil } + guard pollOptions != nil else { return nil } + return self.pollExpireConfigurationOption.seconds + }() + let pollMultiple: Bool? = { + guard self.isPollComposing else { return nil } + guard pollOptions != nil else { return nil } + return self.pollMultipleConfigurationOption + }() + let inReplyToID: Mastodon.Entity.Status.ID? = try await api.backgroundManagedObjectContext.perform { + guard let replyTo = self.replyTo?.object(in: api.backgroundManagedObjectContext) else { return nil } + return replyTo.id + } + + let query = Mastodon.API.Statuses.PublishStatusQuery( + status: content, + mediaIDs: attachmentIDs.isEmpty ? nil : attachmentIDs, + pollOptions: pollOptions, + pollExpiresIn: pollExpiresIn, + inReplyToID: inReplyToID, + sensitive: isMediaSensitive, + spoilerText: isContentWarningComposing ? contentWarning : nil, + visibility: visibility + ) + + let publishResponse = try await api.publishStatus( + domain: authContext.mastodonAuthenticationBox.domain, + idempotencyKey: idempotencyKey, + query: query, + authenticationBox: authContext.mastodonAuthenticationBox + ) + progress.completedUnitCount += publishStatusTaskCount + _state = .success + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): status published: \(publishResponse.value.id)") + + return .mastodon(publishResponse) + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Container/AttachmentView.swift b/MastodonSDK/Sources/MastodonUI/View/Container/AttachmentView.swift deleted file mode 100644 index 150ff5f51..000000000 --- a/MastodonSDK/Sources/MastodonUI/View/Container/AttachmentView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// AttachmentView.swift -// -// -// Created by MainasuK on 22/9/30. -// - -import SwiftUI - -public struct AttachmentView: View { - - @ObservedObject public var viewModel: ViewModel - - public init(viewModel: ViewModel) { - self.viewModel = viewModel - } - - public var body: some View { - Text("Hi") - } - -} - -extension AttachmentView { - public class ViewModel: ObservableObject { - - public init() { } - } -}