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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,6 +49,29 @@ final class HomeTimelineNavigationBarTitleViewModel {
.assign(to: \.value, on: isOffline)
.store(in: &disposeBag)
Publishers.CombineLatest(
context.publisherService.$statusPublishers,
context.publisherService.statusPublishResult.prepend(.failure(AppError.badRequest))
)
.receive(on: DispatchQueue.main)
.sink { [weak self] statusPublishers, publishResult in
guard let self = self else { return }
if statusPublishers.isEmpty {
self.isPublishingPost.value = false
self.isPublished.value = false
} else {
self.isPublishingPost.value = true
switch publishResult {
case .success:
self.isPublished.value = true
case .failure:
self.isPublished.value = false
}
}
}
.store(in: &disposeBag)
// context.statusPublishService.latestPublishingComposeViewModel
// .receive(on: DispatchQueue.main)
// .sink { [weak self] composeViewModel in
@ -82,19 +105,19 @@ final class HomeTimelineNavigationBarTitleViewModel {
.assign(to: \.value, on: state)
.store(in: &disposeBag)
state
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] state in
guard let self = self else { return }
switch state {
case .publishingPostLabel:
self.setupPublishingProgress()
default:
self.suspendPublishingProgress()
}
}
.store(in: &disposeBag)
// state
// .removeDuplicates()
// .receive(on: DispatchQueue.main)
// .sink { [weak self] state in
// guard let self = self else { return }
// switch state {
// case .publishingPostLabel:
// self.setupPublishingProgress()
// default:
// self.suspendPublishingProgress()
// }
// }
// .store(in: &disposeBag)
}
}
@ -150,26 +173,26 @@ extension HomeTimelineNavigationBarTitleViewModel {
}
// MARK: Publish post state
extension HomeTimelineNavigationBarTitleViewModel {
func setupPublishingProgress() {
let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS
.autoconnect()
.share()
.eraseToAnyPublisher()
publishingProgressSubscription = progressUpdatePublisher
.map { _ in Float(0) }
.scan(0.0) { progress, _ -> Float in
return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS)
}
.subscribe(publishingProgress)
}
func suspendPublishingProgress() {
publishingProgressSubscription = nil
publishingProgress.send(0)
}
}
//extension HomeTimelineNavigationBarTitleViewModel {
//
// func setupPublishingProgress() {
// let progressUpdatePublisher = Timer.publish(every: 0.016, on: .main, in: .common) // ~ 60FPS
// .autoconnect()
// .share()
// .eraseToAnyPublisher()
//
// publishingProgressSubscription = progressUpdatePublisher
// .map { _ in Float(0) }
// .scan(0.0) { progress, _ -> Float in
// return 0.95 * progress + 0.05 // progress + 0.05 * (1.0 - progress). ~ 1 sec to 0.95 (under 60FPS)
// }
// .subscribe(publishingProgress)
// }
//
// func suspendPublishingProgress() {
// publishingProgressSubscription = nil
// publishingProgress.send(0)
// }
//
//}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,28 @@
//
// FileManager.swift
//
//
// Created by MainasuK on 2022-1-15.
//
import os.log
import Foundation
extension FileManager {
static let logger = Logger(subsystem: "FileManager", category: "File")
public func createTemporaryFileURL(
filename: String,
pathExtension: String
) throws -> URL {
let tempDirectoryURL = FileManager.default.temporaryDirectory
let fileURL = tempDirectoryURL
.appendingPathComponent(filename)
.appendingPathExtension(pathExtension)
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
Self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): create temporary file at: \(fileURL.debugDescription)")
return fileURL
}
}

View File

@ -0,0 +1,145 @@
//
// NSItemProvider.swift
//
//
// Created by MainasuK on 2021/11/19.
//
import os.log
import Foundation
import UniformTypeIdentifiers
import MobileCoreServices
import PhotosUI
// load image with low memory usage
// Refs: https://christianselig.com/2020/09/phpickerviewcontroller-efficiently/
extension NSItemProvider {
static let logger = Logger(subsystem: "NSItemProvider", category: "Logic")
public struct ImageLoadResult {
public let data: Data
public let type: UTType?
public init(data: Data, type: UTType?) {
self.data = data
self.type = type
}
}
public func loadImageData() async throws -> ImageLoadResult? {
try await withCheckedThrowingContinuation { continuation in
loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, error in
if let error = error {
continuation.resume(with: .failure(error))
return
}
guard let url = url else {
continuation.resume(with: .success(nil))
assertionFailure()
return
}
let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, sourceOptions) else {
return
}
#if APP_EXTENSION
let maxPixelSize: Int = 4096 // not limit but may upload fail
#else
let maxPixelSize: Int = 1536 // fit 120MB RAM limit
#endif
let downsampleOptions = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
] as CFDictionary
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions) else {
continuation.resume(with: .success(nil))
return
}
let data = NSMutableData()
guard let imageDestination = CGImageDestinationCreateWithData(data, kUTTypeJPEG, 1, nil) else {
continuation.resume(with: .success(nil))
assertionFailure()
return
}
let isPNG: Bool = {
guard let utType = cgImage.utType else { return false }
return (utType as String) == UTType.png.identifier
}()
let destinationProperties = [
kCGImageDestinationLossyCompressionQuality: isPNG ? 1.0 : 0.75
] as CFDictionary
CGImageDestinationAddImage(imageDestination, cgImage, destinationProperties)
CGImageDestinationFinalize(imageDestination)
let dataSize = ByteCountFormatter.string(fromByteCount: Int64(data.length), countStyle: .memory)
NSItemProvider.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): load image \(dataSize)")
let result = ImageLoadResult(
data: data as Data,
type: cgImage.utType.flatMap { UTType($0 as String) }
)
continuation.resume(with: .success(result))
}
}
}
}
extension NSItemProvider {
public struct VideoLoadResult {
public let url: URL
public let sizeInBytes: UInt64
}
public func loadVideoData() async throws -> VideoLoadResult? {
try await withCheckedThrowingContinuation { continuation in
loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
if let error = error {
continuation.resume(with: .failure(error))
return
}
guard let url = url,
let attribute = try? FileManager.default.attributesOfItem(atPath: url.path),
let sizeInBytes = attribute[.size] as? UInt64
else {
continuation.resume(with: .success(nil))
assertionFailure()
return
}
do {
let fileURL = try FileManager.default.createTemporaryFileURL(
filename: UUID().uuidString,
pathExtension: url.pathExtension
)
try FileManager.default.copyItem(at: url, to: fileURL)
let result = VideoLoadResult(
url: fileURL,
sizeInBytes: sizeInBytes
)
continuation.resume(with: .success(result))
} catch {
continuation.resume(with: .failure(error))
}
} // end loadFileRepresentation
} // end try await withCheckedThrowingContinuation
} // end func
}

View File

@ -0,0 +1,15 @@
//
// NSKeyValueObservation.swift
// Twidere
//
// Created by Cirno MainasuK on 2020-7-20.
// Copyright © 2020 Twidere. All rights reserved.
//
import Foundation
extension NSKeyValueObservation {
public func store(in set: inout Set<NSKeyValueObservation>) {
set.insert(self)
}
}

View File

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

View File

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

View File

@ -0,0 +1,109 @@
//
// PublisherService.swift
//
//
// Created by MainasuK on 2021-12-2.
//
import os.log
import UIKit
import Combine
public final class PublisherService {
var disposeBag = Set<AnyCancellable>()
let logger = Logger(subsystem: "PublisherService", category: "Service")
// input
let apiService: APIService
@Published public private(set) var statusPublishers: [StatusPublisher] = []
// output
public let statusPublishResult = PassthroughSubject<Result<StatusPublishResult, Error>, Never>()
var currentPublishProgressObservation: NSKeyValueObservation?
@Published public var currentPublishProgress: Double = 0
public init(
apiService: APIService
) {
self.apiService = apiService
$statusPublishers
.receive(on: DispatchQueue.main)
.sink { [weak self] publishers in
guard let self = self else { return }
guard let last = publishers.last else {
self.currentPublishProgressObservation = nil
return
}
self.currentPublishProgressObservation = last.progress
.observe(\.fractionCompleted, options: [.initial, .new]) { [weak self] progress, _ in
guard let self = self else { return }
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish progress \(progress.fractionCompleted)")
self.currentPublishProgress = progress.fractionCompleted
}
}
.store(in: &disposeBag)
$statusPublishers
.filter { $0.isEmpty }
.delay(for: 1, scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self = self else { return }
self.currentPublishProgress = 0
}
.store(in: &disposeBag)
statusPublishResult
.receive(on: DispatchQueue.main)
.sink { result in
switch result {
case .success:
break
// TODO:
// update store review count trigger
// UserDefaults.shared.storeReviewInteractTriggerCount += 1
case .failure:
break
}
}
.store(in: &disposeBag)
}
}
extension PublisherService {
@MainActor
public func enqueue(statusPublisher publisher: StatusPublisher, authContext: AuthContext) {
guard !statusPublishers.contains(where: { $0 === publisher }) else {
assertionFailure()
return
}
statusPublishers.append(publisher)
Task {
do {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish status…")
let result = try await publisher.publish(api: apiService, authContext: authContext)
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish status success")
self.statusPublishResult.send(.success(result))
self.statusPublishers.removeAll(where: { $0 === publisher })
} catch is CancellationError {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish cancelled")
self.statusPublishers.removeAll(where: { $0 === publisher })
} catch {
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): publish failure: \(error.localizedDescription)")
self.statusPublishResult.send(.failure(error))
self.currentPublishProgress = 0
}
}
}
}

View File

@ -0,0 +1,13 @@
//
// StatusPublishResult.swift
//
//
// Created by MainasuK on 2021-11-26.
//
import Foundation
import MastodonSDK
public enum StatusPublishResult {
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Status>)
}

View File

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

View File

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

View File

@ -0,0 +1,14 @@
//
// StatusPublisherState.swift
//
//
// Created by MainasuK on 2021-11-26.
//
import Foundation
public enum StatusPublisherState {
case pending
case failure(Error)
case success
}

View File

@ -44,6 +44,20 @@ extension Mastodon.API.Media {
request.timeoutInterval = 180 // should > 200 Kb/s for 40 MiB media attachment
let serialStream = query.serialStream
request.httpBodyStream = serialStream.boundStreams.input
// total unit count in bytes count
// will small than actally count due to multipart protocol meta
serialStream.progress.totalUnitCount = {
var size = 0
size += query.file?.sizeInByte ?? 0
size += query.thumbnail?.sizeInByte ?? 0
return Int64(size)
}()
query.progress.addChild(
serialStream.progress,
withPendingUnitCount: query.progress.totalUnitCount
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: Mastodon.Entity.Attachment.self, from: data, response: response)
@ -62,6 +76,12 @@ extension Mastodon.API.Media {
public let description: String?
public let focus: String?
public let progress: Progress = {
let progress = Progress()
progress.totalUnitCount = 100
return progress
}()
public init(
file: Mastodon.Query.MediaAttachment?,
thumbnail: Mastodon.Query.MediaAttachment?,

View File

@ -53,6 +53,18 @@ extension Mastodon.Query.MediaAttachment {
var base64EncondedString: String? {
return data.map { "data:" + mimeType + ";base64," + $0.base64EncodedString() }
}
var sizeInByte: Int? {
switch self {
case .jpeg(let data), .gif(let data), .png(let data):
return data?.count
case .other(let url, _, _):
guard let url = url else { return nil }
guard let attribute = try? FileManager.default.attributesOfItem(atPath: url.path) else { return nil }
guard let size = attribute[.size] as? UInt64 else { return nil }
return Int(size)
}
}
}
extension Mastodon.Query.MediaAttachment: MultipartFormValue {

View File

@ -15,6 +15,10 @@ import Combine
// - https://gist.github.com/khanlou/b5e07f963bedcb6e0fcc5387b46991c3
final class SerialStream: NSObject {
let logger = Logger(subsystem: "SerialStream", category: "Stream")
public let progress = Progress()
var writingTimerSubscriber: AnyCancellable?
// serial stream source
@ -70,10 +74,14 @@ final class SerialStream: NSObject {
var baseAddress = 0
var remainsBytes = readBytesCount
while remainsBytes > 0 {
let result = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
baseAddress += result
remainsBytes -= result
os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, result)
let writeResult = self.boundStreams.output.write(&self.buffer[baseAddress], maxLength: remainsBytes)
baseAddress += writeResult
remainsBytes -= writeResult
os_log(.debug, "%{public}s[%{public}ld], %{public}s: write %ld/%ld bytes. write result: %ld", ((#file as NSString).lastPathComponent), #line, #function, baseAddress, readBytesCount, writeResult)
self.progress.completedUnitCount += Int64(writeResult)
self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): estimate progress: \(self.progress.completedUnitCount)/\(self.progress.totalUnitCount)")
}
}

View File

@ -0,0 +1,37 @@
//
// UIAlertController.swift
// TwidereX
//
// Created by Cirno MainasuK on 2020-7-1.
// Copyright © 2020 Dimension. All rights reserved.
//
import UIKit
extension UIAlertController {
public static func standardAlert(of error: Error) -> UIAlertController {
let title: String? = {
if let error = error as? LocalizedError {
return error.errorDescription
} else {
return "Error"
}
}()
let message: String? = {
if let error = error as? LocalizedError {
return [error.failureReason, error.recoverySuggestion].compactMap { $0 }.joined(separator: "\n")
} else {
return error.localizedDescription
}
}()
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let okAction = UIAlertAction(title: "OK", style: .default, handler: nil)
alertController.addAction(okAction)
return alertController
}
}

View File

@ -0,0 +1,246 @@
//
// AttachmentView.swift
//
//
// Created by MainasuK on 2022-5-20.
//
import os.log
import UIKit
import SwiftUI
import Introspect
import AVKit
public struct AttachmentView: View {
static let size = CGSize(width: 56, height: 56)
static let cornerRadius: CGFloat = 8
@ObservedObject var viewModel: AttachmentViewModel
let action: (Action) -> Void
@State var isCaptionEditorPresented = false
@State var caption = ""
public var body: some View {
Text("Hello")
// Menu {
// menu
// } label: {
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// .frame(width: AttachmentView.size.width, height: AttachmentView.size.height)
// .overlay {
// ZStack {
// // spinner
// if viewModel.output == nil {
// Color.clear
// .background(.ultraThinMaterial)
// ProgressView()
// .progressViewStyle(CircularProgressViewStyle())
// .foregroundStyle(.regularMaterial)
// }
// // border
// RoundedRectangle(cornerRadius: AttachmentView.cornerRadius)
// .stroke(Color.black.opacity(0.05))
// }
// .transition(.opacity)
// }
// .overlay(alignment: .bottom) {
// HStack(alignment: .bottom) {
// // alt
// VStack(spacing: 2) {
// switch viewModel.output {
// case .video:
// Image(uiImage: Asset.Media.playerRectangle.image)
// .resizable()
// .frame(width: 16, height: 12)
// default:
// EmptyView()
// }
// if !viewModel.caption.isEmpty {
// Image(uiImage: Asset.Media.altRectangle.image)
// .resizable()
// .frame(width: 16, height: 12)
// }
// }
// Spacer()
// // option
// Image(systemName: "ellipsis")
// .resizable()
// .frame(width: 12, height: 12)
// .symbolVariant(.circle)
// .symbolVariant(.fill)
// .symbolRenderingMode(.palette)
// .foregroundStyle(.white, .black)
// }
// .padding(6)
// }
// .cornerRadius(AttachmentView.cornerRadius)
// } // end Menu
// .sheet(isPresented: $isCaptionEditorPresented) {
// captionSheet
// } // end caption sheet
// .sheet(isPresented: $viewModel.isPreviewPresented) {
// previewSheet
// } // end preview sheet
} // end body
// var menu: some View {
// Group {
// Button(
// action: {
// action(.preview)
// },
// label: {
// Label(L10n.Scene.Compose.Media.preview, systemImage: "photo")
// }
// )
// // caption
// let canAddCaption: Bool = {
// switch viewModel.output {
// case .image: return true
// case .video: return false
// case .none: return false
// }
// }()
// if canAddCaption {
// Button(
// action: {
// action(.caption)
// caption = viewModel.caption
// isCaptionEditorPresented.toggle()
// },
// label: {
// let title = viewModel.caption.isEmpty ? L10n.Scene.Compose.Media.Caption.add : L10n.Scene.Compose.Media.Caption.update
// Label(title, systemImage: "text.bubble")
// // FIXME: https://stackoverflow.com/questions/72318730/how-to-customize-swiftui-menu
// // add caption subtitle
// }
// )
// }
// Divider()
// // remove
// Button(
// role: .destructive,
// action: {
// action(.remove)
// },
// label: {
// Label(L10n.Scene.Compose.Media.remove, systemImage: "minus.circle")
// }
// )
// }
// }
// var captionSheet: some View {
// NavigationView {
// ScrollView(.vertical) {
// VStack {
// // preview
// switch viewModel.output {
// case .image:
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// case .video(let url, _):
// let player = AVPlayer(url: url)
// VideoPlayer(player: player)
// .frame(height: 300)
// case .none:
// EmptyView()
// }
// // caption textField
// TextField(
// text: $caption,
// prompt: Text(L10n.Scene.Compose.Media.Caption.addADescriptionForThisImage)
// ) {
// Text(L10n.Scene.Compose.Media.Caption.update)
// }
// .padding()
// .introspectTextField { textField in
// textField.becomeFirstResponder()
// }
// }
// }
// .navigationTitle(L10n.Scene.Compose.Media.Caption.update)
// .navigationBarTitleDisplayMode(.inline)
// .toolbar {
// ToolbarItem(placement: .navigationBarLeading) {
// Button {
// isCaptionEditorPresented.toggle()
// } label: {
// Image(systemName: "xmark.circle.fill")
// .resizable()
// .frame(width: 30, height: 30, alignment: .center)
// .symbolRenderingMode(.hierarchical)
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
// }
// }
// ToolbarItem(placement: .navigationBarTrailing) {
// Button {
// viewModel.caption = caption.trimmingCharacters(in: .whitespacesAndNewlines)
// isCaptionEditorPresented.toggle()
// } label: {
// Text(L10n.Common.Controls.Actions.save)
// }
// }
// }
// } // end NavigationView
// }
// design for share extension
// preferred UIKit preview in app
// var previewSheet: some View {
// NavigationView {
// ScrollView(.vertical) {
// VStack {
// // preview
// switch viewModel.output {
// case .image:
// let image = viewModel.thumbnail ?? .placeholder(color: .systemGray3)
// Image(uiImage: image)
// .resizable()
// .aspectRatio(contentMode: .fill)
// case .video(let url, _):
// let player = AVPlayer(url: url)
// VideoPlayer(player: player)
// .frame(height: 300)
// case .none:
// EmptyView()
// }
// Spacer()
// }
// }
// .navigationTitle(L10n.Scene.Compose.Media.preview)
// .navigationBarTitleDisplayMode(.inline)
// .toolbar {
// ToolbarItem(placement: .navigationBarLeading) {
// Button {
// viewModel.isPreviewPresented.toggle()
// } label: {
// Image(systemName: "xmark.circle.fill")
// .resizable()
// .frame(width: 30, height: 30, alignment: .center)
// .symbolRenderingMode(.hierarchical)
// .foregroundStyle(Color(uiColor: .secondaryLabel), Color(uiColor: .tertiaryLabel))
// }
// }
// }
// } // end NavigationView
// }
}
extension AttachmentView {
public enum Action: Hashable {
case preview
case caption
case remove
}
}

View File

@ -0,0 +1,316 @@
//
// AttachmentViewModel+Upload.swift
//
//
// Created by MainasuK on 2021-11-26.
//
import os.log
import UIKit
import Kingfisher
import UniformTypeIdentifiers
import MastodonCore
import MastodonSDK
// objc.io
// ref: https://talk.objc.io/episodes/S01E269-swift-concurrency-async-sequences-part-1
struct Chunked<Base: AsyncSequence>: AsyncSequence where Base.Element == UInt8 {
var base: Base
var chunkSize: Int = 1 * 1024 * 1024 // 1 MiB
typealias Element = Data
struct AsyncIterator: AsyncIteratorProtocol {
var base: Base.AsyncIterator
var chunkSize: Int
mutating func next() async throws -> Data? {
var result = Data()
while let element = try await base.next() {
result.append(element)
if result.count == chunkSize { return result }
}
return result.isEmpty ? nil : result
}
}
func makeAsyncIterator() -> AsyncIterator {
AsyncIterator(base: base.makeAsyncIterator(), chunkSize: chunkSize)
}
}
extension AsyncSequence where Element == UInt8 {
var chunked: Chunked<Self> {
Chunked(base: self)
}
}
extension Data {
fileprivate func chunks(size: Int) -> [Data] {
return stride(from: 0, to: count, by: size).map {
Data(self[$0..<Swift.min(count, $0 + size)])
}
}
}
// Twitter Only
//extension AttachmentViewModel {
// class SliceResult {
//
// let fileURL: URL
// let chunks: Chunked<FileHandle.AsyncBytes>
// let chunkCount: Int
// let type: UTType
// let sizeInBytes: UInt64
//
// public init?(
// url: URL,
// type: UTType
// ) {
// guard let chunks = try? FileHandle(forReadingFrom: url).bytes.chunked else { return nil }
// let _sizeInBytes: UInt64? = {
// let attribute = try? FileManager.default.attributesOfItem(atPath: url.path)
// return attribute?[.size] as? UInt64
// }()
// guard let sizeInBytes = _sizeInBytes else { return nil }
//
// self.fileURL = url
// self.chunks = chunks
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
// self.type = type
// self.sizeInBytes = sizeInBytes
// }
//
// public init?(
// imageData: Data,
// type: UTType
// ) {
// let _fileURL = try? FileManager.default.createTemporaryFileURL(
// filename: UUID().uuidString,
// pathExtension: imageData.kf.imageFormat == .PNG ? "png" : "jpeg"
// )
// guard let fileURL = _fileURL else { return nil }
//
// do {
// try imageData.write(to: fileURL)
// } catch {
// return nil
// }
//
// guard let chunks = try? FileHandle(forReadingFrom: fileURL).bytes.chunked else {
// return nil
// }
// let sizeInBytes = UInt64(imageData.count)
//
// self.fileURL = fileURL
// self.chunks = chunks
// self.chunkCount = SliceResult.chunkCount(chunkSize: UInt64(chunks.chunkSize), sizeInBytes: sizeInBytes)
// self.type = type
// self.sizeInBytes = sizeInBytes
// }
//
// static func chunkCount(chunkSize: UInt64, sizeInBytes: UInt64) -> Int {
// guard sizeInBytes > 0 else { return 0 }
// let count = sizeInBytes / chunkSize
// let remains = sizeInBytes % chunkSize
// let result = remains > 0 ? count + 1 : count
// return Int(result)
// }
//
// }
//
// static func slice(output: Output, sizeLimit: SizeLimit) -> SliceResult? {
// // needs execute in background
// assert(!Thread.isMainThread)
//
// // try png then use JPEG compress with Q=0.8
// // then slice into 1MiB chunks
// switch output {
// case .image(let data, _):
// let maxPayloadSizeInBytes = sizeLimit.image
//
// // use processed imageData to remove EXIF
// guard let image = UIImage(data: data),
// var imageData = image.pngData()
// else { return nil }
//
// var didRemoveEXIF = false
// repeat {
// guard let image = KFCrossPlatformImage(data: imageData) else { return nil }
// if imageData.kf.imageFormat == .PNG {
// // A. png image
// guard let pngData = image.pngData() else { return nil }
// didRemoveEXIF = true
// if pngData.count > maxPayloadSizeInBytes {
// guard let compressedJpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress png %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
// imageData = compressedJpegData
// } else {
// os_log("%{public}s[%{public}ld], %{public}s: png %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(pngData.count) / 1024 / 1024)
// imageData = pngData
// }
// } else {
// // B. other image
// if !didRemoveEXIF {
// guard let jpegData = image.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(jpegData.count) / 1024 / 1024)
// imageData = jpegData
// didRemoveEXIF = true
// } else {
// let targetSize = CGSize(width: image.size.width * 0.8, height: image.size.height * 0.8)
// let scaledImage = image.af.imageScaled(to: targetSize)
// guard let compressedJpegData = scaledImage.jpegData(compressionQuality: 0.8) else { return nil }
// os_log("%{public}s[%{public}ld], %{public}s: compress jpeg %.2fMiB -> jpeg %.2fMiB", ((#file as NSString).lastPathComponent), #line, #function, Double(imageData.count) / 1024 / 1024, Double(compressedJpegData.count) / 1024 / 1024)
// imageData = compressedJpegData
// }
// }
// } while (imageData.count > maxPayloadSizeInBytes)
//
// return SliceResult(
// imageData: imageData,
// type: imageData.kf.imageFormat == .PNG ? UTType.png : UTType.jpeg
// )
//
//// case .gif(let url):
//// fatalError()
// case .video(let url, _):
// return SliceResult(
// url: url,
// type: .movie
// )
// }
// }
//}
extension AttachmentViewModel {
struct UploadContext {
let apiService: APIService
let authContext: AuthContext
}
enum UploadResult {
case mastodon(Mastodon.Response.Content<Mastodon.Entity.Attachment>)
}
}
extension AttachmentViewModel {
func upload(context: UploadContext) async throws -> UploadResult {
return try await uploadMastodonMedia(
context: context
)
}
private func uploadMastodonMedia(
context: UploadContext
) async throws -> UploadResult {
guard let output = self.output else {
throw AppError.badRequest
}
let attachment = output.asAttachment
let query = Mastodon.API.Media.UploadMediaQuery(
file: attachment,
thumbnail: nil,
description: {
let caption = caption.trimmingCharacters(in: .whitespacesAndNewlines)
return caption.isEmpty ? nil : caption
}(),
focus: nil // TODO:
)
// upload + N * check upload
// upload : check = 9 : 1
let uploadTaskCount: Int64 = 540
let checkUploadTaskCount: Int64 = 1
let checkUploadTaskRetryLimit: Int64 = 60
progress.totalUnitCount = uploadTaskCount + checkUploadTaskCount * checkUploadTaskRetryLimit
progress.completedUnitCount = 0
let attachmentUploadResponse: Mastodon.Response.Content<Mastodon.Entity.Attachment> = try await {
do {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [V2] upload attachment...")
progress.addChild(query.progress, withPendingUnitCount: uploadTaskCount)
return try await context.apiService.uploadMedia(
domain: context.authContext.mastodonAuthenticationBox.domain,
query: query,
mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox,
needsFallback: false
).singleOutput()
} catch {
// check needs fallback
guard let apiError = error as? Mastodon.API.Error,
apiError.httpResponseStatus == .notFound
else { throw error }
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [V1] upload attachment...")
progress.addChild(query.progress, withPendingUnitCount: uploadTaskCount)
return try await context.apiService.uploadMedia(
domain: context.authContext.mastodonAuthenticationBox.domain,
query: query,
mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox,
needsFallback: true
).singleOutput()
}
}()
// check needs wait processing (until get the `url`)
if attachmentUploadResponse.statusCode == 202 {
// note:
// the Mastodon server append the attachments in order by upload time
// can not upload concurrency
let waitProcessRetryLimit = checkUploadTaskRetryLimit
var waitProcessRetryCount: Int64 = 0
repeat {
defer {
// make sure always count + 1
waitProcessRetryCount += checkUploadTaskCount
}
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): check attachment process status")
let attachmentStatusResponse = try await context.apiService.getMedia(
attachmentID: attachmentUploadResponse.value.id,
mastodonAuthenticationBox: context.authContext.mastodonAuthenticationBox
).singleOutput()
progress.completedUnitCount += checkUploadTaskCount
if let url = attachmentStatusResponse.value.url {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment process finish: \(url)")
// escape here
progress.completedUnitCount = progress.totalUnitCount
return .mastodon(attachmentStatusResponse)
} else {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing. Retry \(waitProcessRetryCount)/\(waitProcessRetryLimit)")
await Task.sleep(1_000_000_000 * 3) // 3s
}
} while waitProcessRetryCount < waitProcessRetryLimit
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): attachment processing result discard due to exceed retry limit")
throw AppError.badRequest
} else {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success: \(attachmentUploadResponse.value.url ?? "<nil>")")
return .mastodon(attachmentUploadResponse)
}
}
}
extension AttachmentViewModel.Output {
var asAttachment: Mastodon.Query.MediaAttachment {
switch self {
case .image(let data, let kind):
switch kind {
case .png: return .png(data)
case .jpg: return .jpeg(data)
}
case .video(let url, _):
return .other(url, fileExtension: url.pathExtension, mimeType: "video/mp4")
}
}
}

View File

@ -0,0 +1,401 @@
//
// AttachmentViewModel.swift
//
//
// Created by MainasuK on 2021/11/19.
//
import os.log
import UIKit
import Combine
import PhotosUI
import Kingfisher
import MastodonCore
final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable {
static let logger = Logger(subsystem: "AttachmentViewModel", category: "ViewModel")
public let id = UUID()
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
// input
public let input: Input
@Published var caption = ""
@Published var sizeLimit = SizeLimit()
@Published public var isPreviewPresented = false
// output
@Published public private(set) var output: Output?
@Published public private(set) var thumbnail: UIImage? // original size image thumbnail
@Published var error: Error?
let progress = Progress() // upload progress
public init(input: Input) {
self.input = input
super.init()
// end init
defer {
load(input: input)
}
$output
.map { output -> UIImage? in
switch output {
case .image(let data, _):
return UIImage(data: data)
case .video(let url, _):
return AttachmentViewModel.createThumbnailForVideo(url: url)
case .none:
return nil
}
}
.assign(to: &$thumbnail)
}
deinit {
switch output {
case .image:
// FIXME:
break
case .video(let url, _):
try? FileManager.default.removeItem(at: url)
case nil :
break
}
}
}
extension AttachmentViewModel {
public enum Input: Hashable {
case image(UIImage)
case url(URL)
case pickerResult(PHPickerResult)
case itemProvider(NSItemProvider)
}
public enum Output {
case image(Data, imageKind: ImageKind)
// case gif(Data)
case video(URL, mimeType: String) // assert use file for video only
public enum ImageKind {
case png
case jpg
}
public var twitterMediaCategory: TwitterMediaCategory {
switch self {
case .image: return .image
case .video: return .amplifyVideo
}
}
}
public struct SizeLimit {
public let image: Int
public let gif: Int
public let video: Int
public init(
image: Int = 5 * 1024 * 1024, // 5 MiB,
gif: Int = 15 * 1024 * 1024, // 15 MiB,
video: Int = 512 * 1024 * 1024 // 512 MiB
) {
self.image = image
self.gif = gif
self.video = video
}
}
public enum AttachmentError: Error {
case invalidAttachmentType
case attachmentTooLarge
}
public enum TwitterMediaCategory: String {
case image = "TWEET_IMAGE"
case GIF = "TWEET_GIF"
case video = "TWEET_VIDEO"
case amplifyVideo = "AMPLIFY_VIDEO"
}
}
extension AttachmentViewModel {
private func load(input: Input) {
switch input {
case .image(let image):
guard let data = image.pngData() else {
error = AttachmentError.invalidAttachmentType
return
}
output = .image(data, imageKind: .png)
case .url(let url):
Task { @MainActor in
do {
let output = try await AttachmentViewModel.load(url: url)
self.output = output
} catch {
self.error = error
}
} // end Task
case .pickerResult(let pickerResult):
Task { @MainActor in
do {
let output = try await AttachmentViewModel.load(itemProvider: pickerResult.itemProvider)
self.output = output
} catch {
self.error = error
}
} // end Task
case .itemProvider(let itemProvider):
Task { @MainActor in
do {
let output = try await AttachmentViewModel.load(itemProvider: itemProvider)
self.output = output
} catch {
self.error = error
}
} // end Task
}
}
private static func load(url: URL) async throws -> Output {
guard let uti = UTType(filenameExtension: url.pathExtension) else {
throw AttachmentError.invalidAttachmentType
}
if uti.conforms(to: .image) {
guard url.startAccessingSecurityScopedResource() else {
throw AttachmentError.invalidAttachmentType
}
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
return .image(imageData, imageKind: imageData.kf.imageFormat == .PNG ? .png : .jpg)
} else if uti.conforms(to: .movie) {
guard url.startAccessingSecurityScopedResource() else {
throw AttachmentError.invalidAttachmentType
}
defer { url.stopAccessingSecurityScopedResource() }
let fileName = UUID().uuidString
let tempDirectoryURL = FileManager.default.temporaryDirectory
let fileURL = tempDirectoryURL.appendingPathComponent(fileName).appendingPathExtension(url.pathExtension)
try FileManager.default.createDirectory(at: tempDirectoryURL, withIntermediateDirectories: true, attributes: nil)
try FileManager.default.copyItem(at: url, to: fileURL)
return .video(fileURL, mimeType: UTType.movie.preferredMIMEType ?? "video/mp4")
} else {
throw AttachmentError.invalidAttachmentType
}
}
private static func load(itemProvider: NSItemProvider) async throws -> Output {
if itemProvider.isImage() {
guard let result = try await itemProvider.loadImageData() else {
throw AttachmentError.invalidAttachmentType
}
let imageKind: Output.ImageKind = {
if let type = result.type {
if type == UTType.png {
return .png
}
if type == UTType.jpeg {
return .jpg
}
}
let imageData = result.data
if imageData.kf.imageFormat == .PNG {
return .png
}
if imageData.kf.imageFormat == .JPEG {
return .jpg
}
assertionFailure("unknown image kind")
return .jpg
}()
return .image(result.data, imageKind: imageKind)
} else if itemProvider.isMovie() {
guard let result = try await itemProvider.loadVideoData() else {
throw AttachmentError.invalidAttachmentType
}
return .video(result.url, mimeType: "video/mp4")
} else {
assertionFailure()
throw AttachmentError.invalidAttachmentType
}
}
}
extension AttachmentViewModel {
static func createThumbnailForVideo(url: URL) -> UIImage? {
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
let asset = AVURLAsset(url: url)
let assetImageGenerator = AVAssetImageGenerator(asset: asset)
assetImageGenerator.appliesPreferredTrackTransform = true // fix orientation
do {
let cgImage = try assetImageGenerator.copyCGImage(at: CMTimeMake(value: 0, timescale: 1), actualTime: nil)
let image = UIImage(cgImage: cgImage)
return image
} catch {
AttachmentViewModel.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
return nil
}
}
}
// MARK: - TypeIdentifiedItemProvider
extension AttachmentViewModel: TypeIdentifiedItemProvider {
public static var typeIdentifier: String {
// must in UTI format
// https://developer.apple.com/library/archive/qa/qa1796/_index.html
return "com.twidere.AttachmentViewModel"
}
}
// MARK: - NSItemProviderWriting
extension AttachmentViewModel: NSItemProviderWriting {
/// Attachment uniform type idendifiers
///
/// The latest one for in-app drag and drop.
/// And use generic `image` and `movie` type to
/// allows transformable media in different formats
public static var writableTypeIdentifiersForItemProvider: [String] {
return [
UTType.image.identifier,
UTType.movie.identifier,
AttachmentViewModel.typeIdentifier,
]
}
public var writableTypeIdentifiersForItemProvider: [String] {
// should append elements in priority order from high to low
var typeIdentifiers: [String] = []
// FIXME: check jpg or png
switch input {
case .image:
typeIdentifiers.append(UTType.png.identifier)
case .url(let url):
let _uti = UTType(filenameExtension: url.pathExtension)
if let uti = _uti {
if uti.conforms(to: .image) {
typeIdentifiers.append(UTType.png.identifier)
} else if uti.conforms(to: .movie) {
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
}
}
case .pickerResult(let item):
if item.itemProvider.isImage() {
typeIdentifiers.append(UTType.png.identifier)
} else if item.itemProvider.isMovie() {
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
}
case .itemProvider(let itemProvider):
if itemProvider.isImage() {
typeIdentifiers.append(UTType.png.identifier)
} else if itemProvider.isMovie() {
typeIdentifiers.append(UTType.mpeg4Movie.identifier)
}
}
typeIdentifiers.append(AttachmentViewModel.typeIdentifier)
return typeIdentifiers
}
public func loadData(
withTypeIdentifier typeIdentifier: String,
forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void
) -> Progress? {
switch typeIdentifier {
case AttachmentViewModel.typeIdentifier:
do {
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
try archiver.encodeEncodable(id, forKey: NSKeyedArchiveRootObjectKey)
archiver.finishEncoding()
let data = archiver.encodedData
completionHandler(data, nil)
} catch {
assertionFailure()
completionHandler(nil, nil)
}
default:
break
}
let loadingProgress = Progress(totalUnitCount: 100)
Publishers.CombineLatest(
$output,
$error
)
.sink { [weak self] output, error in
guard let self = self else { return }
// continue when load completed
guard output != nil || error != nil else { return }
switch output {
case .image(let data, _):
switch typeIdentifier {
case UTType.png.identifier:
loadingProgress.completedUnitCount = 100
completionHandler(data, nil)
default:
completionHandler(nil, nil)
}
case .video(let url, _):
switch typeIdentifier {
case UTType.png.identifier:
let _image = AttachmentViewModel.createThumbnailForVideo(url: url)
let _data = _image?.pngData()
loadingProgress.completedUnitCount = 100
completionHandler(_data, nil)
case UTType.mpeg4Movie.identifier:
let task = URLSession.shared.dataTask(with: url) { data, response, error in
completionHandler(data, error)
}
task.progress.observe(\.fractionCompleted) { progress, change in
loadingProgress.completedUnitCount = Int64(100 * progress.fractionCompleted)
}
.store(in: &self.observations)
task.resume()
default:
completionHandler(nil, nil)
}
case nil:
completionHandler(nil, error)
}
}
.store(in: &disposeBag)
return loadingProgress
}
}
extension NSItemProvider {
fileprivate func isImage() -> Bool {
return hasRepresentationConforming(
toTypeIdentifier: UTType.image.identifier,
fileOptions: []
)
}
fileprivate func isMovie() -> Bool {
return hasRepresentationConforming(
toTypeIdentifier: UTType.movie.identifier,
fileOptions: []
)
}
}

View File

@ -9,10 +9,11 @@ import os.log
import UIKit
import Combine
import CoreDataStack
import MastodonCore
import Meta
import MastodonMeta
import MetaTextKit
import MastodonMeta
import MastodonCore
import MastodonSDK
public final class ComposeContentViewModel: NSObject, ObservableObject {
@ -29,6 +30,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
let kind: Kind
@Published var viewLayoutFrame = ViewLayoutFrame()
// author (me)
@Published var authContext: AuthContext
// output
@ -67,6 +70,11 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
@Published var name: MetaContent = PlaintextMetaContent(string: "")
@Published var username: String = ""
// attachment
@Published public var attachmentViewModels: [AttachmentViewModel] = []
@Published public var maxMediaAttachmentLimit = 4
// @Published public internal(set) var isMediaValid = true
// poll
@Published var isPollActive = false
@Published public var pollOptions: [PollComposeItem.Option] = {
@ -77,11 +85,16 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
return options
}()
@Published public var pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option = .oneDay
@Published public var pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option = false
@Published public var maxPollOptionLimit = 4
// emoji
@Published var isEmojiActive = false
// visibility
@Published var visibility: Mastodon.Entity.Status.Visibility
// UI & UX
@Published var replyToCellFrame: CGRect = .zero
@Published var contentCellFrame: CGRect = .zero
@ -96,6 +109,41 @@ public final class ComposeContentViewModel: NSObject, ObservableObject {
self.context = context
self.authContext = authContext
self.kind = kind
self.visibility = {
// default private when user locked
var visibility: Mastodon.Entity.Status.Visibility = {
guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user else {
return .public
}
return author.locked ? .private : .public
}()
// set visibility for reply post
switch kind {
case .reply(let record):
context.managedObjectContext.performAndWait {
guard let status = record.object(in: context.managedObjectContext) else {
assertionFailure()
return
}
let repliedStatusVisibility = status.visibility
switch repliedStatusVisibility {
case .public, .unlisted:
// keep default
break
case .private:
visibility = .private
case .direct:
visibility = .direct
case ._other:
assertionFailure()
break
}
}
default:
break
}
return visibility
}()
super.init()
// end init
@ -162,6 +210,71 @@ extension ComposeContentViewModel {
}
}
extension ComposeContentViewModel {
public enum ComposeError: LocalizedError {
case pollHasEmptyOption
public var errorDescription: String? {
switch self {
case .pollHasEmptyOption:
return "The post poll is invalid" // TODO: i18n
}
}
public var failureReason: String? {
switch self {
case .pollHasEmptyOption:
return "The poll has empty option" // TODO: i18n
}
}
}
public func statusPublisher() throws -> StatusPublisher {
let authContext = self.authContext
// author
let managedObjectContext = self.context.managedObjectContext
var _author: ManagedObjectRecord<MastodonUser>?
managedObjectContext.performAndWait {
_author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: managedObjectContext)?.user.asRecrod
}
guard let author = _author else {
throw AppError.badAuthentication
}
// poll
_ = try {
guard isPollActive else { return }
let isAllNonEmpty = pollOptions
.map { $0.text }
.allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
guard isAllNonEmpty else {
throw ComposeError.pollHasEmptyOption
}
}()
return MastodonStatusPublisher(
author: author,
replyTo: {
switch self.kind {
case .reply(let status): return status
default: return nil
}
}(),
isContentWarningComposing: isContentWarningActive,
contentWarning: contentWarning,
content: content,
isMediaSensitive: isContentWarningActive,
attachmentViewModels: attachmentViewModels,
isPollComposing: isPollActive,
pollOptions: pollOptions,
pollExpireConfigurationOption: pollExpireConfigurationOption,
pollMultipleConfigurationOption: pollMultipleConfigurationOption,
visibility: visibility
)
} // end func publisher()
}
// MARK: - UITextViewDelegate
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {

View File

@ -0,0 +1,180 @@
//
// MastodonStatusPublisher.swift
//
//
// Created by MainasuK on 2021-12-1.
//
import os.log
import Foundation
import CoreData
import CoreDataStack
import MastodonCore
import MastodonSDK
public final class MastodonStatusPublisher: NSObject, ProgressReporting {
let logger = Logger(subsystem: "MastodonStatusPublisher", category: "Publisher")
// Input
// author
public let author: ManagedObjectRecord<MastodonUser>
// refer
public let replyTo: ManagedObjectRecord<Status>?
// content warning
public let isContentWarningComposing: Bool
public let contentWarning: String
// status content
public let content: String
// media
public let isMediaSensitive: Bool
public let attachmentViewModels: [AttachmentViewModel]
// poll
public let isPollComposing: Bool
public let pollOptions: [PollComposeItem.Option]
public let pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option
public let pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option
// visibility
public let visibility: Mastodon.Entity.Status.Visibility
// Output
let _progress = Progress()
public var progress: Progress { _progress }
@Published var _state: StatusPublisherState = .pending
public var state: Published<StatusPublisherState>.Publisher { $_state }
public var reactor: StatusPublisherReactor?
public init(
author: ManagedObjectRecord<MastodonUser>,
replyTo: ManagedObjectRecord<Status>?,
isContentWarningComposing: Bool,
contentWarning: String,
content: String,
isMediaSensitive: Bool,
attachmentViewModels: [AttachmentViewModel],
isPollComposing: Bool,
pollOptions: [PollComposeItem.Option],
pollExpireConfigurationOption: PollComposeItem.ExpireConfiguration.Option,
pollMultipleConfigurationOption: PollComposeItem.MultipleConfiguration.Option,
visibility: Mastodon.Entity.Status.Visibility
) {
self.author = author
self.replyTo = replyTo
self.isContentWarningComposing = isContentWarningComposing
self.contentWarning = contentWarning
self.content = content
self.isMediaSensitive = isMediaSensitive
self.attachmentViewModels = attachmentViewModels
self.isPollComposing = isPollComposing
self.pollOptions = pollOptions
self.pollExpireConfigurationOption = pollExpireConfigurationOption
self.pollMultipleConfigurationOption = pollMultipleConfigurationOption
self.visibility = visibility
}
}
// MARK: - StatusPublisher
extension MastodonStatusPublisher: StatusPublisher {
public func publish(
api: APIService,
authContext: AuthContext
) async throws -> StatusPublishResult {
let idempotencyKey = UUID().uuidString
let publishStatusTaskStartDelayWeight: Int64 = 20
let publishStatusTaskStartDelayCount: Int64 = publishStatusTaskStartDelayWeight
let publishAttachmentTaskWeight: Int64 = 100
let publishAttachmentTaskCount: Int64 = Int64(attachmentViewModels.count) * publishAttachmentTaskWeight
let publishStatusTaskWeight: Int64 = 20
let publishStatusTaskCount: Int64 = publishStatusTaskWeight
let taskCount = [
publishStatusTaskStartDelayCount,
publishAttachmentTaskCount,
publishStatusTaskCount
].reduce(0, +)
progress.totalUnitCount = taskCount
progress.completedUnitCount = 0
// start delay
try? await Task.sleep(nanoseconds: 1 * .second)
progress.completedUnitCount += publishStatusTaskStartDelayWeight
// Task: attachment
let uploadContext = AttachmentViewModel.UploadContext(
apiService: api,
authContext: authContext
)
var attachmentIDs: [Mastodon.Entity.Attachment.ID] = []
for attachmentViewModel in attachmentViewModels {
// set progress
progress.addChild(attachmentViewModel.progress, withPendingUnitCount: publishAttachmentTaskWeight)
// upload media
do {
let result = try await attachmentViewModel.upload(context: uploadContext)
guard case let .mastodon(response) = result else {
assertionFailure()
continue
}
let attachmentID = response.value.id
attachmentIDs.append(attachmentID)
} catch {
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment fail: \(error.localizedDescription)")
_state = .failure(error)
throw error
}
}
let pollOptions: [String]? = {
guard self.isPollComposing else { return nil }
let options = self.pollOptions.compactMap { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }
return options.isEmpty ? nil : options
}()
let pollExpiresIn: Int? = {
guard self.isPollComposing else { return nil }
guard pollOptions != nil else { return nil }
return self.pollExpireConfigurationOption.seconds
}()
let pollMultiple: Bool? = {
guard self.isPollComposing else { return nil }
guard pollOptions != nil else { return nil }
return self.pollMultipleConfigurationOption
}()
let inReplyToID: Mastodon.Entity.Status.ID? = try await api.backgroundManagedObjectContext.perform {
guard let replyTo = self.replyTo?.object(in: api.backgroundManagedObjectContext) else { return nil }
return replyTo.id
}
let query = Mastodon.API.Statuses.PublishStatusQuery(
status: content,
mediaIDs: attachmentIDs.isEmpty ? nil : attachmentIDs,
pollOptions: pollOptions,
pollExpiresIn: pollExpiresIn,
inReplyToID: inReplyToID,
sensitive: isMediaSensitive,
spoilerText: isContentWarningComposing ? contentWarning : nil,
visibility: visibility
)
let publishResponse = try await api.publishStatus(
domain: authContext.mastodonAuthenticationBox.domain,
idempotencyKey: idempotencyKey,
query: query,
authenticationBox: authContext.mastodonAuthenticationBox
)
progress.completedUnitCount += publishStatusTaskCount
_state = .success
logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): status published: \(publishResponse.value.id)")
return .mastodon(publishResponse)
}
}

View File

@ -1,29 +0,0 @@
//
// AttachmentView.swift
//
//
// Created by MainasuK on 22/9/30.
//
import SwiftUI
public struct AttachmentView: View {
@ObservedObject public var viewModel: ViewModel
public init(viewModel: ViewModel) {
self.viewModel = viewModel
}
public var body: some View {
Text("Hi")
}
}
extension AttachmentView {
public class ViewModel: ObservableObject {
public init() { }
}
}