chore: code clean

This commit is contained in:
CMK 2022-11-14 00:06:44 +08:00
parent 939429aacc
commit 82abc68486
10 changed files with 0 additions and 1238 deletions

View File

@ -948,7 +948,6 @@
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>"; };
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>"; };
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; };
@ -1028,15 +1027,7 @@
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>"; };
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>"; };
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>"; };
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>"; };
@ -2666,26 +2657,9 @@
path = Cell;
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 */ = {
isa = PBXGroup;
children = (
DBFEF05426A576EE006D7ED1 /* View */,
DBC6462226A1712000B0E31B /* ShareViewModel.swift */,
DBC3872329214121001EC0FD /* ShareViewController.swift */,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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