feat: [WIP] restore compose status publish function with background task support
This commit is contained in:
parent
668a1d28e2
commit
a7d5e23406
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -44,7 +44,7 @@ final class ComposeViewModel: NSObject {
|
|||
// @Published var autoCompleteRetryLayoutTimes = 0
|
||||
// @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
|
||||
|
||||
// output
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
// }
|
||||
//
|
||||
//}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// AppError.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022-8-8.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum AppError: Error {
|
||||
case badRequest
|
||||
case badAuthentication
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -25,7 +25,7 @@ public final class APIService {
|
|||
let session: URLSession
|
||||
|
||||
// input
|
||||
let backgroundManagedObjectContext: NSManagedObjectContext
|
||||
public let backgroundManagedObjectContext: NSManagedObjectContext
|
||||
|
||||
// output
|
||||
public let error = PassthroughSubject<APIError, Never>()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
//
|
||||
// StatusPublisherReactor.swift
|
||||
//
|
||||
//
|
||||
// Created by MainasuK on 2022/10/27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
public protocol StatusPublisherReactor: AnyObject { }
|
|
@ -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
|
||||
}
|
|
@ -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?,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: []
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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<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
|
||||
extension ComposeContentViewModel: UITextViewDelegate {
|
||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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() { }
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue