feat: [WIP] restore compose status publish function with background task support

This commit is contained in:
CMK 2022-10-31 20:41:19 +08:00
parent 668a1d28e2
commit a7d5e23406
30 changed files with 2013 additions and 294 deletions

View File

@ -72,6 +72,15 @@
"version" : "4.2.2" "version" : "4.2.2"
} }
}, },
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher.git",
"state" : {
"revision" : "44e891bdb61426a95e31492a67c7c0dfad1f87c5",
"version" : "7.4.1"
}
},
{ {
"identity" : "metatextkit", "identity" : "metatextkit",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View File

@ -57,36 +57,35 @@ final class ComposeViewController: UIViewController, NeedsDependency {
return barButtonItem return barButtonItem
}() }()
// let publishButton: UIButton = { let publishButton: UIButton = {
// let button = RoundedEdgesButton(type: .custom) let button = RoundedEdgesButton(type: .custom)
// button.cornerRadius = 10 button.cornerRadius = 10
// button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
// button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold) button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
// button.setTitle(L10n.Scene.Compose.composeAction, for: .normal) button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
// return button return button
// }() }()
// private(set) lazy var publishBarButtonItem: UIBarButtonItem = { private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
// configurePublishButtonApperance() configurePublishButtonApperance()
// let shadowBackgroundContainer = ShadowBackgroundContainer() let shadowBackgroundContainer = ShadowBackgroundContainer()
// publishButton.translatesAutoresizingMaskIntoConstraints = false publishButton.translatesAutoresizingMaskIntoConstraints = false
// shadowBackgroundContainer.addSubview(publishButton) shadowBackgroundContainer.addSubview(publishButton)
// NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
// publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor), publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor),
// publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor), publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor),
// publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor), publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor),
// publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor), publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor),
// ]) ])
// let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer) let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
// return barButtonItem return barButtonItem
// }() }()
// private func configurePublishButtonApperance() {
// private func configurePublishButtonApperance() { publishButton.adjustsImageWhenHighlighted = false
// publishButton.adjustsImageWhenHighlighted = false publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal)
// 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.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted) publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
// publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) }
// }
// var systemKeyboardHeight: CGFloat = .zero { // var systemKeyboardHeight: CGFloat = .zero {
// didSet { // didSet {
@ -106,7 +105,6 @@ final class ComposeViewController: UIViewController, NeedsDependency {
// var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! // var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
// let composeToolbarBackgroundView = UIView() // let composeToolbarBackgroundView = UIView()
// //
// //
// private(set) lazy var autoCompleteViewController: AutoCompleteViewController = { // private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
// let viewController = AutoCompleteViewController() // let viewController = AutoCompleteViewController()
@ -142,20 +140,20 @@ extension ComposeViewController {
super.viewDidLoad() super.viewDidLoad()
navigationItem.leftBarButtonItem = cancelBarButtonItem navigationItem.leftBarButtonItem = cancelBarButtonItem
// navigationItem.rightBarButtonItem = publishBarButtonItem navigationItem.rightBarButtonItem = publishBarButtonItem
// viewModel.traitCollectionDidChangePublisher viewModel.traitCollectionDidChangePublisher
// .receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
// .sink { [weak self] _ in .sink { [weak self] _ in
// guard let self = self else { return } guard let self = self else { return }
// guard self.traitCollection.userInterfaceIdiom == .pad else { return } guard self.traitCollection.userInterfaceIdiom == .pad else { return }
// var items = [self.publishBarButtonItem] var items = [self.publishBarButtonItem]
// if self.traitCollection.horizontalSizeClass == .regular { if self.traitCollection.horizontalSizeClass == .regular {
// items.append(self.characterCountBarButtonItem) items.append(self.characterCountBarButtonItem)
// } }
// self.navigationItem.rightBarButtonItems = items self.navigationItem.rightBarButtonItems = items
// } }
// .store(in: &disposeBag) .store(in: &disposeBag)
// publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside) publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
addChild(composeContentViewController) addChild(composeContentViewController)
composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false
@ -602,8 +600,8 @@ extension ComposeViewController {
dismiss(animated: true, completion: nil) dismiss(animated: true, completion: nil)
} }
// @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
// do { // do {
// try viewModel.checkAttachmentPrecondition() // try viewModel.checkAttachmentPrecondition()
// } catch { // } catch {
@ -613,17 +611,32 @@ extension ComposeViewController {
// coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) // coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
// return // return
// } // }
//
// guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { // guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
// // TODO: handle error // // TODO: handle error
// return // return
// } // }
//
// // context.statusPublishService.publish(composeViewModel: viewModel) // context.statusPublishService.publish(composeViewModel: viewModel)
// assertionFailure()
// do {
// dismiss(animated: true, completion: nil) 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)
}
} }

View File

@ -44,7 +44,7 @@ final class ComposeViewModel: NSObject {
// @Published var autoCompleteRetryLayoutTimes = 0 // @Published var autoCompleteRetryLayoutTimes = 0
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil // @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
// let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
// var isViewAppeared = false // var isViewAppeared = false
// output // output

View File

@ -11,128 +11,127 @@ import MastodonCore
import MastodonUI import MastodonUI
import MastodonLocalization import MastodonLocalization
extension AttachmentContainerView { //extension AttachmentContainerView {
final class EmptyStateView: UIView { // final class EmptyStateView: UIView {
//
static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate) // static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
static let videoSplashImage: UIImage = { // static let videoSplashImage: UIImage = {
let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64)) // let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
return image // return image
}() // }()
//
let imageView: UIImageView = { // let imageView: UIImageView = {
let imageView = UIImageView() // let imageView = UIImageView()
imageView.tintColor = Asset.Colors.Label.secondary.color // imageView.tintColor = Asset.Colors.Label.secondary.color
imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage // imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage
return imageView // return imageView
}() // }()
let label: UILabel = { // let label: UILabel = {
let label = UILabel() // let label = UILabel()
label.font = .preferredFont(forTextStyle: .body) // label.font = .preferredFont(forTextStyle: .body)
label.textColor = Asset.Colors.Label.secondary.color // label.textColor = Asset.Colors.Label.secondary.color
label.textAlignment = .center // label.textAlignment = .center
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo) // label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
label.numberOfLines = 2 // label.numberOfLines = 2
label.adjustsFontSizeToFitWidth = true // label.adjustsFontSizeToFitWidth = true
label.minimumScaleFactor = 0.3 // label.minimumScaleFactor = 0.3
return label // return label
}() // }()
//
override init(frame: CGRect) { // override init(frame: CGRect) {
super.init(frame: frame) // super.init(frame: frame)
_init() // _init()
} // }
//
required init?(coder: NSCoder) { // required init?(coder: NSCoder) {
super.init(coder: coder) // super.init(coder: coder)
_init() // _init()
} // }
//
} // }
} //}
extension AttachmentContainerView.EmptyStateView { //extension AttachmentContainerView.EmptyStateView {
private func _init() { // private func _init() {
layer.masksToBounds = true // layer.masksToBounds = true
layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius // layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
layer.cornerCurve = .continuous // layer.cornerCurve = .continuous
backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor // backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor
//
let stackView = UIStackView() // let stackView = UIStackView()
stackView.axis = .vertical // stackView.axis = .vertical
stackView.alignment = .center // stackView.alignment = .center
stackView.translatesAutoresizingMaskIntoConstraints = false // stackView.translatesAutoresizingMaskIntoConstraints = false
addSubview(stackView) // addSubview(stackView)
NSLayoutConstraint.activate([ // NSLayoutConstraint.activate([
stackView.topAnchor.constraint(equalTo: topAnchor), // stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.leadingAnchor.constraint(equalTo: leadingAnchor), // stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor), // stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor), // stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
]) // ])
let topPaddingView = UIView() // let topPaddingView = UIView()
let middlePaddingView = UIView() // let middlePaddingView = UIView()
let bottomPaddingView = UIView() // let bottomPaddingView = UIView()
//
topPaddingView.translatesAutoresizingMaskIntoConstraints = false // topPaddingView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(topPaddingView) // stackView.addArrangedSubview(topPaddingView)
imageView.translatesAutoresizingMaskIntoConstraints = false // imageView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(imageView) // stackView.addArrangedSubview(imageView)
NSLayoutConstraint.activate([ // NSLayoutConstraint.activate([
imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh), // imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh),
imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh), // imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh),
]) // ])
imageView.setContentHuggingPriority(.required - 1, for: .vertical) // imageView.setContentHuggingPriority(.required - 1, for: .vertical)
middlePaddingView.translatesAutoresizingMaskIntoConstraints = false // middlePaddingView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(middlePaddingView) // stackView.addArrangedSubview(middlePaddingView)
stackView.addArrangedSubview(label) // stackView.addArrangedSubview(label)
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false // bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
stackView.addArrangedSubview(bottomPaddingView) // stackView.addArrangedSubview(bottomPaddingView)
NSLayoutConstraint.activate([ // NSLayoutConstraint.activate([
topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5), // topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
bottomPaddingView.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
//#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

View File

@ -9,10 +9,10 @@ import UIKit
import SwiftUI import SwiftUI
import MastodonUI import MastodonUI
final class AttachmentContainerView: UIView { //final class AttachmentContainerView: UIView {
//
static let containerViewCornerRadius: CGFloat = 4 // static let containerViewCornerRadius: CGFloat = 4
//
// var descriptionBackgroundViewFrameObservation: NSKeyValueObservation? // var descriptionBackgroundViewFrameObservation: NSKeyValueObservation?
// //
// let activityIndicatorView: UIActivityIndicatorView = { // let activityIndicatorView: UIActivityIndicatorView = {
@ -60,35 +60,35 @@ final class AttachmentContainerView: UIView {
// textView.returnKeyType = .done // textView.returnKeyType = .done
// return textView // return textView
// }() // }()
//
private(set) lazy var contentView = AttachmentView(viewModel: viewModel) // private(set) lazy var contentView = AttachmentView(viewModel: viewModel)
public var viewModel: AttachmentView.ViewModel! // public var viewModel: AttachmentView.ViewModel!
//
override init(frame: CGRect) { // override init(frame: CGRect) {
super.init(frame: frame) // super.init(frame: frame)
_init() // _init()
} // }
//
required init?(coder: NSCoder) { // required init?(coder: NSCoder) {
super.init(coder: coder) // super.init(coder: coder)
_init() // _init()
} // }
//
} //}
extension AttachmentContainerView { //extension AttachmentContainerView {
//
private func _init() { // private func _init() {
let hostingViewController = UIHostingController(rootView: contentView) // let hostingViewController = UIHostingController(rootView: contentView)
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false // hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
addSubview(hostingViewController.view) // addSubview(hostingViewController.view)
NSLayoutConstraint.activate([ // NSLayoutConstraint.activate([
hostingViewController.view.topAnchor.constraint(equalTo: topAnchor), // hostingViewController.view.topAnchor.constraint(equalTo: topAnchor),
hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor), // hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor), // hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor), // hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
]) // ])
//
// previewImageView.translatesAutoresizingMaskIntoConstraints = false // previewImageView.translatesAutoresizingMaskIntoConstraints = false
// addSubview(previewImageView) // addSubview(previewImageView)
// NSLayoutConstraint.activate([ // NSLayoutConstraint.activate([
@ -144,24 +144,24 @@ extension AttachmentContainerView {
// activityIndicatorView.startAnimating() // activityIndicatorView.startAnimating()
// //
// descriptionTextView.delegate = self // descriptionTextView.delegate = self
} // }
//
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { //// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
// super.traitCollectionDidChange(previousTraitCollection) // super.traitCollectionDidChange(previousTraitCollection)
// //
// setupBroader() // setupBroader()
// } // }
//
} //}
//
extension AttachmentContainerView { //extension AttachmentContainerView {
//
// private func setupBroader() { // private func setupBroader() {
// emptyStateView.layer.borderWidth = 1 // emptyStateView.layer.borderWidth = 1
// emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil // emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil
// } // }
//
} //}
//// MARK: - UITextViewDelegate //// MARK: - UITextViewDelegate
//extension AttachmentContainerView: UITextViewDelegate { //extension AttachmentContainerView: UITextViewDelegate {

View File

@ -165,6 +165,7 @@ extension HomeTimelineViewController {
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
]) ])
// // layout publish progress
publishProgressView.translatesAutoresizingMaskIntoConstraints = false publishProgressView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(publishProgressView) view.addSubview(publishProgressView)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
@ -204,10 +205,12 @@ extension HomeTimelineViewController {
} }
.store(in: &disposeBag) .store(in: &disposeBag)
viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress context.publisherService.$currentPublishProgress
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] progress in .sink { [weak self] progress in
guard let self = self else { return } guard let self = self else { return }
let progress = Float(progress)
guard progress > 0 else { guard progress > 0 else {
let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut) let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
dismissAnimator.addAnimations { dismissAnimator.addAnimations {

View File

@ -49,6 +49,29 @@ final class HomeTimelineNavigationBarTitleViewModel {
.assign(to: \.value, on: isOffline) .assign(to: \.value, on: isOffline)
.store(in: &disposeBag) .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 // context.statusPublishService.latestPublishingComposeViewModel
// .receive(on: DispatchQueue.main) // .receive(on: DispatchQueue.main)
// .sink { [weak self] composeViewModel in // .sink { [weak self] composeViewModel in
@ -82,19 +105,19 @@ final class HomeTimelineNavigationBarTitleViewModel {
.assign(to: \.value, on: state) .assign(to: \.value, on: state)
.store(in: &disposeBag) .store(in: &disposeBag)
state // state
.removeDuplicates() // .removeDuplicates()
.receive(on: DispatchQueue.main) // .receive(on: DispatchQueue.main)
.sink { [weak self] state in // .sink { [weak self] state in
guard let self = self else { return } // guard let self = self else { return }
switch state { // switch state {
case .publishingPostLabel: // case .publishingPostLabel:
self.setupPublishingProgress() // self.setupPublishingProgress()
default: // default:
self.suspendPublishingProgress() // self.suspendPublishingProgress()
} // }
} // }
.store(in: &disposeBag) // .store(in: &disposeBag)
} }
} }
@ -150,26 +173,26 @@ extension HomeTimelineNavigationBarTitleViewModel {
} }
// MARK: Publish post state // MARK: Publish post state
extension HomeTimelineNavigationBarTitleViewModel { //extension HomeTimelineNavigationBarTitleViewModel {
//
func setupPublishingProgress() { // func setupPublishingProgress() {
let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS // let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS
.autoconnect() // .autoconnect()
.share() // .share()
.eraseToAnyPublisher() // .eraseToAnyPublisher()
//
publishingProgressSubscription = progressUpdatePublisher // publishingProgressSubscription = progressUpdatePublisher
.map { _ in Float(0) } // .map { _ in Float(0) }
.scan(0.0) { progress, _ -> Float in // .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) // return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS)
} // }
.subscribe(publishingProgress) // .subscribe(publishingProgress)
} // }
//
func suspendPublishingProgress() { // func suspendPublishingProgress() {
publishingProgressSubscription = nil // publishingProgressSubscription = nil
publishingProgress.send(0) // publishingProgress.send(0)
} // }
//
} //}

View File

@ -49,6 +49,7 @@ let package = Package(
.package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"), .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/SDWebImage/SDWebImage.git", from: "5.12.0"),
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.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: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite. // 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: "CropViewController", package: "TOCropViewController"),
.product(name: "PanModal", package: "PanModal"), .product(name: "PanModal", package: "PanModal"),
.product(name: "Stripes", package: "Stripes"), .product(name: "Stripes", package: "Stripes"),
.product(name: "Kingfisher", package: "Kingfisher"),
] ]
), ),
.testTarget( .testTarget(

View File

@ -24,7 +24,8 @@ public class AppContext: ObservableObject {
public let apiService: APIService public let apiService: APIService
public let authenticationService: AuthenticationService public let authenticationService: AuthenticationService
public let emojiService: EmojiService public let emojiService: EmojiService
public let statusPublishService = StatusPublishService() // public let statusPublishService = StatusPublishService()
public let publisherService: PublisherService
public let notificationService: NotificationService public let notificationService: NotificationService
public let settingService: SettingService public let settingService: SettingService
public let instanceService: InstanceService public let instanceService: InstanceService
@ -67,6 +68,8 @@ public class AppContext: ObservableObject {
apiService: apiService apiService: apiService
) )
publisherService = .init(apiService: _apiService)
let _notificationService = NotificationService( let _notificationService = NotificationService(
apiService: _apiService, apiService: _apiService,
authenticationService: _authenticationService authenticationService: _authenticationService

View File

@ -0,0 +1,13 @@
//
// AppError.swift
//
//
// Created by MainasuK on 2022-8-8.
//
import Foundation
public enum AppError: Error {
case badRequest
case badAuthentication
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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<NSKeyValueObservation>) {
set.insert(self)
}
}

View File

@ -94,12 +94,14 @@ extension PollComposeItem {
public final class MultipleConfiguration: Hashable, ObservableObject { public final class MultipleConfiguration: Hashable, ObservableObject {
private let id = UUID() private let id = UUID()
@Published public var isMultiple = false @Published public var isMultiple: Option = false
public init() { public init() {
// end init // end init
} }
public typealias Option = Bool
public static func == (lhs: MultipleConfiguration, rhs: MultipleConfiguration) -> Bool { public static func == (lhs: MultipleConfiguration, rhs: MultipleConfiguration) -> Bool {
return lhs.id == rhs.id return lhs.id == rhs.id
&& lhs.isMultiple == rhs.isMultiple && lhs.isMultiple == rhs.isMultiple

View File

@ -25,7 +25,7 @@ public final class APIService {
let session: URLSession let session: URLSession
// input // input
let backgroundManagedObjectContext: NSManagedObjectContext public let backgroundManagedObjectContext: NSManagedObjectContext
// output // output
public let error = PassthroughSubject<APIError, Never>() public let error = PassthroughSubject<APIError, Never>()

View File

@ -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<AnyCancellable>()
let logger = Logger(subsystem: "PublisherService", category: "Service")
// input
let apiService: APIService
@Published public private(set) var statusPublishers: [StatusPublisher] = []
// output
public let statusPublishResult = PassthroughSubject<Result<StatusPublishResult, Error>, 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
}
}
}
}

View File

@ -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<Mastodon.Entity.Status>)
}

View File

@ -0,0 +1,14 @@
//
// StatusPublisher.swift
//
//
// Created by MainasuK on 2021-11-26.
//
import Foundation
public protocol StatusPublisher: ProgressReporting {
var state: Published<StatusPublisherState>.Publisher { get }
var reactor: StatusPublisherReactor? { get set }
func publish(api: APIService, authContext: AuthContext) async throws -> StatusPublishResult
}

View File

@ -0,0 +1,10 @@
//
// StatusPublisherReactor.swift
//
//
// Created by MainasuK on 2022/10/27.
//
import Foundation
public protocol StatusPublisherReactor: AnyObject { }

View File

@ -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
}

View File

@ -44,6 +44,20 @@ extension Mastodon.API.Media {
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
let serialStream = query.serialStream let serialStream = query.serialStream
request.httpBodyStream = serialStream.boundStreams.input 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) return session.dataTaskPublisher(for: request)
.tryMap { data, response in .tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response) 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 description: String?
public let focus: String? public let focus: String?
public let progress: Progress = {
let progress = Progress()
progress.totalUnitCount = 100
return progress
}()
public init( public init(
file: Mastodon.Query.MediaAttachment?, file: Mastodon.Query.MediaAttachment?,
thumbnail: Mastodon.Query.MediaAttachment?, thumbnail: Mastodon.Query.MediaAttachment?,

View File

@ -53,6 +53,18 @@ extension Mastodon.Query.MediaAttachment {
var base64EncondedString: String? { var base64EncondedString: String? {
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() } 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 { extension Mastodon.Query.MediaAttachment: MultipartFormValue {

View File

@ -15,6 +15,10 @@ import Combine
// - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3 // - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3
final class SerialStream: NSObject { final class SerialStream: NSObject {
let logger = Logger(subsystem: "SerialStream", category: "Stream")
public let progress = Progress()
var writingTimerSubscriber: AnyCancellable? var writingTimerSubscriber: AnyCancellable?
// serial stream source // serial stream source
@ -70,10 +74,14 @@ final class SerialStream: NSObject {
var baseAddress = 0 var baseAddress = 0
var remainsBytes = readBytesCount var remainsBytes = readBytesCount
while remainsBytes > 0 { while remainsBytes > 0 {
let result = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes) let writeResult = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
baseAddress += result baseAddress += writeResult
remainsBytes -= result 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, 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, 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)")
} }
} }

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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<Base: AsyncSequence>: 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<Self> {
Chunked(base: self)
}
}
extension Data {
fileprivate func chunks(size: Int) -> [Data] {
return stride(from: 0, to: count, by: size).map {
Data(self[$0..<Swift.min(count, $0 + size)])
}
}
}
// Twitter Only
//extension AttachmentViewModel {
// class SliceResult {
//
// let fileURL: URL
// let chunks: Chunked<FileHandle.AsyncBytes>
// 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<Mastodon.Entity.Attachment>)
}
}
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<Mastodon.Entity.Attachment> = 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 ?? "<nil>")")
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")
}
}
}

View File

@ -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<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
// 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: []
)
}
}

View File

@ -9,10 +9,11 @@ import os.log
import UIKit import UIKit
import Combine import Combine
import CoreDataStack import CoreDataStack
import MastodonCore
import Meta import Meta
import MastodonMeta
import MetaTextKit import MetaTextKit
import MastodonMeta
import MastodonCore
import MastodonSDK
public final class ComposeContentViewModel: NSObject, ObservableObject { public final class ComposeContentViewModel: NSObject, ObservableObject {
@ -29,6 +30,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
let kind: Kind let kind: Kind
@Published var viewLayoutFrame = ViewLayoutFrame() @Published var viewLayoutFrame = ViewLayoutFrame()
// author (me)
@Published var authContext: AuthContext @Published var authContext: AuthContext
// output // output
@ -67,6 +70,11 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
@Published var name: MetaContent = PlaintextMetaContent(string: "") @Published var name: MetaContent = PlaintextMetaContent(string: "")
@Published var username: String = "" @Published var username: String = ""
// attachment
@Published public var attachmentViewModels: [AttachmentViewModel] = []
@Published public var maxMediaAttachmentLimit = 4
// @Published public internal(set) var isMediaValid = true
// poll // poll
@Published var isPollActive = false @Published var isPollActive = false
@Published public var pollOptions: [PollComposeItem.Option] = { @Published public var pollOptions: [PollComposeItem.Option] = {
@ -77,11 +85,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
return options return options
}() }()
@Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay @Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay
@Published public var pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option = false
@Published public var maxPollOptionLimit = 4 @Published public var maxPollOptionLimit = 4
// emoji // emoji
@Published var isEmojiActive = false @Published var isEmojiActive = false
// visibility
@Published var visibility: Mastodon.Entity.Status.Visibility
// UI & UX // UI & UX
@Published var replyToCellFrame: CGRect = .zero @Published var replyToCellFrame: CGRect = .zero
@Published var contentCellFrame: CGRect = .zero @Published var contentCellFrame: CGRect = .zero
@ -96,6 +109,41 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
self.context = context self.context = context
self.authContext = authContext self.authContext = authContext
self.kind = kind 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() super.init()
// end 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<MastodonUser>?
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 // MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate { extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) { public func textViewDidBeginEditing(_ textView: UITextView) {

View File

@ -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<MastodonUser>
// refer
public let replyTo: ManagedObjectRecord<Status>?
// 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<StatusPublisherState>.Publisher { $_state }
public var reactor: StatusPublisherReactor?
public init(
author: ManagedObjectRecord<MastodonUser>,
replyTo: ManagedObjectRecord<Status>?,
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)
}
}

View File

@ -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() { }
}
}