mirror of
https://github.com/mastodon/mastodon-ios.git
synced 2025-01-02 20:16:41 +01:00
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"
|
"version" : "4.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"identity" : "kingfisher",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/onevcat/Kingfisher.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "44e891bdb61426a95e31492a67c7c0dfad1f87c5",
|
||||||
|
"version" : "7.4.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"identity" : "metatextkit",
|
"identity" : "metatextkit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
@ -57,36 +57,35 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||||||
return barButtonItem
|
return barButtonItem
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// let publishButton: UIButton = {
|
let publishButton: UIButton = {
|
||||||
// let button = RoundedEdgesButton(type: .custom)
|
let button = RoundedEdgesButton(type: .custom)
|
||||||
// button.cornerRadius = 10
|
button.cornerRadius = 10
|
||||||
// button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
|
button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 16, bottom: 5, right: 16) // set 28pt height
|
||||||
// button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
|
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
|
||||||
// button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
|
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
|
||||||
// return button
|
return button
|
||||||
// }()
|
}()
|
||||||
// private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
|
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
|
||||||
// configurePublishButtonApperance()
|
configurePublishButtonApperance()
|
||||||
// let shadowBackgroundContainer = ShadowBackgroundContainer()
|
let shadowBackgroundContainer = ShadowBackgroundContainer()
|
||||||
// publishButton.translatesAutoresizingMaskIntoConstraints = false
|
publishButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
// shadowBackgroundContainer.addSubview(publishButton)
|
shadowBackgroundContainer.addSubview(publishButton)
|
||||||
// NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor),
|
publishButton.topAnchor.constraint(equalTo: shadowBackgroundContainer.topAnchor),
|
||||||
// publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor),
|
publishButton.leadingAnchor.constraint(equalTo: shadowBackgroundContainer.leadingAnchor),
|
||||||
// publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor),
|
publishButton.trailingAnchor.constraint(equalTo: shadowBackgroundContainer.trailingAnchor),
|
||||||
// publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor),
|
publishButton.bottomAnchor.constraint(equalTo: shadowBackgroundContainer.bottomAnchor),
|
||||||
// ])
|
])
|
||||||
// let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
|
let barButtonItem = UIBarButtonItem(customView: shadowBackgroundContainer)
|
||||||
// return barButtonItem
|
return barButtonItem
|
||||||
// }()
|
}()
|
||||||
//
|
private func configurePublishButtonApperance() {
|
||||||
// private func configurePublishButtonApperance() {
|
publishButton.adjustsImageWhenHighlighted = false
|
||||||
// publishButton.adjustsImageWhenHighlighted = false
|
publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal)
|
||||||
// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color), for: .normal)
|
publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted)
|
||||||
// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Label.primary.color.withAlphaComponent(0.5)), for: .highlighted)
|
publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
|
||||||
// publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
|
publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
|
||||||
// publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal)
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// var systemKeyboardHeight: CGFloat = .zero {
|
// var systemKeyboardHeight: CGFloat = .zero {
|
||||||
// didSet {
|
// didSet {
|
||||||
@ -106,7 +105,6 @@ final class ComposeViewController: UIViewController, NeedsDependency {
|
|||||||
// var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
// var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
|
||||||
// let composeToolbarBackgroundView = UIView()
|
// let composeToolbarBackgroundView = UIView()
|
||||||
//
|
//
|
||||||
|
|
||||||
//
|
//
|
||||||
// private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
|
// private(set) lazy var autoCompleteViewController: AutoCompleteViewController = {
|
||||||
// let viewController = AutoCompleteViewController()
|
// let viewController = AutoCompleteViewController()
|
||||||
@ -142,20 +140,20 @@ extension ComposeViewController {
|
|||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
|
|
||||||
navigationItem.leftBarButtonItem = cancelBarButtonItem
|
navigationItem.leftBarButtonItem = cancelBarButtonItem
|
||||||
// navigationItem.rightBarButtonItem = publishBarButtonItem
|
navigationItem.rightBarButtonItem = publishBarButtonItem
|
||||||
// viewModel.traitCollectionDidChangePublisher
|
viewModel.traitCollectionDidChangePublisher
|
||||||
// .receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
// .sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
// guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
// guard self.traitCollection.userInterfaceIdiom == .pad else { return }
|
guard self.traitCollection.userInterfaceIdiom == .pad else { return }
|
||||||
// var items = [self.publishBarButtonItem]
|
var items = [self.publishBarButtonItem]
|
||||||
// if self.traitCollection.horizontalSizeClass == .regular {
|
if self.traitCollection.horizontalSizeClass == .regular {
|
||||||
// items.append(self.characterCountBarButtonItem)
|
items.append(self.characterCountBarButtonItem)
|
||||||
// }
|
}
|
||||||
// self.navigationItem.rightBarButtonItems = items
|
self.navigationItem.rightBarButtonItems = items
|
||||||
// }
|
}
|
||||||
// .store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
// publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
|
publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
|
||||||
|
|
||||||
addChild(composeContentViewController)
|
addChild(composeContentViewController)
|
||||||
composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
composeContentViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@ -602,8 +600,8 @@ extension ComposeViewController {
|
|||||||
dismiss(animated: true, completion: nil)
|
dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
|
||||||
// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
||||||
// do {
|
// do {
|
||||||
// try viewModel.checkAttachmentPrecondition()
|
// try viewModel.checkAttachmentPrecondition()
|
||||||
// } catch {
|
// } catch {
|
||||||
@ -613,17 +611,32 @@ extension ComposeViewController {
|
|||||||
// coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
// coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil))
|
||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
//
|
|
||||||
// guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
|
// guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
|
||||||
// // TODO: handle error
|
// // TODO: handle error
|
||||||
// return
|
// return
|
||||||
// }
|
// }
|
||||||
//
|
|
||||||
// // context.statusPublishService.publish(composeViewModel: viewModel)
|
// context.statusPublishService.publish(composeViewModel: viewModel)
|
||||||
// assertionFailure()
|
|
||||||
//
|
do {
|
||||||
// dismiss(animated: true, completion: nil)
|
let statusPublisher = try composeContentViewModel.statusPublisher()
|
||||||
// }
|
// let result = try await statusPublisher.publish(api: context.apiService, authContext: viewModel.authContext)
|
||||||
|
// if let reactor = presentingViewController?.topMostNotModal as? StatusPublisherReactor {
|
||||||
|
// statusPublisher.reactor = reactor
|
||||||
|
// }
|
||||||
|
viewModel.context.publisherService.enqueue(
|
||||||
|
statusPublisher: statusPublisher,
|
||||||
|
authContext: viewModel.authContext
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
let alertController = UIAlertController.standardAlert(of: error)
|
||||||
|
present(alertController, animated: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(animated: true, completion: nil)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ final class ComposeViewModel: NSObject {
|
|||||||
// @Published var autoCompleteRetryLayoutTimes = 0
|
// @Published var autoCompleteRetryLayoutTimes = 0
|
||||||
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
|
// @Published var autoCompleteInfo: ComposeViewController.AutoCompleteInfo? = nil
|
||||||
|
|
||||||
// let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
|
let traitCollectionDidChangePublisher = CurrentValueSubject<Void, Never>(Void()) // use CurrentValueSubject to make initial event emit
|
||||||
// var isViewAppeared = false
|
// var isViewAppeared = false
|
||||||
|
|
||||||
// output
|
// output
|
||||||
|
@ -11,128 +11,127 @@ import MastodonCore
|
|||||||
import MastodonUI
|
import MastodonUI
|
||||||
import MastodonLocalization
|
import MastodonLocalization
|
||||||
|
|
||||||
extension AttachmentContainerView {
|
//extension AttachmentContainerView {
|
||||||
final class EmptyStateView: UIView {
|
// final class EmptyStateView: UIView {
|
||||||
|
//
|
||||||
static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
|
// static let photoFillSplitImage = Asset.Connectivity.photoFillSplit.image.withRenderingMode(.alwaysTemplate)
|
||||||
static let videoSplashImage: UIImage = {
|
// static let videoSplashImage: UIImage = {
|
||||||
let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
|
// let image = UIImage(systemName: "video.slash")!.withConfiguration(UIImage.SymbolConfiguration(pointSize: 64))
|
||||||
return image
|
// return image
|
||||||
}()
|
// }()
|
||||||
|
//
|
||||||
let imageView: UIImageView = {
|
// let imageView: UIImageView = {
|
||||||
let imageView = UIImageView()
|
// let imageView = UIImageView()
|
||||||
imageView.tintColor = Asset.Colors.Label.secondary.color
|
// imageView.tintColor = Asset.Colors.Label.secondary.color
|
||||||
imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage
|
// imageView.image = AttachmentContainerView.EmptyStateView.photoFillSplitImage
|
||||||
return imageView
|
// return imageView
|
||||||
}()
|
// }()
|
||||||
let label: UILabel = {
|
// let label: UILabel = {
|
||||||
let label = UILabel()
|
// let label = UILabel()
|
||||||
label.font = .preferredFont(forTextStyle: .body)
|
// label.font = .preferredFont(forTextStyle: .body)
|
||||||
label.textColor = Asset.Colors.Label.secondary.color
|
// label.textColor = Asset.Colors.Label.secondary.color
|
||||||
label.textAlignment = .center
|
// label.textAlignment = .center
|
||||||
label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
// label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
||||||
label.numberOfLines = 2
|
// label.numberOfLines = 2
|
||||||
label.adjustsFontSizeToFitWidth = true
|
// label.adjustsFontSizeToFitWidth = true
|
||||||
label.minimumScaleFactor = 0.3
|
// label.minimumScaleFactor = 0.3
|
||||||
return label
|
// return label
|
||||||
}()
|
// }()
|
||||||
|
//
|
||||||
override init(frame: CGRect) {
|
// override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
// super.init(frame: frame)
|
||||||
_init()
|
// _init()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
required init?(coder: NSCoder) {
|
// required init?(coder: NSCoder) {
|
||||||
super.init(coder: coder)
|
// super.init(coder: coder)
|
||||||
_init()
|
// _init()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
extension AttachmentContainerView.EmptyStateView {
|
//extension AttachmentContainerView.EmptyStateView {
|
||||||
private func _init() {
|
// private func _init() {
|
||||||
layer.masksToBounds = true
|
// layer.masksToBounds = true
|
||||||
layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
|
// layer.cornerRadius = AttachmentContainerView.containerViewCornerRadius
|
||||||
layer.cornerCurve = .continuous
|
// layer.cornerCurve = .continuous
|
||||||
backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor
|
// backgroundColor = ThemeService.shared.currentTheme.value.systemGroupedBackgroundColor
|
||||||
|
//
|
||||||
let stackView = UIStackView()
|
// let stackView = UIStackView()
|
||||||
stackView.axis = .vertical
|
// stackView.axis = .vertical
|
||||||
stackView.alignment = .center
|
// stackView.alignment = .center
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
// stackView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(stackView)
|
// addSubview(stackView)
|
||||||
NSLayoutConstraint.activate([
|
// NSLayoutConstraint.activate([
|
||||||
stackView.topAnchor.constraint(equalTo: topAnchor),
|
// stackView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
// stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
// stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
// stackView.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
])
|
// ])
|
||||||
let topPaddingView = UIView()
|
// let topPaddingView = UIView()
|
||||||
let middlePaddingView = UIView()
|
// let middlePaddingView = UIView()
|
||||||
let bottomPaddingView = UIView()
|
// let bottomPaddingView = UIView()
|
||||||
|
//
|
||||||
topPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
// topPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(topPaddingView)
|
// stackView.addArrangedSubview(topPaddingView)
|
||||||
imageView.translatesAutoresizingMaskIntoConstraints = false
|
// imageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(imageView)
|
// stackView.addArrangedSubview(imageView)
|
||||||
NSLayoutConstraint.activate([
|
// NSLayoutConstraint.activate([
|
||||||
imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh),
|
// imageView.widthAnchor.constraint(equalToConstant: 92).priority(.defaultHigh),
|
||||||
imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh),
|
// imageView.heightAnchor.constraint(equalToConstant: 76).priority(.defaultHigh),
|
||||||
])
|
// ])
|
||||||
imageView.setContentHuggingPriority(.required - 1, for: .vertical)
|
// imageView.setContentHuggingPriority(.required - 1, for: .vertical)
|
||||||
middlePaddingView.translatesAutoresizingMaskIntoConstraints = false
|
// middlePaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(middlePaddingView)
|
// stackView.addArrangedSubview(middlePaddingView)
|
||||||
stackView.addArrangedSubview(label)
|
// stackView.addArrangedSubview(label)
|
||||||
bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
// bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
stackView.addArrangedSubview(bottomPaddingView)
|
// stackView.addArrangedSubview(bottomPaddingView)
|
||||||
NSLayoutConstraint.activate([
|
// NSLayoutConstraint.activate([
|
||||||
topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
|
// topPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
|
||||||
bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
|
// bottomPaddingView.heightAnchor.constraint(equalTo: middlePaddingView.heightAnchor, multiplier: 1.5),
|
||||||
])
|
// ])
|
||||||
}
|
// }
|
||||||
}
|
//}
|
||||||
|
|
||||||
#if canImport(SwiftUI) && DEBUG
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider {
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
Group {
|
|
||||||
UIViewPreview(width: 375) {
|
|
||||||
let emptyStateView = AttachmentContainerView.EmptyStateView()
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
emptyStateView.heightAnchor.constraint(equalToConstant: 205)
|
|
||||||
])
|
|
||||||
return emptyStateView
|
|
||||||
}
|
|
||||||
.previewLayout(.fixed(width: 375, height: 205))
|
|
||||||
UIViewPreview(width: 375) {
|
|
||||||
let emptyStateView = AttachmentContainerView.EmptyStateView()
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
emptyStateView.heightAnchor.constraint(equalToConstant: 205)
|
|
||||||
])
|
|
||||||
return emptyStateView
|
|
||||||
}
|
|
||||||
.preferredColorScheme(.dark)
|
|
||||||
.previewLayout(.fixed(width: 375, height: 205))
|
|
||||||
UIViewPreview(width: 375) {
|
|
||||||
let emptyStateView = AttachmentContainerView.EmptyStateView()
|
|
||||||
emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage
|
|
||||||
emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
emptyStateView.heightAnchor.constraint(equalToConstant: 205)
|
|
||||||
])
|
|
||||||
return emptyStateView
|
|
||||||
}
|
|
||||||
.previewLayout(.fixed(width: 375, height: 205))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
||||||
|
//#if canImport(SwiftUI) && DEBUG
|
||||||
|
//import SwiftUI
|
||||||
|
//
|
||||||
|
//struct AttachmentContainerView_EmptyStateView_Previews: PreviewProvider {
|
||||||
|
//
|
||||||
|
// static var previews: some View {
|
||||||
|
// Group {
|
||||||
|
// UIViewPreview(width: 375) {
|
||||||
|
// let emptyStateView = AttachmentContainerView.EmptyStateView()
|
||||||
|
// NSLayoutConstraint.activate([
|
||||||
|
// emptyStateView.heightAnchor.constraint(equalToConstant: 205)
|
||||||
|
// ])
|
||||||
|
// return emptyStateView
|
||||||
|
// }
|
||||||
|
// .previewLayout(.fixed(width: 375, height: 205))
|
||||||
|
// UIViewPreview(width: 375) {
|
||||||
|
// let emptyStateView = AttachmentContainerView.EmptyStateView()
|
||||||
|
// NSLayoutConstraint.activate([
|
||||||
|
// emptyStateView.heightAnchor.constraint(equalToConstant: 205)
|
||||||
|
// ])
|
||||||
|
// return emptyStateView
|
||||||
|
// }
|
||||||
|
// .preferredColorScheme(.dark)
|
||||||
|
// .previewLayout(.fixed(width: 375, height: 205))
|
||||||
|
// UIViewPreview(width: 375) {
|
||||||
|
// let emptyStateView = AttachmentContainerView.EmptyStateView()
|
||||||
|
// emptyStateView.imageView.image = AttachmentContainerView.EmptyStateView.videoSplashImage
|
||||||
|
// emptyStateView.label.text = L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
|
||||||
|
//
|
||||||
|
// NSLayoutConstraint.activate([
|
||||||
|
// emptyStateView.heightAnchor.constraint(equalToConstant: 205)
|
||||||
|
// ])
|
||||||
|
// return emptyStateView
|
||||||
|
// }
|
||||||
|
// .previewLayout(.fixed(width: 375, height: 205))
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//#endif
|
||||||
|
@ -9,10 +9,10 @@ import UIKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MastodonUI
|
import MastodonUI
|
||||||
|
|
||||||
final class AttachmentContainerView: UIView {
|
//final class AttachmentContainerView: UIView {
|
||||||
|
//
|
||||||
static let containerViewCornerRadius: CGFloat = 4
|
// static let containerViewCornerRadius: CGFloat = 4
|
||||||
|
//
|
||||||
// var descriptionBackgroundViewFrameObservation: NSKeyValueObservation?
|
// var descriptionBackgroundViewFrameObservation: NSKeyValueObservation?
|
||||||
//
|
//
|
||||||
// let activityIndicatorView: UIActivityIndicatorView = {
|
// let activityIndicatorView: UIActivityIndicatorView = {
|
||||||
@ -60,35 +60,35 @@ final class AttachmentContainerView: UIView {
|
|||||||
// textView.returnKeyType = .done
|
// textView.returnKeyType = .done
|
||||||
// return textView
|
// return textView
|
||||||
// }()
|
// }()
|
||||||
|
//
|
||||||
private(set) lazy var contentView = AttachmentView(viewModel: viewModel)
|
// private(set) lazy var contentView = AttachmentView(viewModel: viewModel)
|
||||||
public var viewModel: AttachmentView.ViewModel!
|
// public var viewModel: AttachmentView.ViewModel!
|
||||||
|
//
|
||||||
override init(frame: CGRect) {
|
// override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
// super.init(frame: frame)
|
||||||
_init()
|
// _init()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
required init?(coder: NSCoder) {
|
// required init?(coder: NSCoder) {
|
||||||
super.init(coder: coder)
|
// super.init(coder: coder)
|
||||||
_init()
|
// _init()
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
}
|
//}
|
||||||
|
|
||||||
extension AttachmentContainerView {
|
//extension AttachmentContainerView {
|
||||||
|
//
|
||||||
private func _init() {
|
// private func _init() {
|
||||||
let hostingViewController = UIHostingController(rootView: contentView)
|
// let hostingViewController = UIHostingController(rootView: contentView)
|
||||||
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
// hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addSubview(hostingViewController.view)
|
// addSubview(hostingViewController.view)
|
||||||
NSLayoutConstraint.activate([
|
// NSLayoutConstraint.activate([
|
||||||
hostingViewController.view.topAnchor.constraint(equalTo: topAnchor),
|
// hostingViewController.view.topAnchor.constraint(equalTo: topAnchor),
|
||||||
hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
// hostingViewController.view.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
// hostingViewController.view.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
// hostingViewController.view.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
])
|
// ])
|
||||||
|
//
|
||||||
// previewImageView.translatesAutoresizingMaskIntoConstraints = false
|
// previewImageView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
// addSubview(previewImageView)
|
// addSubview(previewImageView)
|
||||||
// NSLayoutConstraint.activate([
|
// NSLayoutConstraint.activate([
|
||||||
@ -144,24 +144,24 @@ extension AttachmentContainerView {
|
|||||||
// activityIndicatorView.startAnimating()
|
// activityIndicatorView.startAnimating()
|
||||||
//
|
//
|
||||||
// descriptionTextView.delegate = self
|
// descriptionTextView.delegate = self
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
//// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
// super.traitCollectionDidChange(previousTraitCollection)
|
// super.traitCollectionDidChange(previousTraitCollection)
|
||||||
//
|
//
|
||||||
// setupBroader()
|
// setupBroader()
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
}
|
//}
|
||||||
|
//
|
||||||
extension AttachmentContainerView {
|
//extension AttachmentContainerView {
|
||||||
|
//
|
||||||
// private func setupBroader() {
|
// private func setupBroader() {
|
||||||
// emptyStateView.layer.borderWidth = 1
|
// emptyStateView.layer.borderWidth = 1
|
||||||
// emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil
|
// emptyStateView.layer.borderColor = traitCollection.userInterfaceStyle == .dark ? ThemeService.shared.currentTheme.value.tableViewCellSelectionBackgroundColor.cgColor : nil
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
}
|
//}
|
||||||
|
|
||||||
//// MARK: - UITextViewDelegate
|
//// MARK: - UITextViewDelegate
|
||||||
//extension AttachmentContainerView: UITextViewDelegate {
|
//extension AttachmentContainerView: UITextViewDelegate {
|
||||||
|
@ -165,6 +165,7 @@ extension HomeTimelineViewController {
|
|||||||
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// // layout publish progress
|
||||||
publishProgressView.translatesAutoresizingMaskIntoConstraints = false
|
publishProgressView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(publishProgressView)
|
view.addSubview(publishProgressView)
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
@ -204,10 +205,12 @@ extension HomeTimelineViewController {
|
|||||||
}
|
}
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
viewModel.homeTimelineNavigationBarTitleViewModel.publishingProgress
|
context.publisherService.$currentPublishProgress
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] progress in
|
.sink { [weak self] progress in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
let progress = Float(progress)
|
||||||
|
|
||||||
guard progress > 0 else {
|
guard progress > 0 else {
|
||||||
let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
|
let dismissAnimator = UIViewPropertyAnimator(duration: 0.1, curve: .easeInOut)
|
||||||
dismissAnimator.addAnimations {
|
dismissAnimator.addAnimations {
|
||||||
|
@ -49,6 +49,29 @@ final class HomeTimelineNavigationBarTitleViewModel {
|
|||||||
.assign(to: \.value, on: isOffline)
|
.assign(to: \.value, on: isOffline)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
Publishers.CombineLatest(
|
||||||
|
context.publisherService.$statusPublishers,
|
||||||
|
context.publisherService.statusPublishResult.prepend(.failure(AppError.badRequest))
|
||||||
|
)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] statusPublishers, publishResult in
|
||||||
|
guard let self = self else { return }
|
||||||
|
|
||||||
|
if statusPublishers.isEmpty {
|
||||||
|
self.isPublishingPost.value = false
|
||||||
|
self.isPublished.value = false
|
||||||
|
} else {
|
||||||
|
self.isPublishingPost.value = true
|
||||||
|
switch publishResult {
|
||||||
|
case .success:
|
||||||
|
self.isPublished.value = true
|
||||||
|
case .failure:
|
||||||
|
self.isPublished.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// context.statusPublishService.latestPublishingComposeViewModel
|
// context.statusPublishService.latestPublishingComposeViewModel
|
||||||
// .receive(on: DispatchQueue.main)
|
// .receive(on: DispatchQueue.main)
|
||||||
// .sink { [weak self] composeViewModel in
|
// .sink { [weak self] composeViewModel in
|
||||||
@ -82,19 +105,19 @@ final class HomeTimelineNavigationBarTitleViewModel {
|
|||||||
.assign(to: \.value, on: state)
|
.assign(to: \.value, on: state)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
state
|
// state
|
||||||
.removeDuplicates()
|
// .removeDuplicates()
|
||||||
.receive(on: DispatchQueue.main)
|
// .receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] state in
|
// .sink { [weak self] state in
|
||||||
guard let self = self else { return }
|
// guard let self = self else { return }
|
||||||
switch state {
|
// switch state {
|
||||||
case .publishingPostLabel:
|
// case .publishingPostLabel:
|
||||||
self.setupPublishingProgress()
|
// self.setupPublishingProgress()
|
||||||
default:
|
// default:
|
||||||
self.suspendPublishingProgress()
|
// self.suspendPublishingProgress()
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
.store(in: &disposeBag)
|
// .store(in: &disposeBag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,26 +173,26 @@ extension HomeTimelineNavigationBarTitleViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Publish post state
|
// MARK: Publish post state
|
||||||
extension HomeTimelineNavigationBarTitleViewModel {
|
//extension HomeTimelineNavigationBarTitleViewModel {
|
||||||
|
//
|
||||||
func setupPublishingProgress() {
|
// func setupPublishingProgress() {
|
||||||
let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS
|
// let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS
|
||||||
.autoconnect()
|
// .autoconnect()
|
||||||
.share()
|
// .share()
|
||||||
.eraseToAnyPublisher()
|
// .eraseToAnyPublisher()
|
||||||
|
//
|
||||||
publishingProgressSubscription = progressUpdatePublisher
|
// publishingProgressSubscription = progressUpdatePublisher
|
||||||
.map { _ in Float(0) }
|
// .map { _ in Float(0) }
|
||||||
.scan(0.0) { progress, _ -> Float in
|
// .scan(0.0) { progress, _ -> Float in
|
||||||
return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS)
|
// return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS)
|
||||||
}
|
// }
|
||||||
.subscribe(publishingProgress)
|
// .subscribe(publishingProgress)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
func suspendPublishingProgress() {
|
// func suspendPublishingProgress() {
|
||||||
publishingProgressSubscription = nil
|
// publishingProgressSubscription = nil
|
||||||
publishingProgress.send(0)
|
// publishingProgress.send(0)
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
}
|
//}
|
||||||
|
|
||||||
|
@ -49,6 +49,7 @@ let package = Package(
|
|||||||
.package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"),
|
.package(url: "https://github.com/woxtu/UIHostingConfigurationBackport.git", from: "0.1.0"),
|
||||||
.package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"),
|
.package(url: "https://github.com/SDWebImage/SDWebImage.git", from: "5.12.0"),
|
||||||
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"),
|
.package(url: "https://github.com/eneko/Stripes.git", from: "0.2.0"),
|
||||||
|
.package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.4.1"),
|
||||||
],
|
],
|
||||||
targets: [
|
targets: [
|
||||||
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
|
||||||
@ -124,6 +125,7 @@ let package = Package(
|
|||||||
.product(name: "CropViewController", package: "TOCropViewController"),
|
.product(name: "CropViewController", package: "TOCropViewController"),
|
||||||
.product(name: "PanModal", package: "PanModal"),
|
.product(name: "PanModal", package: "PanModal"),
|
||||||
.product(name: "Stripes", package: "Stripes"),
|
.product(name: "Stripes", package: "Stripes"),
|
||||||
|
.product(name: "Kingfisher", package: "Kingfisher"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
|
@ -24,7 +24,8 @@ public class AppContext: ObservableObject {
|
|||||||
public let apiService: APIService
|
public let apiService: APIService
|
||||||
public let authenticationService: AuthenticationService
|
public let authenticationService: AuthenticationService
|
||||||
public let emojiService: EmojiService
|
public let emojiService: EmojiService
|
||||||
public let statusPublishService = StatusPublishService()
|
// public let statusPublishService = StatusPublishService()
|
||||||
|
public let publisherService: PublisherService
|
||||||
public let notificationService: NotificationService
|
public let notificationService: NotificationService
|
||||||
public let settingService: SettingService
|
public let settingService: SettingService
|
||||||
public let instanceService: InstanceService
|
public let instanceService: InstanceService
|
||||||
@ -67,6 +68,8 @@ public class AppContext: ObservableObject {
|
|||||||
apiService: apiService
|
apiService: apiService
|
||||||
)
|
)
|
||||||
|
|
||||||
|
publisherService = .init(apiService: _apiService)
|
||||||
|
|
||||||
let _notificationService = NotificationService(
|
let _notificationService = NotificationService(
|
||||||
apiService: _apiService,
|
apiService: _apiService,
|
||||||
authenticationService: _authenticationService
|
authenticationService: _authenticationService
|
||||||
|
13
MastodonSDK/Sources/MastodonCore/AppError.swift
Normal file
13
MastodonSDK/Sources/MastodonCore/AppError.swift
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
//
|
||||||
|
// AppError.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by MainasuK on 2022-8-8.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AppError: Error {
|
||||||
|
case badRequest
|
||||||
|
case badAuthentication
|
||||||
|
}
|
28
MastodonSDK/Sources/MastodonCore/Extension/FileManager.swift
Normal file
28
MastodonSDK/Sources/MastodonCore/Extension/FileManager.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// FileManager.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by MainasuK on 2022-1-15.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension FileManager {
|
||||||
|
static let logger = Logger(subsystem: "FileManager", category: "File")
|
||||||
|
|
||||||
|
public func createTemporaryFileURL(
|
||||||
|
filename: String,
|
||||||
|
pathExtension: String
|
||||||
|
) throws -> URL {
|
||||||
|
let tempDirectoryURL = FileManager.default.temporaryDirectory
|
||||||
|
let fileURL = tempDirectoryURL
|
||||||
|
.appendingPathComponent(filename)
|
||||||
|
.appendingPathExtension(pathExtension)
|
||||||
|
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
|
||||||
|
Self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): create temporary file at: \(fileURL.debugDescription)")
|
||||||
|
|
||||||
|
return fileURL
|
||||||
|
}
|
||||||
|
}
|
145
MastodonSDK/Sources/MastodonCore/Extension/NSItemProvider.swift
Normal file
145
MastodonSDK/Sources/MastodonCore/Extension/NSItemProvider.swift
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
//
|
||||||
|
// NSItemProvider.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by MainasuK on 2021/11/19.
|
||||||
|
//
|
||||||
|
|
||||||
|
import os.log
|
||||||
|
import Foundation
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
import MobileCoreServices
|
||||||
|
import PhotosUI
|
||||||
|
|
||||||
|
// load image with low memory usage
|
||||||
|
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
|
||||||
|
|
||||||
|
extension NSItemProvider {
|
||||||
|
|
||||||
|
static let logger = Logger(subsystem: "NSItemProvider", category: "Logic")
|
||||||
|
|
||||||
|
public struct ImageLoadResult {
|
||||||
|
public let data: Data
|
||||||
|
public let type: UTType?
|
||||||
|
|
||||||
|
public init(data: Data, type: UTType?) {
|
||||||
|
self.data = data
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadImageData() async throws -> ImageLoadResult? {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
|
||||||
|
if let error = error {
|
||||||
|
continuation.resume(with: .failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = url else {
|
||||||
|
continuation.resume(with: .success(nil))
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
#if APP_EXTENSION
|
||||||
|
let maxPixelSize: Int = 4096 // not limit but may upload fail
|
||||||
|
#else
|
||||||
|
let maxPixelSize: Int = 1536 // fit 120MB RAM limit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let downsampleOptions = [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
|
||||||
|
] as CFDictionary
|
||||||
|
|
||||||
|
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else {
|
||||||
|
continuation.resume(with: .success(nil))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = NSMutableData()
|
||||||
|
guard let imageDestination = CGImageDestinationCreateWithData(data, kUTTypeJPEG, 1, nil) else {
|
||||||
|
continuation.resume(with: .success(nil))
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let isPNG: Bool = {
|
||||||
|
guard let utType = cgImage.utType else { return false }
|
||||||
|
return (utType as String) == UTType.png.identifier
|
||||||
|
|
||||||
|
}()
|
||||||
|
|
||||||
|
let destinationProperties = [
|
||||||
|
kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75
|
||||||
|
] as CFDictionary
|
||||||
|
|
||||||
|
CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties)
|
||||||
|
CGImageDestinationFinalize(imageDestination)
|
||||||
|
|
||||||
|
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
|
||||||
|
NSItemProvider.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load image \(dataSize)")
|
||||||
|
|
||||||
|
let result = ImageLoadResult(
|
||||||
|
data: data as Data,
|
||||||
|
type: cgImage.utType.flatMap { UTType($0 as String) }
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.resume(with: .success(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSItemProvider {
|
||||||
|
|
||||||
|
public struct VideoLoadResult {
|
||||||
|
public let url: URL
|
||||||
|
public let sizeInBytes: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadVideoData() async throws -> VideoLoadResult? {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
|
||||||
|
if let error = error {
|
||||||
|
continuation.resume(with: .failure(error))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = url,
|
||||||
|
let attribute = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
|
let sizeInBytes = attribute[.size] as? UInt64
|
||||||
|
else {
|
||||||
|
continuation.resume(with: .success(nil))
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fileURL = try FileManager.default.createTemporaryFileURL(
|
||||||
|
filename: UUID().uuidString,
|
||||||
|
pathExtension: url.pathExtension
|
||||||
|
)
|
||||||
|
try FileManager.default.copyItem(at: url, to: fileURL)
|
||||||
|
let result = VideoLoadResult(
|
||||||
|
url: fileURL,
|
||||||
|
sizeInBytes: sizeInBytes
|
||||||
|
)
|
||||||
|
|
||||||
|
continuation.resume(with: .success(result))
|
||||||
|
} catch {
|
||||||
|
continuation.resume(with: .failure(error))
|
||||||
|
}
|
||||||
|
} // end loadFileRepresentation
|
||||||
|
} // end try await withCheckedThrowingContinuation
|
||||||
|
} // end func
|
||||||
|
|
||||||
|
}
|
@ -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 {
|
public final class MultipleConfiguration: Hashable, ObservableObject {
|
||||||
private let id = UUID()
|
private let id = UUID()
|
||||||
|
|
||||||
@Published public var isMultiple = false
|
@Published public var isMultiple: Option = false
|
||||||
|
|
||||||
public init() {
|
public init() {
|
||||||
// end init
|
// end init
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public typealias Option = Bool
|
||||||
|
|
||||||
public static func == (lhs: MultipleConfiguration, rhs: MultipleConfiguration) -> Bool {
|
public static func == (lhs: MultipleConfiguration, rhs: MultipleConfiguration) -> Bool {
|
||||||
return lhs.id == rhs.id
|
return lhs.id == rhs.id
|
||||||
&& lhs.isMultiple == rhs.isMultiple
|
&& lhs.isMultiple == rhs.isMultiple
|
||||||
|
@ -25,7 +25,7 @@ public final class APIService {
|
|||||||
let session: URLSession
|
let session: URLSession
|
||||||
|
|
||||||
// input
|
// input
|
||||||
let backgroundManagedObjectContext: NSManagedObjectContext
|
public let backgroundManagedObjectContext: NSManagedObjectContext
|
||||||
|
|
||||||
// output
|
// output
|
||||||
public let error = PassthroughSubject<APIError, Never>()
|
public let error = PassthroughSubject<APIError, Never>()
|
||||||
|
@ -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
|
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
|
||||||
let serialStream = query.serialStream
|
let serialStream = query.serialStream
|
||||||
request.httpBodyStream = serialStream.boundStreams.input
|
request.httpBodyStream = serialStream.boundStreams.input
|
||||||
|
|
||||||
|
// total unit count in bytes count
|
||||||
|
// will small than actally count due to multipart protocol meta
|
||||||
|
serialStream.progress.totalUnitCount = {
|
||||||
|
var size = 0
|
||||||
|
size += query.file?.sizeInByte ?? 0
|
||||||
|
size += query.thumbnail?.sizeInByte ?? 0
|
||||||
|
return Int64(size)
|
||||||
|
}()
|
||||||
|
query.progress.addChild(
|
||||||
|
serialStream.progress,
|
||||||
|
withPendingUnitCount: query.progress.totalUnitCount
|
||||||
|
)
|
||||||
|
|
||||||
return session.dataTaskPublisher(for: request)
|
return session.dataTaskPublisher(for: request)
|
||||||
.tryMap { data, response in
|
.tryMap { data, response in
|
||||||
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
|
||||||
@ -62,6 +76,12 @@ extension Mastodon.API.Media {
|
|||||||
public let description: String?
|
public let description: String?
|
||||||
public let focus: String?
|
public let focus: String?
|
||||||
|
|
||||||
|
public let progress: Progress = {
|
||||||
|
let progress = Progress()
|
||||||
|
progress.totalUnitCount = 100
|
||||||
|
return progress
|
||||||
|
}()
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
file: Mastodon.Query.MediaAttachment?,
|
file: Mastodon.Query.MediaAttachment?,
|
||||||
thumbnail: Mastodon.Query.MediaAttachment?,
|
thumbnail: Mastodon.Query.MediaAttachment?,
|
||||||
|
@ -53,6 +53,18 @@ extension Mastodon.Query.MediaAttachment {
|
|||||||
var base64EncondedString: String? {
|
var base64EncondedString: String? {
|
||||||
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
|
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var sizeInByte: Int? {
|
||||||
|
switch self {
|
||||||
|
case .jpeg(let data), .gif(let data), .png(let data):
|
||||||
|
return data?.count
|
||||||
|
case .other(let url, _, _):
|
||||||
|
guard let url = url else { return nil }
|
||||||
|
guard let attribute = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
|
||||||
|
guard let size = attribute[.size] as? UInt64 else { return nil }
|
||||||
|
return Int(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
extension Mastodon.Query.MediaAttachment: MultipartFormValue {
|
||||||
|
@ -15,6 +15,10 @@ import Combine
|
|||||||
// - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3
|
// - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3
|
||||||
|
|
||||||
final class SerialStream: NSObject {
|
final class SerialStream: NSObject {
|
||||||
|
|
||||||
|
let logger = Logger(subsystem: "SerialStream", category: "Stream")
|
||||||
|
|
||||||
|
public let progress = Progress()
|
||||||
var writingTimerSubscriber: AnyCancellable?
|
var writingTimerSubscriber: AnyCancellable?
|
||||||
|
|
||||||
// serial stream source
|
// serial stream source
|
||||||
@ -70,10 +74,14 @@ final class SerialStream: NSObject {
|
|||||||
var baseAddress = 0
|
var baseAddress = 0
|
||||||
var remainsBytes = readBytesCount
|
var remainsBytes = readBytesCount
|
||||||
while remainsBytes > 0 {
|
while remainsBytes > 0 {
|
||||||
let result = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
|
let writeResult = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
|
||||||
baseAddress += result
|
baseAddress += writeResult
|
||||||
remainsBytes -= result
|
remainsBytes -= writeResult
|
||||||
os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, result)
|
|
||||||
|
os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, writeResult)
|
||||||
|
|
||||||
|
self.progress.completedUnitCount += Int64(writeResult)
|
||||||
|
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): estimate progress: \(self.progress.completedUnitCount)/\(self.progress.totalUnitCount)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 UIKit
|
||||||
import Combine
|
import Combine
|
||||||
import CoreDataStack
|
import CoreDataStack
|
||||||
import MastodonCore
|
|
||||||
import Meta
|
import Meta
|
||||||
import MastodonMeta
|
|
||||||
import MetaTextKit
|
import MetaTextKit
|
||||||
|
import MastodonMeta
|
||||||
|
import MastodonCore
|
||||||
|
import MastodonSDK
|
||||||
|
|
||||||
public final class ComposeContentViewModel: NSObject, ObservableObject {
|
public final class ComposeContentViewModel: NSObject, ObservableObject {
|
||||||
|
|
||||||
@ -29,6 +30,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
let kind: Kind
|
let kind: Kind
|
||||||
|
|
||||||
@Published var viewLayoutFrame = ViewLayoutFrame()
|
@Published var viewLayoutFrame = ViewLayoutFrame()
|
||||||
|
|
||||||
|
// author (me)
|
||||||
@Published var authContext: AuthContext
|
@Published var authContext: AuthContext
|
||||||
|
|
||||||
// output
|
// output
|
||||||
@ -67,6 +70,11 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
@Published var name: MetaContent = PlaintextMetaContent(string: "")
|
@Published var name: MetaContent = PlaintextMetaContent(string: "")
|
||||||
@Published var username: String = ""
|
@Published var username: String = ""
|
||||||
|
|
||||||
|
// attachment
|
||||||
|
@Published public var attachmentViewModels: [AttachmentViewModel] = []
|
||||||
|
@Published public var maxMediaAttachmentLimit = 4
|
||||||
|
// @Published public internal(set) var isMediaValid = true
|
||||||
|
|
||||||
// poll
|
// poll
|
||||||
@Published var isPollActive = false
|
@Published var isPollActive = false
|
||||||
@Published public var pollOptions: [PollComposeItem.Option] = {
|
@Published public var pollOptions: [PollComposeItem.Option] = {
|
||||||
@ -77,11 +85,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
return options
|
return options
|
||||||
}()
|
}()
|
||||||
@Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay
|
@Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay
|
||||||
|
@Published public var pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option = false
|
||||||
|
|
||||||
@Published public var maxPollOptionLimit = 4
|
@Published public var maxPollOptionLimit = 4
|
||||||
|
|
||||||
// emoji
|
// emoji
|
||||||
@Published var isEmojiActive = false
|
@Published var isEmojiActive = false
|
||||||
|
|
||||||
|
// visibility
|
||||||
|
@Published var visibility: Mastodon.Entity.Status.Visibility
|
||||||
|
|
||||||
// UI & UX
|
// UI & UX
|
||||||
@Published var replyToCellFrame: CGRect = .zero
|
@Published var replyToCellFrame: CGRect = .zero
|
||||||
@Published var contentCellFrame: CGRect = .zero
|
@Published var contentCellFrame: CGRect = .zero
|
||||||
@ -96,6 +109,41 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
|
|||||||
self.context = context
|
self.context = context
|
||||||
self.authContext = authContext
|
self.authContext = authContext
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
|
self.visibility = {
|
||||||
|
// default private when user locked
|
||||||
|
var visibility: Mastodon.Entity.Status.Visibility = {
|
||||||
|
guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user else {
|
||||||
|
return .public
|
||||||
|
}
|
||||||
|
return author.locked ? .private : .public
|
||||||
|
}()
|
||||||
|
// set visibility for reply post
|
||||||
|
switch kind {
|
||||||
|
case .reply(let record):
|
||||||
|
context.managedObjectContext.performAndWait {
|
||||||
|
guard let status = record.object(in: context.managedObjectContext) else {
|
||||||
|
assertionFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let repliedStatusVisibility = status.visibility
|
||||||
|
switch repliedStatusVisibility {
|
||||||
|
case .public, .unlisted:
|
||||||
|
// keep default
|
||||||
|
break
|
||||||
|
case .private:
|
||||||
|
visibility = .private
|
||||||
|
case .direct:
|
||||||
|
visibility = .direct
|
||||||
|
case ._other:
|
||||||
|
assertionFailure()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return visibility
|
||||||
|
}()
|
||||||
super.init()
|
super.init()
|
||||||
// end init
|
// end init
|
||||||
|
|
||||||
@ -162,6 +210,71 @@ extension ComposeContentViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ComposeContentViewModel {
|
||||||
|
public enum ComposeError: LocalizedError {
|
||||||
|
case pollHasEmptyOption
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .pollHasEmptyOption:
|
||||||
|
return "The post poll is invalid" // TODO: i18n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var failureReason: String? {
|
||||||
|
switch self {
|
||||||
|
case .pollHasEmptyOption:
|
||||||
|
return "The poll has empty option" // TODO: i18n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func statusPublisher() throws -> StatusPublisher {
|
||||||
|
let authContext = self.authContext
|
||||||
|
|
||||||
|
// author
|
||||||
|
let managedObjectContext = self.context.managedObjectContext
|
||||||
|
var _author: ManagedObjectRecord<MastodonUser>?
|
||||||
|
managedObjectContext.performAndWait {
|
||||||
|
_author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user.asRecrod
|
||||||
|
}
|
||||||
|
guard let author = _author else {
|
||||||
|
throw AppError.badAuthentication
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll
|
||||||
|
_ = try {
|
||||||
|
guard isPollActive else { return }
|
||||||
|
let isAllNonEmpty = pollOptions
|
||||||
|
.map { $0.text }
|
||||||
|
.allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||||
|
guard isAllNonEmpty else {
|
||||||
|
throw ComposeError.pollHasEmptyOption
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return MastodonStatusPublisher(
|
||||||
|
author: author,
|
||||||
|
replyTo: {
|
||||||
|
switch self.kind {
|
||||||
|
case .reply(let status): return status
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}(),
|
||||||
|
isContentWarningComposing: isContentWarningActive,
|
||||||
|
contentWarning: contentWarning,
|
||||||
|
content: content,
|
||||||
|
isMediaSensitive: isContentWarningActive,
|
||||||
|
attachmentViewModels: attachmentViewModels,
|
||||||
|
isPollComposing: isPollActive,
|
||||||
|
pollOptions: pollOptions,
|
||||||
|
pollExpireConfigurationOption: pollExpireConfigurationOption,
|
||||||
|
pollMultipleConfigurationOption: pollMultipleConfigurationOption,
|
||||||
|
visibility: visibility
|
||||||
|
)
|
||||||
|
} // end func publisher()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - UITextViewDelegate
|
// MARK: - UITextViewDelegate
|
||||||
extension ComposeContentViewModel: UITextViewDelegate {
|
extension ComposeContentViewModel: UITextViewDelegate {
|
||||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
public func textViewDidBeginEditing(_ textView: UITextView) {
|
||||||
|
@ -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
Block a user