chore: code clean
This commit is contained in:
parent
939429aacc
commit
82abc68486
|
@ -948,7 +948,6 @@
|
||||||
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
|
DBB525632612C988002F1F29 /* MeProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeProfileViewModel.swift; sourceTree = "<group>"; };
|
||||||
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
|
DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPostIntentHandler.swift; sourceTree = "<group>"; };
|
||||||
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
|
DBB9759B262462E1004620BD /* ThreadMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadMetaView.swift; sourceTree = "<group>"; };
|
||||||
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeToolbarView.swift; sourceTree = "<group>"; };
|
|
||||||
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
DBBE1B4425F3474B0081417A /* MastodonPickServerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonPickServerAppearance.swift; sourceTree = "<group>"; };
|
||||||
DBC3872329214121001EC0FD /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
DBC3872329214121001EC0FD /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = ShareActionExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
@ -1028,15 +1027,7 @@
|
||||||
DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = "<group>"; };
|
DBFEEC98279BDCDE004F81DD /* ProfileAboutViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAboutViewModel.swift; sourceTree = "<group>"; };
|
||||||
DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
DBFEEC9A279BDDD9004F81DD /* ProfileAboutViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileAboutViewModel+Diffable.swift"; sourceTree = "<group>"; };
|
||||||
DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = "<group>"; };
|
DBFEEC9C279C12C1004F81DD /* ProfileFieldEditCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldEditCollectionViewCell.swift; sourceTree = "<group>"; };
|
||||||
DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusEditorView.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF05626A576EE006D7ED1 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentWarningEditorView.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAuthorView.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentView.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusAttachmentViewModel.swift; sourceTree = "<group>"; };
|
|
||||||
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
|
DBFEF06726A58D07006D7ED1 /* ShareActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareActionExtension.entitlements; sourceTree = "<group>"; };
|
||||||
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusAttachmentViewModel+UploadState.swift"; sourceTree = "<group>"; };
|
|
||||||
DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk - release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk - release.xcconfig"; sourceTree = "<group>"; };
|
DDB1B139FA8EA26F510D58B6 /* Pods-AppShared.asdk - release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AppShared.asdk - release.xcconfig"; path = "Target Support Files/Pods-AppShared/Pods-AppShared.asdk - release.xcconfig"; sourceTree = "<group>"; };
|
||||||
DF65937EC1FF64462BC002EE /* Pods-MastodonTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.profile.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.profile.xcconfig"; sourceTree = "<group>"; };
|
DF65937EC1FF64462BC002EE /* Pods-MastodonTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MastodonTests.profile.xcconfig"; path = "Target Support Files/Pods-MastodonTests/Pods-MastodonTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
E5C7236E58D14A0322FE00F2 /* Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; path = "Target Support Files/Pods-Mastodon-MastodonUITests/Pods-Mastodon-MastodonUITests.asdk - debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
@ -2666,26 +2657,9 @@
|
||||||
path = Cell;
|
path = Cell;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
DBFEF05426A576EE006D7ED1 /* View */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
DBBC24A726A52F9000398BB9 /* ComposeToolbarView.swift */,
|
|
||||||
DBFEF05526A576EE006D7ED1 /* StatusEditorView.swift */,
|
|
||||||
DBFEF05626A576EE006D7ED1 /* ComposeView.swift */,
|
|
||||||
DBFEF05726A576EE006D7ED1 /* ComposeViewModel.swift */,
|
|
||||||
DBFEF05826A576EE006D7ED1 /* ContentWarningEditorView.swift */,
|
|
||||||
DBFEF05926A576EE006D7ED1 /* StatusAuthorView.swift */,
|
|
||||||
DBFEF05A26A576EE006D7ED1 /* StatusAttachmentView.swift */,
|
|
||||||
DBFEF06226A577F2006D7ED1 /* StatusAttachmentViewModel.swift */,
|
|
||||||
DBFEF06C26A67FB7006D7ED1 /* StatusAttachmentViewModel+UploadState.swift */,
|
|
||||||
);
|
|
||||||
path = View;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
DBFEF06126A57721006D7ED1 /* Scene */ = {
|
DBFEF06126A57721006D7ED1 /* Scene */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DBFEF05426A576EE006D7ED1 /* View */,
|
|
||||||
DBC6462226A1712000B0E31B /* ShareViewModel.swift */,
|
DBC6462226A1712000B0E31B /* ShareViewModel.swift */,
|
||||||
DBC3872329214121001EC0FD /* ShareViewController.swift */,
|
DBC3872329214121001EC0FD /* ShareViewController.swift */,
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,262 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeToolbarView.swift
|
|
||||||
// ShareActionExtension
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-19.
|
|
||||||
//
|
|
||||||
|
|
||||||
import os.log
|
|
||||||
import UIKit
|
|
||||||
import Combine
|
|
||||||
import MastodonSDK
|
|
||||||
import MastodonAsset
|
|
||||||
import MastodonLocalization
|
|
||||||
import MastodonCore
|
|
||||||
import MastodonUI
|
|
||||||
|
|
||||||
protocol ComposeToolbarViewDelegate: AnyObject {
|
|
||||||
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton)
|
|
||||||
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class ComposeToolbarView: UIView {
|
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
static let toolbarButtonSize: CGSize = CGSize(width: 44, height: 44)
|
|
||||||
static let toolbarHeight: CGFloat = 44
|
|
||||||
|
|
||||||
weak var delegate: ComposeToolbarViewDelegate?
|
|
||||||
|
|
||||||
let contentWarningButton: UIButton = {
|
|
||||||
let button = HighlightDimmableButton()
|
|
||||||
ComposeToolbarView.configureToolbarButtonAppearance(button: button)
|
|
||||||
button.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular)), for: .normal)
|
|
||||||
button.accessibilityLabel = L10n.Scene.Compose.Accessibility.enableContentWarning
|
|
||||||
return button
|
|
||||||
}()
|
|
||||||
|
|
||||||
let visibilityButton: UIButton = {
|
|
||||||
let button = HighlightDimmableButton()
|
|
||||||
ComposeToolbarView.configureToolbarButtonAppearance(button: button)
|
|
||||||
button.setImage(UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium)), for: .normal)
|
|
||||||
button.accessibilityLabel = L10n.Scene.Compose.Accessibility.postVisibilityMenu
|
|
||||||
return button
|
|
||||||
}()
|
|
||||||
|
|
||||||
let characterCountLabel: UILabel = {
|
|
||||||
let label = UILabel()
|
|
||||||
label.font = .systemFont(ofSize: 15, weight: .regular)
|
|
||||||
label.text = "500"
|
|
||||||
label.textColor = Asset.Colors.Label.secondary.color
|
|
||||||
label.accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(500)
|
|
||||||
return label
|
|
||||||
}()
|
|
||||||
|
|
||||||
let activeVisibilityType = CurrentValueSubject<VisibilitySelectionType, Never>(.public)
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
_init()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
super.init(coder: coder)
|
|
||||||
_init()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeToolbarView {
|
|
||||||
|
|
||||||
private func _init() {
|
|
||||||
setupBackgroundColor(theme: ThemeService.shared.currentTheme.value)
|
|
||||||
ThemeService.shared.currentTheme
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] theme in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.setupBackgroundColor(theme: theme)
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
let stackView = UIStackView()
|
|
||||||
stackView.axis = .horizontal
|
|
||||||
stackView.spacing = 0
|
|
||||||
stackView.distribution = .fillEqually
|
|
||||||
stackView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(stackView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
stackView.centerYAnchor.constraint(equalTo: centerYAnchor),
|
|
||||||
layoutMarginsGuide.leadingAnchor.constraint(equalTo: stackView.leadingAnchor, constant: 8), // tweak button margin offset
|
|
||||||
])
|
|
||||||
|
|
||||||
let buttons = [
|
|
||||||
contentWarningButton,
|
|
||||||
visibilityButton,
|
|
||||||
]
|
|
||||||
buttons.forEach { button in
|
|
||||||
button.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
stackView.addArrangedSubview(button)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
button.widthAnchor.constraint(equalToConstant: 44),
|
|
||||||
button.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
characterCountLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
addSubview(characterCountLabel)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
characterCountLabel.topAnchor.constraint(equalTo: topAnchor),
|
|
||||||
characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8),
|
|
||||||
characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
|
|
||||||
characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
||||||
])
|
|
||||||
characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
|
||||||
|
|
||||||
contentWarningButton.addTarget(self, action: #selector(ComposeToolbarView.contentWarningButtonDidPressed(_:)), for: .touchUpInside)
|
|
||||||
visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle)
|
|
||||||
visibilityButton.showsMenuAsPrimaryAction = true
|
|
||||||
|
|
||||||
updateToolbarButtonUserInterfaceStyle()
|
|
||||||
|
|
||||||
// update menu when selected visibility type changed
|
|
||||||
activeVisibilityType
|
|
||||||
.receive(on: RunLoop.main)
|
|
||||||
.sink { [weak self] type in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.visibilityButton.menu = self.createVisibilityContextMenu(interfaceStyle: self.traitCollection.userInterfaceStyle)
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
||||||
super.traitCollectionDidChange(previousTraitCollection)
|
|
||||||
|
|
||||||
updateToolbarButtonUserInterfaceStyle()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeToolbarView {
|
|
||||||
private func setupBackgroundColor(theme: Theme) {
|
|
||||||
backgroundColor = theme.composeToolbarBackgroundColor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeToolbarView {
|
|
||||||
enum MediaSelectionType: String {
|
|
||||||
case camera
|
|
||||||
case photoLibrary
|
|
||||||
case browse
|
|
||||||
}
|
|
||||||
|
|
||||||
enum VisibilitySelectionType: String, CaseIterable {
|
|
||||||
case `public`
|
|
||||||
// TODO: remove unlisted option from codebase
|
|
||||||
// case unlisted
|
|
||||||
case `private`
|
|
||||||
case direct
|
|
||||||
|
|
||||||
var title: String {
|
|
||||||
switch self {
|
|
||||||
case .public: return L10n.Scene.Compose.Visibility.public
|
|
||||||
// case .unlisted: return L10n.Scene.Compose.Visibility.unlisted
|
|
||||||
case .private: return L10n.Scene.Compose.Visibility.private
|
|
||||||
case .direct: return L10n.Scene.Compose.Visibility.direct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func image(interfaceStyle: UIUserInterfaceStyle) -> UIImage {
|
|
||||||
switch self {
|
|
||||||
case .public: return UIImage(systemName: "globe", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .medium))!
|
|
||||||
// case .unlisted: return UIImage(systemName: "eye.slash", withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular))!
|
|
||||||
case .private:
|
|
||||||
switch interfaceStyle {
|
|
||||||
case .light: return UIImage(systemName: "person.3", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
|
|
||||||
default: return UIImage(systemName: "person.3.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15, weight: .medium))!
|
|
||||||
}
|
|
||||||
case .direct: return UIImage(systemName: "at", withConfiguration: UIImage.SymbolConfiguration(pointSize: 19, weight: .regular))!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var visibility: Mastodon.Entity.Status.Visibility {
|
|
||||||
switch self {
|
|
||||||
case .public: return .public
|
|
||||||
// case .unlisted: return .unlisted
|
|
||||||
case .private: return .private
|
|
||||||
case .direct: return .direct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeToolbarView {
|
|
||||||
|
|
||||||
private static func configureToolbarButtonAppearance(button: UIButton) {
|
|
||||||
button.tintColor = ThemeService.tintColor
|
|
||||||
button.setBackgroundImage(.placeholder(size: ComposeToolbarView.toolbarButtonSize, color: .systemFill), for: .highlighted)
|
|
||||||
button.layer.masksToBounds = true
|
|
||||||
button.layer.cornerRadius = 5
|
|
||||||
button.layer.cornerCurve = .continuous
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateToolbarButtonUserInterfaceStyle() {
|
|
||||||
switch traitCollection.userInterfaceStyle {
|
|
||||||
case .light:
|
|
||||||
contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
|
|
||||||
|
|
||||||
case .dark:
|
|
||||||
contentWarningButton.setImage(UIImage(systemName: "exclamationmark.shield.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))!, for: .normal)
|
|
||||||
|
|
||||||
default:
|
|
||||||
assertionFailure()
|
|
||||||
}
|
|
||||||
|
|
||||||
visibilityButton.menu = createVisibilityContextMenu(interfaceStyle: traitCollection.userInterfaceStyle)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createVisibilityContextMenu(interfaceStyle: UIUserInterfaceStyle) -> UIMenu {
|
|
||||||
let children: [UIMenuElement] = VisibilitySelectionType.allCases.map { type in
|
|
||||||
let state: UIMenuElement.State = activeVisibilityType.value == type ? .on : .off
|
|
||||||
return UIAction(title: type.title, image: type.image(interfaceStyle: interfaceStyle), identifier: nil, discoverabilityTitle: nil, attributes: [], state: state) { [weak self] action in
|
|
||||||
guard let self = self else { return }
|
|
||||||
os_log(.info, "%{public}s[%{public}ld], %{public}s: visibilitySelectionType: %s", ((#file as NSString).lastPathComponent), #line, #function, type.rawValue)
|
|
||||||
self.delegate?.composeToolbarView(self, visibilityButtonDidPressed: self.visibilityButton, visibilitySelectionType: type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return UIMenu(title: "", image: nil, identifier: nil, options: .displayInline, children: children)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeToolbarView {
|
|
||||||
|
|
||||||
@objc private func contentWarningButtonDidPressed(_ sender: UIButton) {
|
|
||||||
os_log(.info, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
|
|
||||||
delegate?.composeToolbarView(self, contentWarningButtonDidPressed: sender)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(SwiftUI) && DEBUG
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct ComposeToolbarView_Previews: PreviewProvider {
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
UIViewPreview(width: 375) {
|
|
||||||
let toolbarView = ComposeToolbarView()
|
|
||||||
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
toolbarView.widthAnchor.constraint(equalToConstant: 375).priority(.defaultHigh),
|
|
||||||
toolbarView.heightAnchor.constraint(equalToConstant: 64).priority(.defaultHigh),
|
|
||||||
])
|
|
||||||
return toolbarView
|
|
||||||
}
|
|
||||||
.previewLayout(.fixed(width: 375, height: 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
|
||||||
|
|
|
@ -1,151 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeView.swift
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-16.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
public struct ComposeView: View {
|
|
||||||
|
|
||||||
@EnvironmentObject var viewModel: ComposeViewModel
|
|
||||||
@State var statusEditorViewWidth: CGFloat = .zero
|
|
||||||
|
|
||||||
let horizontalMargin: CGFloat = 20
|
|
||||||
|
|
||||||
public init() { }
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
GeometryReader { proxy in
|
|
||||||
List {
|
|
||||||
// Content Warning
|
|
||||||
if viewModel.isContentWarningComposing {
|
|
||||||
ContentWarningEditorView(
|
|
||||||
contentWarningContent: $viewModel.contentWarningContent,
|
|
||||||
placeholder: viewModel.contentWarningPlaceholder
|
|
||||||
)
|
|
||||||
.padding(EdgeInsets(top: 6, leading: horizontalMargin, bottom: 6, trailing: horizontalMargin))
|
|
||||||
.background(viewModel.contentWarningBackgroundColor)
|
|
||||||
.transition(.opacity)
|
|
||||||
.listRow(backgroundColor: Color(viewModel.backgroundColor))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Author
|
|
||||||
StatusAuthorView(
|
|
||||||
avatarImageURL: viewModel.avatarImageURL,
|
|
||||||
name: viewModel.authorName,
|
|
||||||
username: viewModel.authorUsername
|
|
||||||
)
|
|
||||||
.padding(EdgeInsets(top: 20, leading: horizontalMargin, bottom: 16, trailing: horizontalMargin))
|
|
||||||
.listRow(backgroundColor: Color(viewModel.backgroundColor))
|
|
||||||
|
|
||||||
// Editor
|
|
||||||
StatusEditorView(
|
|
||||||
string: $viewModel.statusContent,
|
|
||||||
placeholder: viewModel.statusPlaceholder,
|
|
||||||
width: statusEditorViewWidth,
|
|
||||||
attributedString: viewModel.statusContentAttributedString,
|
|
||||||
keyboardType: .twitter,
|
|
||||||
viewDidAppear: $viewModel.viewDidAppear
|
|
||||||
)
|
|
||||||
.frame(width: statusEditorViewWidth)
|
|
||||||
.frame(minHeight: 100)
|
|
||||||
.padding(EdgeInsets(top: 0, leading: horizontalMargin, bottom: 0, trailing: horizontalMargin))
|
|
||||||
.listRow(backgroundColor: Color(viewModel.backgroundColor))
|
|
||||||
|
|
||||||
// Attachments
|
|
||||||
ForEach(viewModel.attachmentViewModels) { attachmentViewModel in
|
|
||||||
let descriptionBinding = Binding {
|
|
||||||
return attachmentViewModel.descriptionContent
|
|
||||||
} set: { newValue in
|
|
||||||
attachmentViewModel.descriptionContent = newValue
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusAttachmentView(
|
|
||||||
image: attachmentViewModel.thumbnailImage,
|
|
||||||
descriptionPlaceholder: attachmentViewModel.descriptionPlaceholder,
|
|
||||||
description: descriptionBinding,
|
|
||||||
errorPrompt: attachmentViewModel.errorPrompt,
|
|
||||||
errorPromptImage: attachmentViewModel.errorPromptImage,
|
|
||||||
isUploading: attachmentViewModel.isUploading,
|
|
||||||
progressViewTintColor: attachmentViewModel.progressViewTintColor,
|
|
||||||
removeButtonAction: {
|
|
||||||
self.viewModel.removeAttachmentViewModel(attachmentViewModel)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.padding(EdgeInsets(top: 16, leading: horizontalMargin, bottom: 0, trailing: horizontalMargin))
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
.listRow(backgroundColor: Color(viewModel.backgroundColor))
|
|
||||||
|
|
||||||
// bottom padding
|
|
||||||
Color.clear
|
|
||||||
.frame(height: viewModel.toolbarHeight + 20)
|
|
||||||
.listRow(backgroundColor: Color(viewModel.backgroundColor))
|
|
||||||
} // end List
|
|
||||||
.listStyle(.plain)
|
|
||||||
.introspectTableView(customize: { tableView in
|
|
||||||
// tableView.keyboardDismissMode = .onDrag
|
|
||||||
tableView.verticalScrollIndicatorInsets.bottom = viewModel.toolbarHeight
|
|
||||||
})
|
|
||||||
.preference(
|
|
||||||
key: ComposeListViewFramePreferenceKey.self,
|
|
||||||
value: proxy.frame(in: .local)
|
|
||||||
)
|
|
||||||
.onPreferenceChange(ComposeListViewFramePreferenceKey.self) { frame in
|
|
||||||
var frame = frame
|
|
||||||
frame.size.width = frame.width - 2 * horizontalMargin
|
|
||||||
statusEditorViewWidth = frame.width
|
|
||||||
} // end List
|
|
||||||
.introspectTableView(customize: { tableView in
|
|
||||||
tableView.backgroundColor = .clear
|
|
||||||
})
|
|
||||||
.overrideBackground(color: Color(viewModel.backgroundColor))
|
|
||||||
} // end GeometryReader
|
|
||||||
} // end body
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ComposeListViewFramePreferenceKey: PreferenceKey {
|
|
||||||
static var defaultValue: CGRect = .zero
|
|
||||||
static func reduce(value: inout CGRect, nextValue: () -> CGRect) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension View {
|
|
||||||
// hack for separator line
|
|
||||||
@ViewBuilder
|
|
||||||
func listRow(backgroundColor: Color) -> some View {
|
|
||||||
// expand list row to edge (set inset)
|
|
||||||
// then hide the separator
|
|
||||||
if #available(iOS 15, *) {
|
|
||||||
frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
||||||
.listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1))
|
|
||||||
.background(backgroundColor)
|
|
||||||
.listRowSeparator(.hidden) // new API
|
|
||||||
} else {
|
|
||||||
frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
||||||
.listRowInsets(EdgeInsets(top: -1, leading: -1, bottom: -1, trailing: -1)) // separator line hidden magic
|
|
||||||
.background(backgroundColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
|
||||||
func overrideBackground(color: Color) -> some View {
|
|
||||||
background(color.ignoresSafeArea())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
struct ComposeView_Previews: PreviewProvider {
|
|
||||||
|
|
||||||
static let viewModel: ComposeViewModel = {
|
|
||||||
let viewModel = ComposeViewModel()
|
|
||||||
return viewModel
|
|
||||||
}()
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
ComposeView().environmentObject(viewModel)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,130 +0,0 @@
|
||||||
//
|
|
||||||
// ComposeViewModel.swift
|
|
||||||
// ShareActionExtension
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-16.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
import CoreDataStack
|
|
||||||
|
|
||||||
class ComposeViewModel: ObservableObject {
|
|
||||||
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
@Published var authentication: MastodonAuthentication?
|
|
||||||
|
|
||||||
@Published var backgroundColor: UIColor = .clear
|
|
||||||
@Published var toolbarHeight: CGFloat = 0
|
|
||||||
@Published var viewDidAppear = false
|
|
||||||
|
|
||||||
@Published var avatarImageURL: URL?
|
|
||||||
@Published var authorName: String = ""
|
|
||||||
@Published var authorUsername: String = ""
|
|
||||||
|
|
||||||
@Published var statusContent = ""
|
|
||||||
@Published var statusPlaceholder = ""
|
|
||||||
@Published var statusContentAttributedString = NSAttributedString()
|
|
||||||
|
|
||||||
@Published var isContentWarningComposing = false
|
|
||||||
@Published var contentWarningBackgroundColor = Color.secondary
|
|
||||||
@Published var contentWarningPlaceholder = ""
|
|
||||||
@Published var contentWarningContent = ""
|
|
||||||
|
|
||||||
@Published private(set) var attachmentViewModels: [StatusAttachmentViewModel] = []
|
|
||||||
|
|
||||||
@Published var characterCount = 0
|
|
||||||
|
|
||||||
public init() {
|
|
||||||
$statusContent
|
|
||||||
.map { NSAttributedString(string: $0) }
|
|
||||||
.assign(to: &$statusContentAttributedString)
|
|
||||||
|
|
||||||
Publishers.CombineLatest3(
|
|
||||||
$statusContent,
|
|
||||||
$isContentWarningComposing,
|
|
||||||
$contentWarningContent
|
|
||||||
)
|
|
||||||
.map { statusContent, isContentWarningComposing, contentWarningContent in
|
|
||||||
var count = statusContent.count
|
|
||||||
if isContentWarningComposing {
|
|
||||||
count += contentWarningContent.count
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
.assign(to: &$characterCount)
|
|
||||||
|
|
||||||
// setup attribute updater
|
|
||||||
$attachmentViewModels
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.debounce(for: 0.3, scheduler: DispatchQueue.main)
|
|
||||||
.sink { attachmentViewModels in
|
|
||||||
// drive upload state
|
|
||||||
// make image upload in the queue
|
|
||||||
for attachmentViewModel in attachmentViewModels {
|
|
||||||
// skip when prefix N task when task finish OR fail OR uploading
|
|
||||||
guard let currentState = attachmentViewModel.uploadStateMachine.currentState else { break }
|
|
||||||
if currentState is StatusAttachmentViewModel.UploadState.Fail {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if currentState is StatusAttachmentViewModel.UploadState.Finish {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if currentState is StatusAttachmentViewModel.UploadState.Uploading {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
// trigger uploading one by one
|
|
||||||
if currentState is StatusAttachmentViewModel.UploadState.Initial {
|
|
||||||
attachmentViewModel.uploadStateMachine.enter(StatusAttachmentViewModel.UploadState.Uploading.self)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
// avatarImageURL = URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif")
|
|
||||||
// authorName = "Alice"
|
|
||||||
// authorUsername = "alice"
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension ComposeViewModel {
|
|
||||||
func setupAttachmentViewModels(_ viewModels: [StatusAttachmentViewModel]) {
|
|
||||||
attachmentViewModels = viewModels
|
|
||||||
for viewModel in viewModels {
|
|
||||||
// set delegate
|
|
||||||
viewModel.delegate = self
|
|
||||||
// set observed
|
|
||||||
viewModel.objectWillChange.sink { [weak self] _ in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.objectWillChange.send()
|
|
||||||
}
|
|
||||||
.store(in: &viewModel.disposeBag)
|
|
||||||
// bind authentication
|
|
||||||
$authentication
|
|
||||||
.assign(to: \.value, on: viewModel.authentication)
|
|
||||||
.store(in: &viewModel.disposeBag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func removeAttachmentViewModel(_ viewModel: StatusAttachmentViewModel) {
|
|
||||||
if let index = attachmentViewModels.firstIndex(where: { $0 === viewModel }) {
|
|
||||||
attachmentViewModels.remove(at: index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - StatusAttachmentViewModelDelegate
|
|
||||||
extension ComposeViewModel: StatusAttachmentViewModelDelegate {
|
|
||||||
func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?) {
|
|
||||||
// trigger event update
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.attachmentViewModels = self.attachmentViewModels
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,48 +0,0 @@
|
||||||
//
|
|
||||||
// ContentWarningEditorView.swift
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-19.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Introspect
|
|
||||||
|
|
||||||
struct ContentWarningEditorView: View {
|
|
||||||
|
|
||||||
@Binding var contentWarningContent: String
|
|
||||||
let placeholder: String
|
|
||||||
let spacing: CGFloat = 11
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(alignment: .center, spacing: spacing) {
|
|
||||||
Image(systemName: "exclamationmark.shield")
|
|
||||||
.font(.system(size: 30, weight: .regular))
|
|
||||||
Text(contentWarningContent.isEmpty ? " " : contentWarningContent)
|
|
||||||
.opacity(0)
|
|
||||||
.padding(.all, 8)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.overlay(
|
|
||||||
TextEditor(text: $contentWarningContent)
|
|
||||||
.introspectTextView { textView in
|
|
||||||
textView.backgroundColor = .clear
|
|
||||||
textView.placeholder = placeholder
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentWarningEditorView_Previews: PreviewProvider {
|
|
||||||
|
|
||||||
@State static var content = ""
|
|
||||||
|
|
||||||
static var previews: some View {
|
|
||||||
ContentWarningEditorView(
|
|
||||||
contentWarningContent: $content,
|
|
||||||
placeholder: "Write an accurate warning here..."
|
|
||||||
)
|
|
||||||
.previewLayout(.fixed(width: 375, height: 100))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,113 +0,0 @@
|
||||||
//
|
|
||||||
// StatusAttachmentView.swift
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-19.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Introspect
|
|
||||||
|
|
||||||
struct StatusAttachmentView: View {
|
|
||||||
|
|
||||||
let image: UIImage?
|
|
||||||
let descriptionPlaceholder: String
|
|
||||||
@Binding var description: String
|
|
||||||
let errorPrompt: String?
|
|
||||||
let errorPromptImage: UIImage
|
|
||||||
let isUploading: Bool
|
|
||||||
let progressViewTintColor: UIColor
|
|
||||||
|
|
||||||
let removeButtonAction: () -> Void
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
let image = image ?? UIImage.placeholder(color: .systemFill)
|
|
||||||
ZStack(alignment: .bottom) {
|
|
||||||
if let errorPrompt = errorPrompt {
|
|
||||||
Color.clear
|
|
||||||
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill)
|
|
||||||
.overlay(
|
|
||||||
VStack(alignment: .center) {
|
|
||||||
Image(uiImage: errorPromptImage)
|
|
||||||
Text(errorPrompt)
|
|
||||||
.lineLimit(2)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.background(Color.gray)
|
|
||||||
} else {
|
|
||||||
Color.clear
|
|
||||||
.aspectRatio(CGSize(width: 16, height: 9), contentMode: .fill)
|
|
||||||
.overlay(
|
|
||||||
Image(uiImage: image)
|
|
||||||
.resizable()
|
|
||||||
.aspectRatio(contentMode: .fill)
|
|
||||||
)
|
|
||||||
.background(Color.gray)
|
|
||||||
LinearGradient(gradient: Gradient(colors: [Color(white: 0, opacity: 0.69), Color.clear]), startPoint: .bottom, endPoint: .top)
|
|
||||||
.frame(maxHeight: 71)
|
|
||||||
TextField("", text: $description)
|
|
||||||
.placeholder(when: description.isEmpty) {
|
|
||||||
Text(descriptionPlaceholder).foregroundColor(Color(white: 1, opacity: 0.6))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.font(.system(size: 15, weight: .regular, design: .default))
|
|
||||||
.padding(EdgeInsets(top: 0, leading: 8, bottom: 7, trailing: 8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.cornerRadius(4)
|
|
||||||
.badgeView(
|
|
||||||
Button(action: {
|
|
||||||
removeButtonAction()
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "minus.circle.fill")
|
|
||||||
.renderingMode(.original)
|
|
||||||
.font(.system(size: 22, weight: .bold, design: .default))
|
|
||||||
})
|
|
||||||
.buttonStyle(BorderlessButtonStyle())
|
|
||||||
)
|
|
||||||
.overlay(
|
|
||||||
Group {
|
|
||||||
if isUploading {
|
|
||||||
ProgressView()
|
|
||||||
.progressViewStyle(CircularProgressViewStyle(tint: Color(progressViewTintColor)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// ref: https://stackoverflow.com/a/57715771/3797903
|
|
||||||
extension View {
|
|
||||||
func placeholder<Content: View>(
|
|
||||||
when shouldShow: Bool,
|
|
||||||
alignment: Alignment = .leading,
|
|
||||||
@ViewBuilder placeholder: () -> Content) -> some View {
|
|
||||||
|
|
||||||
ZStack(alignment: alignment) {
|
|
||||||
placeholder().opacity(shouldShow ? 1 : 0)
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//struct StatusAttachmentView_Previews: PreviewProvider {
|
|
||||||
// static var previews: some View {
|
|
||||||
// ScrollView {
|
|
||||||
// StatusAttachmentView(
|
|
||||||
// image: UIImage(systemName: "photo"),
|
|
||||||
// descriptionPlaceholder: "Describe photo",
|
|
||||||
// description: .constant(""),
|
|
||||||
// errorPrompt: nil,
|
|
||||||
// errorPromptImage: StatusAttachmentViewModel.photoFillSplitImage,
|
|
||||||
// isUploading: true,
|
|
||||||
// progressViewTintColor: .systemFill,
|
|
||||||
// removeButtonAction: {
|
|
||||||
// // do nothing
|
|
||||||
// }
|
|
||||||
// )
|
|
||||||
// .padding(20)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//}
|
|
|
@ -1,131 +0,0 @@
|
||||||
//
|
|
||||||
// StatusAttachmentViewModel+UploadState.swift
|
|
||||||
// ShareActionExtension
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import os.log
|
|
||||||
import Foundation
|
|
||||||
import Combine
|
|
||||||
import GameplayKit
|
|
||||||
import MastodonSDK
|
|
||||||
import MastodonCore
|
|
||||||
|
|
||||||
extension StatusAttachmentViewModel {
|
|
||||||
class UploadState: GKState {
|
|
||||||
weak var viewModel: StatusAttachmentViewModel?
|
|
||||||
|
|
||||||
init(viewModel: StatusAttachmentViewModel) {
|
|
||||||
self.viewModel = viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didEnter(from previousState: GKState?) {
|
|
||||||
os_log("%{public}s[%{public}ld], %{public}s: enter %s, previous: %s", ((#file as NSString).lastPathComponent), #line, #function, self.debugDescription, previousState.debugDescription)
|
|
||||||
viewModel?.uploadStateMachineSubject.send(self)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StatusAttachmentViewModel.UploadState {
|
|
||||||
|
|
||||||
class Initial: StatusAttachmentViewModel.UploadState {
|
|
||||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
|
||||||
guard viewModel?.authentication.value != nil else { return false }
|
|
||||||
if stateClass == Initial.self {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if viewModel?.file.value != nil {
|
|
||||||
return stateClass == Uploading.self
|
|
||||||
} else {
|
|
||||||
return stateClass == Fail.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Uploading: StatusAttachmentViewModel.UploadState {
|
|
||||||
let logger = Logger(subsystem: "StatusAttachmentViewModel.UploadState.Uploading", category: "logic")
|
|
||||||
var needsFallback = false
|
|
||||||
|
|
||||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
|
||||||
return stateClass == Fail.self || stateClass == Finish.self || stateClass == Uploading.self
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didEnter(from previousState: GKState?) {
|
|
||||||
super.didEnter(from: previousState)
|
|
||||||
|
|
||||||
guard let viewModel = viewModel, let stateMachine = stateMachine else { return }
|
|
||||||
guard let authentication = viewModel.authentication.value else { return }
|
|
||||||
guard let file = viewModel.file.value else { return }
|
|
||||||
|
|
||||||
let description = viewModel.descriptionContent
|
|
||||||
let query = Mastodon.API.Media.UploadMediaQuery(
|
|
||||||
file: file,
|
|
||||||
thumbnail: nil,
|
|
||||||
description: description,
|
|
||||||
focus: nil
|
|
||||||
)
|
|
||||||
|
|
||||||
let mastodonAuthenticationBox = MastodonAuthenticationBox(
|
|
||||||
authenticationRecord: .init(objectID: authentication.objectID),
|
|
||||||
domain: authentication.domain,
|
|
||||||
userID: authentication.userID,
|
|
||||||
appAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.appAccessToken),
|
|
||||||
userAuthorization: Mastodon.API.OAuth.Authorization(accessToken: authentication.userAccessToken)
|
|
||||||
)
|
|
||||||
|
|
||||||
// and needs clone the `query` if needs retry
|
|
||||||
viewModel.api.uploadMedia(
|
|
||||||
domain: mastodonAuthenticationBox.domain,
|
|
||||||
query: query,
|
|
||||||
mastodonAuthenticationBox: mastodonAuthenticationBox,
|
|
||||||
needsFallback: needsFallback
|
|
||||||
)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] completion in
|
|
||||||
guard let self = self else { return }
|
|
||||||
switch completion {
|
|
||||||
case .failure(let error):
|
|
||||||
if let apiError = error as? Mastodon.API.Error,
|
|
||||||
apiError.httpResponseStatus == .notFound,
|
|
||||||
self.needsFallback == false
|
|
||||||
{
|
|
||||||
self.needsFallback = true
|
|
||||||
stateMachine.enter(Uploading.self)
|
|
||||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fallback to V1")
|
|
||||||
} else {
|
|
||||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fail: \(error.localizedDescription)")
|
|
||||||
viewModel.error = error
|
|
||||||
stateMachine.enter(Fail.self)
|
|
||||||
}
|
|
||||||
case .finished:
|
|
||||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment success")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} receiveValue: { [weak self] response in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): upload attachment \(response.value.id) success, \(response.value.url ?? "<nil>")")
|
|
||||||
viewModel.attachment.value = response.value
|
|
||||||
stateMachine.enter(Finish.self)
|
|
||||||
}
|
|
||||||
.store(in: &viewModel.disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class Fail: StatusAttachmentViewModel.UploadState {
|
|
||||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
|
||||||
// allow discard publishing
|
|
||||||
return stateClass == Uploading.self || stateClass == Finish.self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Finish: StatusAttachmentViewModel.UploadState {
|
|
||||||
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,227 +0,0 @@
|
||||||
//
|
|
||||||
// StatusAttachmentViewModel.swift
|
|
||||||
// ShareActionExtension
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-19.
|
|
||||||
//
|
|
||||||
|
|
||||||
import os.log
|
|
||||||
import Foundation
|
|
||||||
import SwiftUI
|
|
||||||
import Combine
|
|
||||||
import CoreDataStack
|
|
||||||
import MastodonSDK
|
|
||||||
import MastodonUI
|
|
||||||
import AVFoundation
|
|
||||||
import GameplayKit
|
|
||||||
import MobileCoreServices
|
|
||||||
import UniformTypeIdentifiers
|
|
||||||
import MastodonAsset
|
|
||||||
import MastodonCore
|
|
||||||
import MastodonLocalization
|
|
||||||
|
|
||||||
protocol StatusAttachmentViewModelDelegate: AnyObject {
|
|
||||||
func statusAttachmentViewModel(_ viewModel: StatusAttachmentViewModel, uploadStateDidChange state: StatusAttachmentViewModel.UploadState?)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class StatusAttachmentViewModel: ObservableObject, Identifiable {
|
|
||||||
|
|
||||||
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 logger = Logger(subsystem: "StatusAttachmentViewModel", category: "logic")
|
|
||||||
|
|
||||||
weak var delegate: StatusAttachmentViewModelDelegate?
|
|
||||||
var disposeBag = Set<AnyCancellable>()
|
|
||||||
|
|
||||||
let id = UUID()
|
|
||||||
let itemProvider: NSItemProvider
|
|
||||||
|
|
||||||
// input
|
|
||||||
let api: APIService
|
|
||||||
let file = CurrentValueSubject<Mastodon.Query.MediaAttachment?, Never>(nil)
|
|
||||||
let authentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
|
|
||||||
@Published var descriptionContent = ""
|
|
||||||
|
|
||||||
// output
|
|
||||||
let attachment = CurrentValueSubject<Mastodon.Entity.Attachment?, Never>(nil)
|
|
||||||
@Published var thumbnailImage: UIImage?
|
|
||||||
@Published var descriptionPlaceholder = ""
|
|
||||||
@Published var isUploading = true
|
|
||||||
@Published var progressViewTintColor = UIColor.systemFill
|
|
||||||
@Published var error: Error?
|
|
||||||
@Published var errorPrompt: String?
|
|
||||||
@Published var errorPromptImage: UIImage = StatusAttachmentViewModel.photoFillSplitImage
|
|
||||||
|
|
||||||
private(set) lazy var uploadStateMachine: GKStateMachine = {
|
|
||||||
// exclude timeline middle fetcher state
|
|
||||||
let stateMachine = GKStateMachine(states: [
|
|
||||||
UploadState.Initial(viewModel: self),
|
|
||||||
UploadState.Uploading(viewModel: self),
|
|
||||||
UploadState.Fail(viewModel: self),
|
|
||||||
UploadState.Finish(viewModel: self),
|
|
||||||
])
|
|
||||||
stateMachine.enter(UploadState.Initial.self)
|
|
||||||
return stateMachine
|
|
||||||
}()
|
|
||||||
lazy var uploadStateMachineSubject = CurrentValueSubject<StatusAttachmentViewModel.UploadState?, Never>(nil)
|
|
||||||
|
|
||||||
init(
|
|
||||||
api: APIService,
|
|
||||||
itemProvider: NSItemProvider
|
|
||||||
) {
|
|
||||||
self.api = api
|
|
||||||
self.itemProvider = itemProvider
|
|
||||||
|
|
||||||
// bind attachment from item provider
|
|
||||||
Just(itemProvider)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.flatMap { result -> AnyPublisher<Mastodon.Query.MediaAttachment?, Error> in
|
|
||||||
if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.image.identifier, fileOptions: []) {
|
|
||||||
return ItemProviderLoader.loadImageData(from: result).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
if itemProvider.hasRepresentationConforming(toTypeIdentifier: UTType.movie.identifier, fileOptions: []) {
|
|
||||||
return ItemProviderLoader.loadVideoData(from: result).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
return Fail(error: AttachmentError.invalidAttachmentType).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
.sink { [weak self] completion in
|
|
||||||
guard let self = self else { return }
|
|
||||||
switch completion {
|
|
||||||
case .failure(let error):
|
|
||||||
self.error = error
|
|
||||||
self.uploadStateMachine.enter(UploadState.Fail.self)
|
|
||||||
case .finished:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} receiveValue: { [weak self] file in
|
|
||||||
guard let self = self else { return }
|
|
||||||
self.file.value = file
|
|
||||||
self.uploadStateMachine.enter(UploadState.Initial.self)
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
// bind progress view tint color
|
|
||||||
$thumbnailImage
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.map { image -> UIColor in
|
|
||||||
guard let image = image else { return .systemFill }
|
|
||||||
switch image.domainLumaCoefficientsStyle {
|
|
||||||
case .light:
|
|
||||||
return UIColor.black.withAlphaComponent(0.8)
|
|
||||||
default:
|
|
||||||
return UIColor.white.withAlphaComponent(0.8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.assign(to: &$progressViewTintColor)
|
|
||||||
|
|
||||||
// bind description placeholder and error prompt image
|
|
||||||
file
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] file in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard let file = file else { return }
|
|
||||||
switch file {
|
|
||||||
case .jpeg, .png, .gif:
|
|
||||||
self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionPhoto
|
|
||||||
self.errorPromptImage = StatusAttachmentViewModel.photoFillSplitImage
|
|
||||||
case .other:
|
|
||||||
self.descriptionPlaceholder = L10n.Scene.Compose.Attachment.descriptionVideo
|
|
||||||
self.errorPromptImage = StatusAttachmentViewModel.videoSplashImage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
// bind thumbnail image
|
|
||||||
file
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.map { file -> UIImage? in
|
|
||||||
guard let file = file else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch file {
|
|
||||||
case .jpeg(let data), .png(let data):
|
|
||||||
return data.flatMap { UIImage(data: $0) }
|
|
||||||
case .gif:
|
|
||||||
// TODO:
|
|
||||||
return nil
|
|
||||||
case .other(let url, _, _):
|
|
||||||
guard let url = url, 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 {
|
|
||||||
self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): thumbnail generate fail: \(error.localizedDescription)")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.assign(to: &$thumbnailImage)
|
|
||||||
|
|
||||||
// bind state and error
|
|
||||||
Publishers.CombineLatest(
|
|
||||||
uploadStateMachineSubject,
|
|
||||||
$error
|
|
||||||
)
|
|
||||||
.sink { [weak self] state, error in
|
|
||||||
guard let self = self else { return }
|
|
||||||
// trigger delegate
|
|
||||||
self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: state)
|
|
||||||
|
|
||||||
// set error prompt
|
|
||||||
if let error = error {
|
|
||||||
self.isUploading = false
|
|
||||||
self.errorPrompt = error.localizedDescription
|
|
||||||
} else {
|
|
||||||
guard let state = state else { return }
|
|
||||||
switch state {
|
|
||||||
case is UploadState.Finish:
|
|
||||||
self.isUploading = false
|
|
||||||
case is UploadState.Fail:
|
|
||||||
self.isUploading = false
|
|
||||||
// FIXME: not display
|
|
||||||
self.errorPrompt = {
|
|
||||||
guard let file = self.file.value else {
|
|
||||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
|
||||||
}
|
|
||||||
switch file {
|
|
||||||
case .jpeg, .png, .gif:
|
|
||||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.photo)
|
|
||||||
case .other:
|
|
||||||
return L10n.Scene.Compose.Attachment.attachmentBroken(L10n.Scene.Compose.Attachment.video)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
|
|
||||||
// trigger delegate when authentication get new value
|
|
||||||
authentication
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] authentication in
|
|
||||||
guard let self = self else { return }
|
|
||||||
guard authentication != nil else { return }
|
|
||||||
self.delegate?.statusAttachmentViewModel(self, uploadStateDidChange: self.uploadStateMachineSubject.value)
|
|
||||||
}
|
|
||||||
.store(in: &disposeBag)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StatusAttachmentViewModel {
|
|
||||||
enum AttachmentError: Error {
|
|
||||||
case invalidAttachmentType
|
|
||||||
case attachmentTooLarge
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,45 +0,0 @@
|
||||||
//
|
|
||||||
// StatusAuthorView.swift
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-16.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import MastodonUI
|
|
||||||
import Nuke
|
|
||||||
import FLAnimatedImage
|
|
||||||
|
|
||||||
struct StatusAuthorView: View {
|
|
||||||
|
|
||||||
let avatarImageURL: URL?
|
|
||||||
let name: String
|
|
||||||
let username: String
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
HStack(spacing: 5) {
|
|
||||||
AnimatedImage(imageURL: avatarImageURL)
|
|
||||||
.frame(width: 42, height: 42)
|
|
||||||
.background(Color(UIColor.systemFill))
|
|
||||||
.cornerRadius(4)
|
|
||||||
VStack(alignment: .leading) {
|
|
||||||
Text(name)
|
|
||||||
.font(.headline)
|
|
||||||
Text(username)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct StatusAuthorView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
StatusAuthorView(
|
|
||||||
avatarImageURL: URL(string: "https://upload.wikimedia.org/wikipedia/commons/2/2c/Rotating_earth_%28large%29.gif"),
|
|
||||||
name: "Alice",
|
|
||||||
username: "alice"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,105 +0,0 @@
|
||||||
//
|
|
||||||
// StatusEditorView.swift
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// Created by MainasuK Cirno on 2021-7-16.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
import UITextView_Placeholder
|
|
||||||
|
|
||||||
public struct StatusEditorView: UIViewRepresentable {
|
|
||||||
|
|
||||||
@Binding var string: String
|
|
||||||
let placeholder: String
|
|
||||||
let width: CGFloat
|
|
||||||
let attributedString: NSAttributedString
|
|
||||||
let keyboardType: UIKeyboardType
|
|
||||||
@Binding var viewDidAppear: Bool
|
|
||||||
|
|
||||||
public init(
|
|
||||||
string: Binding<String>,
|
|
||||||
placeholder: String,
|
|
||||||
width: CGFloat,
|
|
||||||
attributedString: NSAttributedString,
|
|
||||||
keyboardType: UIKeyboardType,
|
|
||||||
viewDidAppear: Binding<Bool>
|
|
||||||
) {
|
|
||||||
self._string = string
|
|
||||||
self.placeholder = placeholder
|
|
||||||
self.width = width
|
|
||||||
self.attributedString = attributedString
|
|
||||||
self.keyboardType = keyboardType
|
|
||||||
self._viewDidAppear = viewDidAppear
|
|
||||||
}
|
|
||||||
|
|
||||||
public func makeUIView(context: Context) -> UITextView {
|
|
||||||
let textView = UITextView(frame: .zero)
|
|
||||||
textView.placeholder = placeholder
|
|
||||||
|
|
||||||
textView.isScrollEnabled = false
|
|
||||||
textView.font = .preferredFont(forTextStyle: .body)
|
|
||||||
textView.textColor = .label
|
|
||||||
textView.keyboardType = keyboardType
|
|
||||||
textView.delegate = context.coordinator
|
|
||||||
textView.backgroundColor = .clear
|
|
||||||
|
|
||||||
textView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
let widthLayoutConstraint = textView.widthAnchor.constraint(equalToConstant: 100)
|
|
||||||
widthLayoutConstraint.priority = .required - 1
|
|
||||||
context.coordinator.widthLayoutConstraint = widthLayoutConstraint
|
|
||||||
|
|
||||||
return textView
|
|
||||||
}
|
|
||||||
|
|
||||||
public func updateUIView(_ textView: UITextView, context: Context) {
|
|
||||||
// preserve currently selected text range to prevent cursor jump
|
|
||||||
let currentlySelectedRange = textView.selectedRange
|
|
||||||
|
|
||||||
// update content
|
|
||||||
// textView.attributedText = attributedString
|
|
||||||
textView.text = string
|
|
||||||
|
|
||||||
// update layout
|
|
||||||
context.coordinator.updateLayout(width: width)
|
|
||||||
|
|
||||||
// set becomeFirstResponder
|
|
||||||
if viewDidAppear {
|
|
||||||
viewDidAppear = false
|
|
||||||
textView.becomeFirstResponder()
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore selected text range
|
|
||||||
textView.selectedRange = currentlySelectedRange
|
|
||||||
}
|
|
||||||
|
|
||||||
public func makeCoordinator() -> Coordinator {
|
|
||||||
Coordinator(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Coordinator: NSObject, UITextViewDelegate {
|
|
||||||
var parent: StatusEditorView
|
|
||||||
var widthLayoutConstraint: NSLayoutConstraint?
|
|
||||||
|
|
||||||
init(_ parent: StatusEditorView) {
|
|
||||||
self.parent = parent
|
|
||||||
}
|
|
||||||
|
|
||||||
public func textViewDidChange(_ textView: UITextView) {
|
|
||||||
// prevent break IME input
|
|
||||||
if textView.markedTextRange == nil {
|
|
||||||
parent.string = textView.text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateLayout(width: CGFloat) {
|
|
||||||
guard let widthLayoutConstraint = widthLayoutConstraint else { return }
|
|
||||||
widthLayoutConstraint.constant = width
|
|
||||||
widthLayoutConstraint.isActive = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue