From 929a27d572a8e33b913d3bd211628de51bc2499c Mon Sep 17 00:00:00 2001 From: CMK Date: Sun, 13 Nov 2022 22:08:26 +0800 Subject: [PATCH] feat: [WIP] restore publish button and compose pre-insert content --- Mastodon.xcodeproj/project.pbxproj | 8 - .../Scene/Compose/ComposeViewController.swift | 645 ++---------------- .../Compose/ComposeViewModel+DataSource.swift | 453 ------------ .../ComposeViewModel+PublishState.swift | 164 ----- Mastodon/Scene/Compose/ComposeViewModel.swift | 333 +-------- .../Attachment/AttachmentViewModel.swift | 1 - .../ComposeContentViewController.swift | 90 ++- .../ComposeContentViewModel.swift | 254 ++++++- .../Poll/PollOptionTextField.swift | 2 +- .../ComposeContentToolbarView+ViewModel.swift | 3 + .../ComposeContentToolbarView.swift | 12 + 11 files changed, 401 insertions(+), 1564 deletions(-) delete mode 100644 Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift delete mode 100644 Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift rename MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/{View => Toolbar}/ComposeContentToolbarView+ViewModel.swift (97%) rename MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/{View => Toolbar}/ComposeContentToolbarView.swift (87%) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index e3c3c58d3..24d394497 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -253,7 +253,6 @@ DB64BA452851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */; }; DB64BA482851F29300ADF1B7 /* Account+Fetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */; }; DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB65C63627A2AF6C008BAC2E /* ReportItem.swift */; }; - DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */; }; DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */; }; DB6746ED278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */; }; DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */; }; @@ -333,7 +332,6 @@ DB98EB6927B21A7C0082E365 /* ReportResultActionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */; }; DB98EB6B27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */; }; DB9A486C26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */; }; - DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */; }; DB9D6BE925E4F5340051B173 /* SearchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BE825E4F5340051B173 /* SearchViewController.swift */; }; DB9D6BF825E4F5690051B173 /* NotificationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */; }; DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */; }; @@ -811,7 +809,6 @@ DB64BA442851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MastodonAuthentication+Fetch.swift"; sourceTree = ""; }; DB64BA472851F29300ADF1B7 /* Account+Fetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Account+Fetch.swift"; sourceTree = ""; }; DB65C63627A2AF6C008BAC2E /* ReportItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportItem.swift; sourceTree = ""; }; - DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+DataSource.swift"; sourceTree = ""; }; DB6746EA278ED8B0008A6B94 /* PollOptionView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PollOptionView+Configuration.swift"; sourceTree = ""; }; DB6746EC278F45F0008A6B94 /* AutoGenerateProtocolRelayDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolRelayDelegate.swift; sourceTree = ""; }; DB6746EF278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoGenerateProtocolDelegate.swift; sourceTree = ""; }; @@ -903,7 +900,6 @@ DB98EB6827B21A7C0082E365 /* ReportResultActionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReportResultActionTableViewCell.swift; sourceTree = ""; }; DB98EB6A27B243470082E365 /* SettingsAppearanceTableViewCell+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsAppearanceTableViewCell+ViewModel.swift"; sourceTree = ""; }; DB9A486B26032AC1008B817C /* AttachmentContainerView+EmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AttachmentContainerView+EmptyStateView.swift"; sourceTree = ""; }; - DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ComposeViewModel+PublishState.swift"; sourceTree = ""; }; DB9D6BE825E4F5340051B173 /* SearchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewController.swift; sourceTree = ""; }; DB9D6BF725E4F5690051B173 /* NotificationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationViewController.swift; sourceTree = ""; }; DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; @@ -2134,8 +2130,6 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, - DB66728B25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift */, - DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, ); path = Compose; sourceTree = ""; @@ -3183,7 +3177,6 @@ 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */, DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */, - DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, 2D8434F525FF465D00EECE90 /* HomeTimelineNavigationBarTitleViewModel.swift in Sources */, DB938F0F2624119800E5B6C1 /* ThreadViewModel+LoadThreadState.swift in Sources */, @@ -3285,7 +3278,6 @@ DB63F7492799126300455B82 /* FollowerListViewController+DataSourceProvider.swift in Sources */, 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */, 2DAC9E38262FC2320062E1A6 /* SuggestionAccountViewController.swift in Sources */, - DB66728C25F9F8DC00D60309 /* ComposeViewModel+DataSource.swift in Sources */, DB6180E02639194B0018D199 /* MediaPreviewPagingViewController.swift in Sources */, DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */, 5B90C45E262599800002E742 /* SettingsViewModel.swift in Sources */, diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index f23e44e5f..33eabd721 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -86,22 +86,6 @@ final class ComposeViewController: UIViewController, NeedsDependency { publishButton.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled) publishButton.setTitleColor(Asset.Colors.Label.primaryReverse.color, for: .normal) } - -// var systemKeyboardHeight: CGFloat = .zero { -// didSet { -// // note: some system AutoLayout warning here -// let height = max(300, systemKeyboardHeight) -// customEmojiPickerInputView.frame.size.height = height -// } -// } -// - -// -// let composeToolbarView = ComposeToolbarView() -// var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint! -// let composeToolbarBackgroundView = UIView() -// -// deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -155,132 +139,30 @@ extension ComposeViewController { ]) composeContentViewController.didMove(toParent: self) -// configureNavigationBarTitleStyle() -// viewModel.traitCollectionDidChangePublisher -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let self = self else { return } -// self.configureNavigationBarTitleStyle() -// } -// .store(in: &disposeBag) -// -// viewModel.$title -// .receive(on: DispatchQueue.main) -// .sink { [weak self] title in -// guard let self = self else { return } -// self.title = title -// } -// .store(in: &disposeBag) -// -// composeToolbarView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(composeToolbarView) -// composeToolbarViewBottomLayoutConstraint = view.bottomAnchor.constraint(equalTo: composeToolbarView.bottomAnchor) -// NSLayoutConstraint.activate([ -// composeToolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// composeToolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// composeToolbarViewBottomLayoutConstraint, -// composeToolbarView.heightAnchor.constraint(equalToConstant: ComposeToolbarView.toolbarHeight), -// ]) -// composeToolbarView.preservesSuperviewLayoutMargins = true -// composeToolbarView.delegate = self -// -// composeToolbarBackgroundView.translatesAutoresizingMaskIntoConstraints = false -// view.insertSubview(composeToolbarBackgroundView, belowSubview: composeToolbarView) -// NSLayoutConstraint.activate([ -// composeToolbarBackgroundView.topAnchor.constraint(equalTo: composeToolbarView.topAnchor), -// composeToolbarBackgroundView.leadingAnchor.constraint(equalTo: composeToolbarView.leadingAnchor), -// composeToolbarBackgroundView.trailingAnchor.constraint(equalTo: composeToolbarView.trailingAnchor), -// view.bottomAnchor.constraint(equalTo: composeToolbarBackgroundView.bottomAnchor), -// ]) + // bind navigation bar style + configureNavigationBarTitleStyle() + viewModel.traitCollectionDidChangePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.configureNavigationBarTitleStyle() + } + .store(in: &disposeBag) -// tableView.delegate = self -// viewModel.setupDataSource( -// tableView: tableView, -// metaTextDelegate: self, -// metaTextViewDelegate: self, -// customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel, -// composeStatusAttachmentCollectionViewCellDelegate: self, -// composeStatusPollOptionCollectionViewCellDelegate: self, -// composeStatusPollOptionAppendEntryCollectionViewCellDelegate: self, -// composeStatusPollExpiresOptionCollectionViewCellDelegate: self -// ) + // bind title + viewModel.$title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self = self else { return } + self.title = title + } + .store(in: &disposeBag) -// viewModel.composeStatusAttribute.$composeContent -// .removeDuplicates() -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let self = self else { return } -// guard self.view.window != nil else { return } -// UIView.performWithoutAnimation { -// self.tableView.beginUpdates() -// self.tableView.setNeedsLayout() -// self.tableView.layoutIfNeeded() -// self.tableView.endUpdates() -// } -// } -// .store(in: &disposeBag) - -// viewModel.composeStatusContentTableViewCell.delegate = self -// -// // update layout when keyboard show/dismiss -// view.layoutIfNeeded() -// -// // bind publish bar button state -// viewModel.$isPublishBarButtonItemEnabled -// .receive(on: DispatchQueue.main) -// .assign(to: \.isEnabled, on: publishButton) -// .store(in: &disposeBag) -// -// // bind media button toolbar state -// viewModel.$isMediaToolbarButtonEnabled -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isMediaToolbarButtonEnabled in -// guard let self = self else { return } -// self.composeToolbarView.mediaBarButtonItem.isEnabled = isMediaToolbarButtonEnabled -// self.composeToolbarView.mediaButton.isEnabled = isMediaToolbarButtonEnabled -// } -// .store(in: &disposeBag) -// -// // bind poll button toolbar state -// viewModel.$isPollToolbarButtonEnabled -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isPollToolbarButtonEnabled in -// guard let self = self else { return } -// self.composeToolbarView.pollBarButtonItem.isEnabled = isPollToolbarButtonEnabled -// self.composeToolbarView.pollButton.isEnabled = isPollToolbarButtonEnabled -// } -// .store(in: &disposeBag) -// -// Publishers.CombineLatest( -// viewModel.$isPollComposing, -// viewModel.$isPollToolbarButtonEnabled -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isPollComposing, isPollToolbarButtonEnabled in -// guard let self = self else { return } -// guard isPollToolbarButtonEnabled else { -// let accessibilityLabel = L10n.Scene.Compose.Accessibility.appendPoll -// self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel -// self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel -// return -// } -// let accessibilityLabel = isPollComposing ? L10n.Scene.Compose.Accessibility.removePoll : L10n.Scene.Compose.Accessibility.appendPoll -// self.composeToolbarView.pollBarButtonItem.accessibilityLabel = accessibilityLabel -// self.composeToolbarView.pollButton.accessibilityLabel = accessibilityLabel -// } -// .store(in: &disposeBag) -// -// // bind image picker toolbar state -// viewModel.$attachmentServices -// .receive(on: DispatchQueue.main) -// .sink { [weak self] attachmentServices in -// guard let self = self else { return } -// let isEnabled = attachmentServices.count < self.viewModel.maxMediaAttachments -// self.composeToolbarView.mediaBarButtonItem.isEnabled = isEnabled -// self.composeToolbarView.mediaButton.isEnabled = isEnabled -// self.resetImagePicker() -// } -// .store(in: &disposeBag) + // bind publish bar button state + composeContentViewModel.$isPublishBarButtonItemEnabled + .receive(on: DispatchQueue.main) + .assign(to: \.isEnabled, on: publishButton) + .store(in: &disposeBag) // // // bind content warning button state // viewModel.$isContentWarningComposing @@ -292,72 +174,7 @@ extension ComposeViewController { // self.composeToolbarView.contentWarningButton.accessibilityLabel = accessibilityLabel // } // .store(in: &disposeBag) -// -// // bind visibility toolbar UI -// Publishers.CombineLatest( -// viewModel.$selectedStatusVisibility, -// viewModel.traitCollectionDidChangePublisher -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] type, _ in -// guard let self = self else { return } -// let image = type.image(interfaceStyle: self.traitCollection.userInterfaceStyle) -// self.composeToolbarView.visibilityBarButtonItem.image = image -// self.composeToolbarView.visibilityButton.setImage(image, for: .normal) -// self.composeToolbarView.activeVisibilityType.value = type -// } -// .store(in: &disposeBag) -// -// viewModel.$characterCount -// .receive(on: DispatchQueue.main) -// .sink { [weak self] characterCount in -// guard let self = self else { return } -// let count = self.viewModel.composeContentLimit - characterCount -// self.composeToolbarView.characterCountLabel.text = "\(count)" -// self.characterCountLabel.text = "\(count)" -// let font: UIFont -// let textColor: UIColor -// let accessibilityLabel: String -// switch count { -// case _ where count < 0: -// font = .monospacedDigitSystemFont(ofSize: 24, weight: .bold) -// textColor = Asset.Colors.danger.color -// accessibilityLabel = L10n.A11y.Plural.Count.inputLimitExceeds(abs(count)) -// default: -// font = .monospacedDigitSystemFont(ofSize: 15, weight: .regular) -// textColor = Asset.Colors.Label.secondary.color -// accessibilityLabel = L10n.A11y.Plural.Count.inputLimitRemains(count) -// } -// self.composeToolbarView.characterCountLabel.font = font -// self.composeToolbarView.characterCountLabel.textColor = textColor -// self.composeToolbarView.characterCountLabel.accessibilityLabel = accessibilityLabel -// self.characterCountLabel.font = font -// self.characterCountLabel.textColor = textColor -// self.characterCountLabel.accessibilityLabel = accessibilityLabel -// self.characterCountLabel.sizeToFit() -// } -// .store(in: &disposeBag) -// -// configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar.value) -// Publishers.CombineLatest( -// keyboardHasShortcutBar, -// viewModel.traitCollectionDidChangePublisher -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] keyboardHasShortcutBar, _ in -// guard let self = self else { return } -// self.configureToolbarDisplay(keyboardHasShortcutBar: keyboardHasShortcutBar) -// } -// .store(in: &disposeBag) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - -// // update MetaText without trigger call underlaying `UITextStorage.processEditing` -// _ = textEditorView.processEditing(textEditorView.textStorage) - -// markTextEditorViewBecomeFirstResponser() + } override func viewDidAppear(_ animated: Bool) { @@ -369,102 +186,27 @@ extension ComposeViewController { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) -// configurePublishButtonApperance() -// viewModel.traitCollectionDidChangePublisher.send() + configurePublishButtonApperance() + viewModel.traitCollectionDidChangePublisher.send() } } -//extension ComposeViewController { -// -// private var textEditorView: MetaText { -// return viewModel.composeStatusContentTableViewCell.metaText -// } -// -// private func markTextEditorViewBecomeFirstResponser() { -// textEditorView.textView.becomeFirstResponder() -// } -// -// private func contentWarningEditorTextView() -> UITextView? { -// viewModel.composeStatusContentTableViewCell.statusContentWarningEditorView.textView -// } -// -// private func pollOptionCollectionViewCell(of item: ComposeStatusPollItem) -> ComposeStatusPollOptionCollectionViewCell? { -// guard case .pollOption = item else { return nil } -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } -// guard let indexPath = dataSource.indexPath(for: item), -// let cell = viewModel.composeStatusPollTableViewCell.collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else { -// return nil -// } -// -// return cell -// } -// -// private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } -// let items = dataSource.snapshot().itemIdentifiers(inSection: .main) -// let firstPollItem = items.first { item -> Bool in -// guard case .pollOption = item else { return false } -// return true -// } -// -// guard let item = firstPollItem else { -// return nil -// } -// -// return pollOptionCollectionViewCell(of: item) -// } -// -// private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? { -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return nil } -// let items = dataSource.snapshot().itemIdentifiers(inSection: .main) -// let lastPollItem = items.last { item -> Bool in -// guard case .pollOption = item else { return false } -// return true -// } -// -// guard let item = lastPollItem else { -// return nil -// } -// -// return pollOptionCollectionViewCell(of: item) -// } -// -// private func markFirstPollOptionCollectionViewCellBecomeFirstResponser() { -// guard let cell = firstPollOptionCollectionViewCell() else { return } -// cell.pollOptionView.optionTextField.becomeFirstResponder() -// } -// -// private func markLastPollOptionCollectionViewCellBecomeFirstResponser() { -// guard let cell = lastPollOptionCollectionViewCell() else { return } -// cell.pollOptionView.optionTextField.becomeFirstResponder() -// } -// -// private func showDismissConfirmAlertController() { -// let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) -// let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in -// guard let self = self else { return } -// self.dismiss(animated: true, completion: nil) -// } -// alertController.addAction(discardAction) -// let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) -// alertController.addAction(cancelAction) -// alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem -// present(alertController, animated: true, completion: nil) -// } -// -// private func resetImagePicker() { -// let selectionLimit = max(1, viewModel.maxMediaAttachments - viewModel.attachmentServices.count) -// let configuration = ComposeViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) -// photoLibraryPicker = createImagePicker(configuration: configuration) -// } -// -// private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { -// let imagePicker = PHPickerViewController(configuration: configuration) -// imagePicker.delegate = self -// return imagePicker -// } -// +extension ComposeViewController { + + private func showDismissConfirmAlertController() { + let alertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + let discardAction = UIAlertAction(title: L10n.Common.Controls.Actions.discard, style: .destructive) { [weak self] _ in + guard let self = self else { return } + self.dismiss(animated: true, completion: nil) + } + alertController.addAction(discardAction) + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + alertController.addAction(cancelAction) + alertController.popoverPresentationController?.barButtonItem = cancelBarButtonItem + present(alertController, animated: true, completion: nil) + } + // private func setupBackgroundColor(theme: Theme) { // let backgroundColor = UIColor(dynamicProvider: { traitCollection in // switch traitCollection.userInterfaceStyle { @@ -503,46 +245,40 @@ extension ComposeViewController { // } // } // -// private func configureNavigationBarTitleStyle() { -// switch traitCollection.userInterfaceIdiom { -// case .pad: -// navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular -// default: -// break -// } -// } -// -//} -// + private func configureNavigationBarTitleStyle() { + switch traitCollection.userInterfaceIdiom { + case .pad: + navigationController?.navigationBar.prefersLargeTitles = traitCollection.horizontalSizeClass == .regular + default: + break + } + } + +} + extension ComposeViewController { @objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") -// guard viewModel.shouldDismiss else { -// showDismissConfirmAlertController() -// return -// } + guard composeContentViewModel.shouldDismiss else { + showDismissConfirmAlertController() + return + } dismiss(animated: true, completion: nil) } @objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// do { -// try viewModel.checkAttachmentPrecondition() -// } catch { -// let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) -// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) -// alertController.addAction(okAction) -// coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) -// return -// } -// guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else { -// // TODO: handle error -// return -// } - - // context.statusPublishService.publish(composeViewModel: viewModel) + do { + try composeContentViewModel.checkAttachmentPrecondition() + } catch { + let alertController = UIAlertController(for: error, title: nil, preferredStyle: .alert) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) + alertController.addAction(okAction) + coordinator.present(scene: .alertController(alertController: alertController), from: nil, transition: .alertController(animated: true, completion: nil)) + return + } do { let statusPublisher = try composeContentViewModel.statusPublisher() @@ -565,111 +301,6 @@ extension ComposeViewController { } -//// MARK: - MetaTextDelegate -//extension ComposeViewController: MetaTextDelegate { -// func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { -// let string = metaText.textStorage.string -// let content = MastodonContent( -// content: string, -// emojis: viewModel.customEmojiViewModel?.emojiMapping.value ?? [:] -// ) -// let metaContent = MastodonMetaContent.convert(text: content) -// return metaContent -// } -//} -// -//// MARK: - UITextViewDelegate -//extension ComposeViewController: UITextViewDelegate { -// -// func textViewShouldBeginEditing(_ textView: UITextView) -> Bool { -// setupInputAssistantItem(item: textView.inputAssistantItem) -// return true -// } -// - -// - -// - -// -// func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { -// switch textView { -// case textEditorView.textView: -// return false -// default: -// return true -// } -// } -// -// func textView(_ textView: UITextView, shouldInteractWith textAttachment: NSTextAttachment, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { -// switch textView { -// case textEditorView.textView: -// return false -// default: -// return true -// } -// } -// -//} -// -//// MARK: - ComposeToolbarViewDelegate -//extension ComposeViewController: ComposeToolbarViewDelegate { - -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: Any) { -// // toggle poll composing state -// viewModel.isPollComposing.toggle() -// -// // cancel custom picker input -// viewModel.isCustomEmojiComposing = false -// -// // setup initial poll option if needs -// if viewModel.isPollComposing, viewModel.pollOptionAttributes.isEmpty { -// viewModel.pollOptionAttributes = [ComposeStatusPollItem.PollOptionAttribute(), ComposeStatusPollItem.PollOptionAttribute()] -// } -// -// if viewModel.isPollComposing { -// // Magic RunLoop -// DispatchQueue.main.async { -// self.markFirstPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } else { -// markTextEditorViewBecomeFirstResponser() -// } -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: Any) { -// viewModel.isCustomEmojiComposing.toggle() -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: Any) { -// // cancel custom picker input -// viewModel.isCustomEmojiComposing = false -// -// // restore first responder for text editor when content warning dismiss -// if viewModel.isContentWarningComposing { -// if contentWarningEditorTextView()?.isFirstResponder == true { -// markTextEditorViewBecomeFirstResponser() -// } -// } -// -// // toggle composing status -// viewModel.isContentWarningComposing.toggle() -// -// // active content warning after toggled -// if viewModel.isContentWarningComposing { -// contentWarningEditorTextView()?.becomeFirstResponder() -// } -// } -// -// func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: Any, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) { -// viewModel.selectedStatusVisibility = type -// } -// -//} - -//// MARK: - UITableViewDelegate -//extension ComposeViewController: UITableViewDelegate { } - // MARK: - UIAdaptivePresentationControllerDelegate extension ComposeViewController: UIAdaptivePresentationControllerDelegate { @@ -681,15 +312,15 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { return .pageSheet } } - -// func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { -// return viewModel.shouldDismiss -// } -// func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// showDismissConfirmAlertController() -// } + func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool { + return composeContentViewModel.shouldDismiss + } + + func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + showDismissConfirmAlertController() + } func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) @@ -697,138 +328,6 @@ extension ComposeViewController: UIAdaptivePresentationControllerDelegate { } -//// MARK: - ComposeStatusAttachmentTableViewCellDelegate -//extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate { -// -// func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) { -// guard let diffableDataSource = viewModel.composeStatusAttachmentTableViewCell.dataSource else { return } -// guard let indexPath = viewModel.composeStatusAttachmentTableViewCell.collectionView.indexPath(for: cell) else { return } -// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } -// guard case let .attachment(attachmentService) = item else { return } -// -// var attachmentServices = viewModel.attachmentServices -// guard let index = attachmentServices.firstIndex(of: attachmentService) else { return } -// let removedItem = attachmentServices[index] -// attachmentServices.remove(at: index) -// viewModel.attachmentServices = attachmentServices -// -// // cancel task -// removedItem.disposeBag.removeAll() -// } -// -//} -// -//// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate -//extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate { -// -// func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) { -// -// setupInputAssistantItem(item: textField.inputAssistantItem) -// -// // FIXME: make poll section visible -// // DispatchQueue.main.async { -// // self.collectionView.scroll(to: .bottom, animated: true) -// // } -// } -// -// -// // handle delete backward event for poll option input -// func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) { -// guard (text ?? "").isEmpty else { return } -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } -// guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } -// guard let item = dataSource.itemIdentifier(for: indexPath) else { return } -// guard case let .pollOption(attribute) = item else { return } -// -// var pollAttributes = viewModel.pollOptionAttributes -// guard let index = pollAttributes.firstIndex(of: attribute) else { return } -// -// // mark previous (fallback to next) item of removed middle poll option become first responder -// let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main) -// if let indexOfItem = pollItems.firstIndex(of: item), index > 0 { -// func cellBeforeRemoved() -> ComposeStatusPollOptionCollectionViewCell? { -// guard index > 0 else { return nil } -// let indexBeforeRemoved = pollItems.index(before: indexOfItem) -// let itemBeforeRemoved = pollItems[indexBeforeRemoved] -// return pollOptionCollectionViewCell(of: itemBeforeRemoved) -// } -// -// func cellAfterRemoved() -> ComposeStatusPollOptionCollectionViewCell? { -// guard index < pollItems.count - 1 else { return nil } -// let indexAfterRemoved = pollItems.index(after: index) -// let itemAfterRemoved = pollItems[indexAfterRemoved] -// return pollOptionCollectionViewCell(of: itemAfterRemoved) -// } -// -// var cell: ComposeStatusPollOptionCollectionViewCell? = cellBeforeRemoved() -// if cell == nil { -// cell = cellAfterRemoved() -// } -// cell?.pollOptionView.optionTextField.becomeFirstResponder() -// } -// -// guard pollAttributes.count > 2 else { -// return -// } -// pollAttributes.remove(at: index) -// -// // update data source -// viewModel.pollOptionAttributes = pollAttributes -// } -// -// // handle keyboard return event for poll option input -// func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) { -// guard let dataSource = viewModel.composeStatusPollTableViewCell.dataSource else { return } -// guard let indexPath = viewModel.composeStatusPollTableViewCell.collectionView.indexPath(for: cell) else { return } -// let pollItems = dataSource.snapshot().itemIdentifiers(inSection: .main).filter { item in -// guard case .pollOption = item else { return false } -// return true -// } -// guard let item = dataSource.itemIdentifier(for: indexPath) else { return } -// guard let index = pollItems.firstIndex(of: item) else { return } -// -// if index == pollItems.count - 1 { -// // is the last -// viewModel.createNewPollOptionIfPossible() -// DispatchQueue.main.async { -// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } else { -// // not the last -// let indexAfter = pollItems.index(after: index) -// let itemAfter = pollItems[indexAfter] -// let cell = pollOptionCollectionViewCell(of: itemAfter) -// cell?.pollOptionView.optionTextField.becomeFirstResponder() -// } -// } -// -//} -// -//// MARK: - ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate -//extension ComposeViewController: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate { -// func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed(_ cell: ComposeStatusPollOptionAppendEntryCollectionViewCell) { -// viewModel.createNewPollOptionIfPossible() -// DispatchQueue.main.async { -// self.markLastPollOptionCollectionViewCellBecomeFirstResponser() -// } -// } -//} -// -//// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate -//extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate { -// func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusPollItem.PollExpiresOptionAttribute.ExpiresOption) { -// viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption -// } -//} -// -//// MARK: - ComposeStatusContentTableViewCellDelegate -//extension ComposeViewController: ComposeStatusContentTableViewCellDelegate { -// func composeStatusContentTableViewCell(_ cell: ComposeStatusContentTableViewCell, textViewShouldBeginEditing textView: UITextView) -> Bool { -// setupInputAssistantItem(item: textView.inputAssistantItem) -// return true -// } -//} - //extension ComposeViewController { // override var keyCommands: [UIKeyCommand]? { // composeKeyCommands diff --git a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift b/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift deleted file mode 100644 index 5ecba3791..000000000 --- a/Mastodon/Scene/Compose/ComposeViewModel+DataSource.swift +++ /dev/null @@ -1,453 +0,0 @@ -// -// ComposeViewModel+Diffable.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-11. -// - -import os.log -import UIKit -import Combine -import CoreDataStack -import MetaTextKit -import MastodonMeta -import MastodonAsset -import MastodonCore -import MastodonLocalization -import MastodonSDK - -extension ComposeViewModel { - -// func setupDataSource( -// tableView: UITableView, -// metaTextDelegate: MetaTextDelegate, -// metaTextViewDelegate: UITextViewDelegate, -// customEmojiPickerInputViewModel: CustomEmojiPickerInputViewModel, -// composeStatusAttachmentCollectionViewCellDelegate: ComposeStatusAttachmentCollectionViewCellDelegate, -// composeStatusPollOptionCollectionViewCellDelegate: ComposeStatusPollOptionCollectionViewCellDelegate, -// composeStatusPollOptionAppendEntryCollectionViewCellDelegate: ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate, -// composeStatusPollExpiresOptionCollectionViewCellDelegate: ComposeStatusPollExpiresOptionCollectionViewCellDelegate -// ) { -// // UI -// bind() -// -// // content -// bind(cell: composeStatusContentTableViewCell, tableView: tableView) -// composeStatusContentTableViewCell.metaText.delegate = metaTextDelegate -// composeStatusContentTableViewCell.metaText.textView.delegate = metaTextViewDelegate -// -// // attachment -// bind(cell: composeStatusAttachmentTableViewCell, tableView: tableView) -// composeStatusAttachmentTableViewCell.composeStatusAttachmentCollectionViewCellDelegate = composeStatusAttachmentCollectionViewCellDelegate -// -// // poll -// bind(cell: composeStatusPollTableViewCell, tableView: tableView) -// composeStatusPollTableViewCell.delegate = self -// composeStatusPollTableViewCell.customEmojiPickerInputViewModel = customEmojiPickerInputViewModel -// composeStatusPollTableViewCell.composeStatusPollOptionCollectionViewCellDelegate = composeStatusPollOptionCollectionViewCellDelegate -// composeStatusPollTableViewCell.composeStatusPollOptionAppendEntryCollectionViewCellDelegate = composeStatusPollOptionAppendEntryCollectionViewCellDelegate -// composeStatusPollTableViewCell.composeStatusPollExpiresOptionCollectionViewCellDelegate = composeStatusPollExpiresOptionCollectionViewCellDelegate -// -// // setup data source -// tableView.dataSource = self -// } - -} - -//// MARK: - UITableViewDataSource -//extension ComposeViewModel: UITableViewDataSource { - -// func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { -// switch Section.allCases[indexPath.section] { -// case .repliedTo: -// let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: ComposeRepliedToStatusContentTableViewCell.self), for: indexPath) as! ComposeRepliedToStatusContentTableViewCell -// guard case let .reply(record) = composeKind else { return cell } -// -// // bind frame publisher -// cell.framePublisher -// .receive(on: DispatchQueue.main) -// .assign(to: \.repliedToCellFrame, on: self) -// .store(in: &cell.disposeBag) -// -// // set initial width -// if cell.statusView.frame.width == .zero { -// cell.statusView.frame.size.width = tableView.frame.width -// } -// -// // configure status -// context.managedObjectContext.performAndWait { -// guard let replyTo = record.object(in: context.managedObjectContext) else { return } -// cell.statusView.configure(status: replyTo) -// } -// -// return cell -// case .status: -// return composeStatusContentTableViewCell -// case .attachment: -// return composeStatusAttachmentTableViewCell -// case .poll: -// return composeStatusPollTableViewCell -// } -// } -//} - -//// MARK: - ComposeStatusPollTableViewCellDelegate -//extension ComposeViewModel: ComposeStatusPollTableViewCellDelegate { -// func composeStatusPollTableViewCell(_ cell: ComposeStatusPollTableViewCell, pollOptionAttributesDidReorder options: [ComposeStatusPollItem.PollOptionAttribute]) { -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) -// -// self.pollOptionAttributes = options -// } -//} -// -//extension ComposeViewModel { -// private func bind() { -// $isCustomEmojiComposing -// .assign(to: \.value, on: customEmojiPickerInputViewModel.isCustomEmojiComposing) -// .store(in: &disposeBag) -// -// $isContentWarningComposing -// .assign(to: \.isContentWarningComposing, on: composeStatusAttribute) -// .store(in: &disposeBag) -// -// // bind compose toolbar UI state -// Publishers.CombineLatest( -// $isPollComposing, -// $attachmentServices -// ) -// .receive(on: DispatchQueue.main) -// .sink(receiveValue: { [weak self] isPollComposing, attachmentServices in -// guard let self = self else { return } -// let shouldMediaDisable = isPollComposing || attachmentServices.count >= self.maxMediaAttachments -// let shouldPollDisable = attachmentServices.count > 0 -// -// self.isMediaToolbarButtonEnabled = !shouldMediaDisable -// self.isPollToolbarButtonEnabled = !shouldPollDisable -// }) -// .store(in: &disposeBag) -// -// // calculate `Idempotency-Key` -// let content = Publishers.CombineLatest3( -// composeStatusAttribute.$isContentWarningComposing, -// composeStatusAttribute.$contentWarningContent, -// composeStatusAttribute.$composeContent -// ) -// .map { isContentWarningComposing, contentWarningContent, composeContent -> String in -// if isContentWarningComposing { -// return contentWarningContent + (composeContent ?? "") -// } else { -// return composeContent ?? "" -// } -// } -// let attachmentIDs = $attachmentServices.map { attachments -> String in -// let attachmentIDs = attachments.compactMap { $0.attachment.value?.id } -// return attachmentIDs.joined(separator: ",") -// } -// let pollOptionsAndDuration = Publishers.CombineLatest3( -// $isPollComposing, -// $pollOptionAttributes, -// pollExpiresOptionAttribute.expiresOption -// ) -// .map { isPollComposing, pollOptionAttributes, expiresOption -> String in -// guard isPollComposing else { -// return "" -// } -// -// let pollOptions = pollOptionAttributes.map { $0.option.value }.joined(separator: ",") -// return pollOptions + expiresOption.rawValue -// } -// -// Publishers.CombineLatest4( -// content, -// attachmentIDs, -// pollOptionsAndDuration, -// $selectedStatusVisibility -// ) -// .map { content, attachmentIDs, pollOptionsAndDuration, selectedStatusVisibility -> String in -// var hasher = Hasher() -// hasher.combine(content) -// hasher.combine(attachmentIDs) -// hasher.combine(pollOptionsAndDuration) -// hasher.combine(selectedStatusVisibility.visibility.rawValue) -// let hashValue = hasher.finalize() -// return "\(hashValue)" -// } -// .assign(to: \.value, on: idempotencyKey) -// .store(in: &disposeBag) -// -// // bind modal dismiss state -// composeStatusAttribute.$composeContent -// .receive(on: DispatchQueue.main) -// .map { [weak self] content in -// let content = content ?? "" -// if content.isEmpty { -// return true -// } -// // if preInsertedContent plus a space is equal to the content, simply dismiss the modal -// if let preInsertedContent = self?.preInsertedContent { -// return content == preInsertedContent -// } -// return false -// } -// .assign(to: &$shouldDismiss) -// -// // bind compose bar button item UI state -// let isComposeContentEmpty = composeStatusAttribute.$composeContent -// .map { ($0 ?? "").isEmpty } -// let isComposeContentValid = $characterCount -// .compactMap { [weak self] characterCount -> Bool in -// guard let self = self else { return characterCount <= 500 } -// return characterCount <= self.composeContentLimit -// } -// let isMediaEmpty = $attachmentServices -// .map { $0.isEmpty } -// let isMediaUploadAllSuccess = $attachmentServices -// .map { services in -// services.allSatisfy { $0.uploadStateMachineSubject.value is MastodonAttachmentService.UploadState.Finish } -// } -// let isPollAttributeAllValid = $pollOptionAttributes -// .map { pollAttributes in -// pollAttributes.allSatisfy { attribute -> Bool in -// !attribute.option.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty -// } -// } -// -// let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( -// isComposeContentEmpty, -// isComposeContentValid, -// isMediaEmpty, -// isMediaUploadAllSuccess -// ) -// .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in -// if isMediaEmpty { -// return isComposeContentValid && !isComposeContentEmpty -// } else { -// return isComposeContentValid && isMediaUploadAllSuccess -// } -// } -// .eraseToAnyPublisher() -// -// let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( -// isComposeContentEmpty, -// isComposeContentValid, -// $isPollComposing, -// isPollAttributeAllValid -// ) -// .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollAttributeAllValid -> Bool in -// if isPollComposing { -// return isComposeContentValid && !isComposeContentEmpty && isPollAttributeAllValid -// } else { -// return isComposeContentValid && !isComposeContentEmpty -// } -// } -// .eraseToAnyPublisher() -// -// Publishers.CombineLatest( -// isPublishBarButtonItemEnabledPrecondition1, -// isPublishBarButtonItemEnabledPrecondition2 -// ) -// .map { $0 && $1 } -// .assign(to: &$isPublishBarButtonItemEnabled) -// } -//} -// -//extension ComposeViewModel { -// private func bind( -// cell: ComposeStatusContentTableViewCell, -// tableView: UITableView -// ) { -// // bind status content character count -// Publishers.CombineLatest3( -// composeStatusAttribute.$composeContent, -// composeStatusAttribute.$isContentWarningComposing, -// composeStatusAttribute.$contentWarningContent -// ) -// .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in -// let composeContent = composeContent ?? "" -// var count = composeContent.count -// if isContentWarningComposing { -// count += contentWarningContent.count -// } -// return count -// } -// .assign(to: &$characterCount) -// -// // bind content warning -// composeStatusAttribute.$isContentWarningComposing -// .receive(on: DispatchQueue.main) -// .sink { [weak cell, weak tableView] isContentWarningComposing in -// guard let cell = cell else { return } -// guard let tableView = tableView else { return } -// -// // self size input cell -// cell.statusContentWarningEditorView.isHidden = !isContentWarningComposing -// cell.statusContentWarningEditorView.alpha = 0 -// UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { -// cell.statusContentWarningEditorView.alpha = 1 -// tableView.beginUpdates() -// tableView.endUpdates() -// } completion: { _ in -// // do nothing -// } -// } -// .store(in: &disposeBag) -// -// cell.contentWarningContent -// .removeDuplicates() -// .receive(on: DispatchQueue.main) -// .sink { [weak tableView, weak self] text in -// guard let self = self else { return } -// // bind input data -// self.composeStatusAttribute.contentWarningContent = text -// -// // self size input cell -// guard let tableView = tableView else { return } -// UIView.performWithoutAnimation { -// tableView.beginUpdates() -// tableView.endUpdates() -// } -// } -// .store(in: &cell.disposeBag) -// -// // configure custom emoji picker -// ComposeStatusSection.configureCustomEmojiPicker( -// viewModel: customEmojiPickerInputViewModel, -// customEmojiReplaceableTextInput: cell.metaText.textView, -// disposeBag: &disposeBag -// ) -// ComposeStatusSection.configureCustomEmojiPicker( -// viewModel: customEmojiPickerInputViewModel, -// customEmojiReplaceableTextInput: cell.statusContentWarningEditorView.textView, -// disposeBag: &disposeBag -// ) -// } -//} -// -//extension ComposeViewModel { -// private func bind( -// cell: ComposeStatusPollTableViewCell, -// tableView: UITableView -// ) { -// Publishers.CombineLatest( -// $isPollComposing, -// $pollOptionAttributes -// ) -// .receive(on: DispatchQueue.main) -// .sink { [weak self] isPollComposing, pollOptionAttributes in -// guard let self = self else { return } -// guard self.isViewAppeared else { return } -// -// let cell = self.composeStatusPollTableViewCell -// guard let dataSource = cell.dataSource else { return } -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// var items: [ComposeStatusPollItem] = [] -// if isPollComposing { -// for attribute in pollOptionAttributes { -// items.append(.pollOption(attribute: attribute)) -// } -// if pollOptionAttributes.count < self.maxPollOptions { -// items.append(.pollOptionAppendEntry) -// } -// items.append(.pollExpiresOption(attribute: self.pollExpiresOptionAttribute)) -// } -// snapshot.appendItems(items, toSection: .main) -// -// tableView.performBatchUpdates { -// if #available(iOS 15.0, *) { -// dataSource.apply(snapshot, animatingDifferences: false) -// } else { -// dataSource.apply(snapshot, animatingDifferences: true) -// } -// } -// } -// .store(in: &disposeBag) -// -// // bind delegate -// $pollOptionAttributes -// .sink { [weak self] pollAttributes in -// guard let self = self else { return } -// pollAttributes.forEach { $0.delegate = self } -// } -// .store(in: &disposeBag) -// } -//} -// -//extension ComposeViewModel { -// private func bind( -// cell: ComposeStatusAttachmentTableViewCell, -// tableView: UITableView -// ) { -// cell.collectionViewHeightDidUpdate -// .receive(on: DispatchQueue.main) -// .sink { [weak self] _ in -// guard let _ = self else { return } -// tableView.beginUpdates() -// tableView.endUpdates() -// } -// .store(in: &disposeBag) -// -// $attachmentServices -// .removeDuplicates() -// .receive(on: DispatchQueue.main) -// .sink { [weak self] attachmentServices in -// guard let self = self else { return } -// guard self.isViewAppeared else { return } -// -// let cell = self.composeStatusAttachmentTableViewCell -// guard let dataSource = cell.dataSource else { return } -// -// var snapshot = NSDiffableDataSourceSnapshot() -// snapshot.appendSections([.main]) -// let items = attachmentServices.map { ComposeStatusAttachmentItem.attachment(attachmentService: $0) } -// snapshot.appendItems(items, toSection: .main) -// -// if #available(iOS 15.0, *) { -// dataSource.applySnapshotUsingReloadData(snapshot) -// } else { -// dataSource.apply(snapshot, animatingDifferences: false) -// } -// } -// .store(in: &disposeBag) -// -// // setup attribute updater -// $attachmentServices -// .receive(on: DispatchQueue.main) -// .debounce(for: 0.3, scheduler: DispatchQueue.main) -// .sink { attachmentServices in -// // drive service upload state -// // make image upload in the queue -// for attachmentService in attachmentServices { -// // skip when prefix N task when task finish OR fail OR uploading -// guard let currentState = attachmentService.uploadStateMachine.currentState else { break } -// if currentState is MastodonAttachmentService.UploadState.Fail { -// continue -// } -// if currentState is MastodonAttachmentService.UploadState.Finish { -// continue -// } -// if currentState is MastodonAttachmentService.UploadState.Processing { -// continue -// } -// if currentState is MastodonAttachmentService.UploadState.Uploading { -// break -// } -// // trigger uploading one by one -// if currentState is MastodonAttachmentService.UploadState.Initial { -// attachmentService.uploadStateMachine.enter(MastodonAttachmentService.UploadState.Uploading.self) -// break -// } -// } -// } -// .store(in: &disposeBag) -// -// // bind delegate -// $attachmentServices -// .sink { [weak self] attachmentServices in -// guard let self = self else { return } -// attachmentServices.forEach { $0.delegate = self } -// } -// .store(in: &disposeBag) -// } -//} diff --git a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift b/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift deleted file mode 100644 index b9ed18c45..000000000 --- a/Mastodon/Scene/Compose/ComposeViewModel+PublishState.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// ComposeViewModel+PublishState.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-18. -// - -import os.log -import Foundation -import Combine -import CoreDataStack -import GameplayKit -import MastodonSDK - -//extension ComposeViewModel { -// class PublishState: GKState { -// weak var viewModel: ComposeViewModel? -// -// init(viewModel: ComposeViewModel) { -// 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?.publishStateMachinePublisher.value = self -// } -// } -//} - -//extension ComposeViewModel.PublishState { -// class Initial: ComposeViewModel.PublishState { -// override func isValidNextState(_ stateClass: AnyClass) -> Bool { -// return stateClass == Publishing.self -// } -// } -// -// class Publishing: ComposeViewModel.PublishState { -// -// var publishingSubscription: AnyCancellable? -// -// override func isValidNextState(_ stateClass: AnyClass) -> Bool { -// return stateClass == Fail.self || stateClass == Finish.self -// } -// -// override func didEnter(from previousState: GKState?) { -// super.didEnter(from: previousState) -// guard let viewModel = viewModel, let stateMachine = stateMachine else { return } -// -// viewModel.updatePublishDate() -// -// let authenticationBox = viewModel.authenticationBox -// let domain = authenticationBox.domain -// let attachmentServices = viewModel.attachmentServices -// let mediaIDs = attachmentServices.compactMap { attachmentService in -// attachmentService.attachment.value?.id -// } -// let pollOptions: [String]? = { -// guard viewModel.isPollComposing else { return nil } -// return viewModel.pollOptionAttributes.map { attribute in attribute.option.value } -// }() -// let pollExpiresIn: Int? = { -// guard viewModel.isPollComposing else { return nil } -// return viewModel.pollExpiresOptionAttribute.expiresOption.value.seconds -// }() -// let inReplyToID: Mastodon.Entity.Status.ID? = { -// guard case let .reply(status) = viewModel.composeKind else { return nil } -// var id: Mastodon.Entity.Status.ID? -// viewModel.context.managedObjectContext.performAndWait { -// guard let replyTo = status.object(in: viewModel.context.managedObjectContext) else { return } -// id = replyTo.id -// } -// return id -// }() -// let sensitive: Bool = viewModel.isContentWarningComposing -// let spoilerText: String? = { -// let text = viewModel.composeStatusAttribute.contentWarningContent.trimmingCharacters(in: .whitespacesAndNewlines) -// guard !text.isEmpty else { -// return nil -// } -// return text -// }() -// let visibility = viewModel.selectedStatusVisibility.visibility -// -// let updateMediaQuerySubscriptions: [AnyPublisher, Error>] = { -// var subscriptions: [AnyPublisher, Error>] = [] -// for attachmentService in attachmentServices { -// guard let attachmentID = attachmentService.attachment.value?.id else { continue } -// let description = attachmentService.description.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" -// guard !description.isEmpty else { continue } -// let query = Mastodon.API.Media.UpdateMediaQuery( -// file: nil, -// thumbnail: nil, -// description: description, -// focus: nil -// ) -// let subscription = viewModel.context.apiService.updateMedia( -// domain: domain, -// attachmentID: attachmentID, -// query: query, -// mastodonAuthenticationBox: authenticationBox -// ) -// subscriptions.append(subscription) -// } -// return subscriptions -// }() -// -// let idempotencyKey = viewModel.idempotencyKey.value -// -// publishingSubscription = Publishers.MergeMany(updateMediaQuerySubscriptions) -// .collect() -// .asyncMap { attachments -> Mastodon.Response.Content in -// let query = Mastodon.API.Statuses.PublishStatusQuery( -// status: viewModel.composeStatusAttribute.composeContent, -// mediaIDs: mediaIDs.isEmpty ? nil : mediaIDs, -// pollOptions: pollOptions, -// pollExpiresIn: pollExpiresIn, -// inReplyToID: inReplyToID, -// sensitive: sensitive, -// spoilerText: spoilerText, -// visibility: visibility -// ) -// return try await viewModel.context.apiService.publishStatus( -// domain: domain, -// idempotencyKey: idempotencyKey, -// query: query, -// authenticationBox: authenticationBox -// ) -// } -// .receive(on: DispatchQueue.main) -// .sink { completion in -// switch completion { -// case .failure(let error): -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) -// stateMachine.enter(Fail.self) -// case .finished: -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: publish status success", ((#file as NSString).lastPathComponent), #line, #function) -// stateMachine.enter(Finish.self) -// } -// } receiveValue: { response in -// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: status %s published: %s", ((#file as NSString).lastPathComponent), #line, #function, response.value.id, response.value.uri) -// } -// } -// } -// -// class Fail: ComposeViewModel.PublishState { -// override func isValidNextState(_ stateClass: AnyClass) -> Bool { -// // allow discard publishing -// return stateClass == Publishing.self || stateClass == Discard.self -// } -// } -// -// class Discard: ComposeViewModel.PublishState { -// override func isValidNextState(_ stateClass: AnyClass) -> Bool { -// return false -// } -// } -// -// class Finish: ComposeViewModel.PublishState { -// override func isValidNextState(_ stateClass: AnyClass) -> Bool { -// return false -// } -// } -// -//} diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 5e5fbb1c3..bf234b095 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -18,7 +18,7 @@ import MastodonLocalization import MastodonMeta import MastodonUI -final class ComposeViewModel: NSObject { +final class ComposeViewModel { let logger = Logger(subsystem: "ComposeViewModel", category: "ViewModel") @@ -30,84 +30,13 @@ final class ComposeViewModel: NSObject { let context: AppContext let authContext: AuthContext let kind: ComposeContentViewModel.Kind - -// var authenticationBox: MastodonAuthenticationBox { -// authContext.mastodonAuthenticationBox -// } -// -// @Published var isPollComposing = false -// @Published var isCustomEmojiComposing = false -// @Published var isContentWarningComposing = false -// -// @Published var selectedStatusVisibility: ComposeToolbarView.VisibilitySelectionType -// @Published var repliedToCellFrame: CGRect = .zero let traitCollectionDidChangePublisher = CurrentValueSubject(Void()) // use CurrentValueSubject to make initial event emit -// var isViewAppeared = false // output -// let instanceConfiguration: Mastodon.Entity.Instance.Configuration? -// var composeContentLimit: Int { -// guard let maxCharacters = instanceConfiguration?.statuses?.maxCharacters else { return 500 } -// return max(1, maxCharacters) -// } -// var maxMediaAttachments: Int { -// guard let maxMediaAttachments = instanceConfiguration?.statuses?.maxMediaAttachments else { -// return 4 -// } -// // FIXME: update timeline media preview UI -// return min(4, max(1, maxMediaAttachments)) -// // return max(1, maxMediaAttachments) -// } -// var maxPollOptions: Int { -// guard let maxOptions = instanceConfiguration?.polls?.maxOptions else { return 4 } -// return max(2, maxOptions) -// } -// -// let composeStatusAttribute = ComposeStatusItem.ComposeStatusAttribute() -// let composeStatusContentTableViewCell = ComposeStatusContentTableViewCell() -// let composeStatusAttachmentTableViewCell = ComposeStatusAttachmentTableViewCell() -// let composeStatusPollTableViewCell = ComposeStatusPollTableViewCell() -// -// // var dataSource: UITableViewDiffableDataSource? -// var customEmojiPickerDiffableDataSource: UICollectionViewDiffableDataSource? -// private(set) lazy var publishStateMachine: GKStateMachine = { -// // exclude timeline middle fetcher state -// let stateMachine = GKStateMachine(states: [ -// PublishState.Initial(viewModel: self), -// PublishState.Publishing(viewModel: self), -// PublishState.Fail(viewModel: self), -// PublishState.Discard(viewModel: self), -// PublishState.Finish(viewModel: self), -// ]) -// stateMachine.enter(PublishState.Initial.self) -// return stateMachine -// }() -// private(set) lazy var publishStateMachinePublisher = CurrentValueSubject(nil) -// private(set) var publishDate = Date() // update it when enter Publishing state -// -// // TODO: group post material into Hashable class -// var idempotencyKey = CurrentValueSubject(UUID().uuidString) -// -// // UI & UX -// @Published var title: String -// @Published var shouldDismiss = true -// @Published var isPublishBarButtonItemEnabled = false -// @Published var isMediaToolbarButtonEnabled = true -// @Published var isPollToolbarButtonEnabled = true -// @Published var characterCount = 0 -// @Published var collectionViewState: CollectionViewState = .fold -// -// // for hashtag: "# " -// // for mention: "@ " -// var preInsertedContent: String? -// -// // attachment -// @Published var attachmentServices: [MastodonAttachmentService] = [] -// -// // polls -// @Published var pollOptionAttributes: [ComposeStatusPollItem.PollOptionAttribute] = [] -// let pollExpiresOptionAttribute = ComposeStatusPollItem.PollExpiresOptionAttribute() + + // UI & UX + @Published var title: String init( context: AppContext, @@ -117,63 +46,14 @@ final class ComposeViewModel: NSObject { self.context = context self.authContext = authContext self.kind = kind + // end init -// self.title = { -// switch composeKind { -// case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost -// case .reply: return L10n.Scene.Compose.Title.newReply -// } -// }() -// self.selectedStatusVisibility = { -// // default private when user locked -// var visibility: ComposeToolbarView.VisibilitySelectionType = { -// guard let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user -// else { -// return .public -// } -// return author.locked ? .private : .public -// }() -// // set visibility for reply post -// switch composeKind { -// case .reply(let record): -// context.managedObjectContext.performAndWait { -// guard let status = record.object(in: context.managedObjectContext) else { -// assertionFailure() -// return -// } -// let repliedStatusVisibility = status.visibility -// switch repliedStatusVisibility { -// case .public, .unlisted: -// // keep default -// break -// case .private: -// visibility = .private -// case .direct: -// visibility = .direct -// case ._other: -// assertionFailure() -// break -// } -// } -// default: -// break -// } -// return visibility -// }() -// // set limit -// self.instanceConfiguration = { -// var configuration: Mastodon.Entity.Instance.Configuration? = nil -// context.managedObjectContext.performAndWait { -// guard let authentication = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext) else { return } -// configuration = authentication.instance?.configuration -// } -// return configuration -// }() -// self.customEmojiViewModel = context.emojiService.dequeueCustomEmojiViewModel(for: authContext.mastodonAuthenticationBox.domain) -// super.init() -// // end init -// -// setup(cell: composeStatusContentTableViewCell) + self.title = { + switch kind { + case .post, .hashtag, .mention: return L10n.Scene.Compose.Title.newPost + case .reply: return L10n.Scene.Compose.Title.newReply + } + }() } deinit { @@ -181,194 +61,3 @@ final class ComposeViewModel: NSObject { } } - -extension ComposeViewModel { -// func createNewPollOptionIfPossible() { -// guard pollOptionAttributes.count < maxPollOptions else { return } -// -// let attribute = ComposeStatusPollItem.PollOptionAttribute() -// pollOptionAttributes = pollOptionAttributes + [attribute] -// } -// -// func updatePublishDate() { -// publishDate = Date() -// } -} - -//extension ComposeViewModel { -// -// enum AttachmentPrecondition: Error, LocalizedError { -// case videoAttachWithPhoto -// case moreThanOneVideo -// -// var errorDescription: String? { -// return L10n.Common.Alerts.PublishPostFailure.title -// } -// -// var failureReason: String? { -// switch self { -// case .videoAttachWithPhoto: -// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto -// case .moreThanOneVideo: -// return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo -// } -// } -// } -// -// // check exclusive limit: -// // - up to 1 video -// // - up to N photos -// func checkAttachmentPrecondition() throws { -// let attachmentServices = self.attachmentServices -// guard !attachmentServices.isEmpty else { return } -// var photoAttachmentServices: [MastodonAttachmentService] = [] -// var videoAttachmentServices: [MastodonAttachmentService] = [] -// attachmentServices.forEach { service in -// guard let file = service.file.value else { -// assertionFailure() -// return -// } -// switch file { -// case .jpeg, .png, .gif: -// photoAttachmentServices.append(service) -// case .other: -// videoAttachmentServices.append(service) -// } -// } -// -// if !videoAttachmentServices.isEmpty { -// guard videoAttachmentServices.count == 1 else { -// throw AttachmentPrecondition.moreThanOneVideo -// } -// guard photoAttachmentServices.isEmpty else { -// throw AttachmentPrecondition.videoAttachWithPhoto -// } -// } -// } -// -//} -// -//// MARK: - MastodonAttachmentServiceDelegate -//extension ComposeViewModel: MastodonAttachmentServiceDelegate { -// func mastodonAttachmentService(_ service: MastodonAttachmentService, uploadStateDidChange state: MastodonAttachmentService.UploadState?) { -// // trigger new output event -// attachmentServices = attachmentServices -// } -//} -// -//// MARK: - ComposePollAttributeDelegate -//extension ComposeViewModel: ComposePollAttributeDelegate { -// func composePollAttribute(_ attribute: ComposeStatusPollItem.PollOptionAttribute, pollOptionDidChange: String?) { -// // trigger update -// pollOptionAttributes = pollOptionAttributes -// } -//} -// -//extension ComposeViewModel { -// private func setup( -// cell: ComposeStatusContentTableViewCell -// ) { -// setupStatusHeader(cell: cell) -// setupStatusAuthor(cell: cell) -// setupStatusContent(cell: cell) -// } -// -// private func setupStatusHeader( -// cell: ComposeStatusContentTableViewCell -// ) { -// // configure header -// let managedObjectContext = context.managedObjectContext -// managedObjectContext.performAndWait { -// guard case let .reply(record) = self.composeKind, -// let replyTo = record.object(in: managedObjectContext) -// else { -// cell.statusView.viewModel.header = .none -// return -// } -// -// let info: StatusView.ViewModel.Header.ReplyInfo -// do { -// let content = MastodonContent( -// content: replyTo.author.displayNameWithFallback, -// emojis: replyTo.author.emojis.asDictionary -// ) -// let metaContent = try MastodonMetaContent.convert(document: content) -// info = .init(header: metaContent) -// } catch { -// let metaContent = PlaintextMetaContent(string: replyTo.author.displayNameWithFallback) -// info = .init(header: metaContent) -// } -// cell.statusView.viewModel.header = .reply(info: info) -// } -// } -// -// private func setupStatusAuthor( -// cell: ComposeStatusContentTableViewCell -// ) { -// self.context.managedObjectContext.performAndWait { -// guard let author = authenticationBox.authenticationRecord.object(in: self.context.managedObjectContext)?.user else { return } -// cell.statusView.configureAuthor(author: author) -// } -// } -// -// private func setupStatusContent( -// cell: ComposeStatusContentTableViewCell -// ) { -// switch composeKind { -// case .reply(let record): -// context.managedObjectContext.performAndWait { -// guard let status = record.object(in: context.managedObjectContext) else { return } -// let author = self.authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user -// -// var mentionAccts: [String] = [] -// if author?.id != status.author.id { -// mentionAccts.append("@" + status.author.acct) -// } -// let mentions = status.mentions -// .filter { author?.id != $0.id } -// for mention in mentions { -// let acct = "@" + mention.acct -// guard !mentionAccts.contains(acct) else { continue } -// mentionAccts.append(acct) -// } -// for acct in mentionAccts { -// UITextChecker.learnWord(acct) -// } -// if let spoilerText = status.spoilerText, !spoilerText.isEmpty { -// self.isContentWarningComposing = true -// self.composeStatusAttribute.contentWarningContent = spoilerText -// } -// -// let initialComposeContent = mentionAccts.joined(separator: " ") -// let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " -// self.preInsertedContent = preInsertedContent -// self.composeStatusAttribute.composeContent = preInsertedContent -// } -// case .hashtag(let hashtag): -// let initialComposeContent = "#" + hashtag -// UITextChecker.learnWord(initialComposeContent) -// let preInsertedContent = initialComposeContent + " " -// self.preInsertedContent = preInsertedContent -// self.composeStatusAttribute.composeContent = preInsertedContent -// case .mention(let record): -// context.managedObjectContext.performAndWait { -// guard let user = record.object(in: context.managedObjectContext) else { return } -// let initialComposeContent = "@" + user.acct -// UITextChecker.learnWord(initialComposeContent) -// let preInsertedContent = initialComposeContent + " " -// self.preInsertedContent = preInsertedContent -// self.composeStatusAttribute.composeContent = preInsertedContent -// } -// case .post: -// self.preInsertedContent = nil -// } -// -// // configure content warning -// if let composeContent = composeStatusAttribute.composeContent { -// cell.metaText.textView.text = composeContent -// } -// -// // configure content warning -// cell.statusContentWarningEditorView.textView.text = composeStatusAttribute.contentWarningContent -// } -//} diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift index 57f1d6b95..9a0f58f47 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Attachment/AttachmentViewModel.swift @@ -57,7 +57,6 @@ final public class AttachmentViewModel: NSObject, ObservableObject, Identifiable @Published public private(set) var thumbnail: UIImage? // original size image thumbnail @Published public private(set) var outputSizeInByte: Int64 = 0 - @MainActor @Published public private(set) var uploadState: UploadState = .none @Published public private(set) var uploadResult: UploadResult? @Published var error: Error? diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift index 54fc6e67a..bab22b7ba 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewController.swift @@ -14,6 +14,8 @@ import MastodonCore public final class ComposeContentViewController: UIViewController { + static let minAutoCompleteVisibleHeight: CGFloat = 100 + let logger = Logger(subsystem: "ComposeContentViewController", category: "ViewController") var disposeBag = Set() @@ -40,7 +42,6 @@ public final class ComposeContentViewController: UIViewController { }() // toolbar - lazy var composeContentToolbarView = ComposeContentToolbarView(viewModel: composeContentToolbarViewModel) var composeContentToolbarViewBottomLayoutConstraint: NSLayoutConstraint! let composeContentToolbarBackgroundView = UIView() @@ -146,49 +147,42 @@ extension ComposeContentViewController { ]) // bind keyboard - let keyboardHasShortcutBar = CurrentValueSubject(traitCollection.userInterfaceIdiom == .pad) // update default value later let keyboardEventPublishers = Publishers.CombineLatest3( KeyboardResponderService.shared.isShow, KeyboardResponderService.shared.state, KeyboardResponderService.shared.endFrame ) -// Publishers.CombineLatest3( -// viewModel.$isCustomEmojiComposing, -// ) - keyboardEventPublishers - .sink(receiveValue: { [weak self] keyboardEvents in + Publishers.CombineLatest3( + keyboardEventPublishers, + viewModel.$isEmojiActive, + viewModel.$autoCompleteInfo + ) + .sink(receiveValue: { [weak self] keyboardEvents, isEmojiActive, autoCompleteInfo in guard let self = self else { return } let (isShow, state, endFrame) = keyboardEvents - -// switch self.traitCollection.userInterfaceIdiom { -// case .pad: -// keyboardHasShortcutBar.value = state != .floating -// default: -// keyboardHasShortcutBar.value = false -// } -// + let extraMargin: CGFloat = { var margin = ComposeContentToolbarView.toolbarHeight -// if autoCompleteInfo != nil { -//// margin += ComposeViewController.minAutoCompleteVisibleHeight -// } + if autoCompleteInfo != nil { + margin += ComposeContentViewController.minAutoCompleteVisibleHeight + } return margin }() -// + guard isShow, state == .dock else { self.tableView.contentInset.bottom = extraMargin self.tableView.verticalScrollIndicatorInsets.bottom = extraMargin -// if let superView = self.autoCompleteViewController.tableView.superview { -// let autoCompleteTableViewBottomInset: CGFloat = { -// let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil) -// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY -// return max(0, padding) -// }() -// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset -// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset -// } + if let superView = self.autoCompleteViewController.tableView.superview { + let autoCompleteTableViewBottomInset: CGFloat = { + let tableViewFrameInWindow = superView.convert(self.autoCompleteViewController.tableView.frame, to: nil) + let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - self.view.frame.maxY + return max(0, padding) + }() + self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset + self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset + } UIView.animate(withDuration: 0.3) { self.composeContentToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom @@ -199,17 +193,16 @@ extension ComposeContentViewController { return } // isShow AND dock state -// self.systemKeyboardHeight = endFrame.height // adjust inset for auto-complete -// let autoCompleteTableViewBottomInset: CGFloat = { -// guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero } -// let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil) -// let padding = tableViewFrameInWindow.maxY + self.composeToolbarView.frame.height + AutoCompleteViewController.chevronViewHeight - endFrame.minY -// return max(0, padding) -// }() -// self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset -// self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset + let autoCompleteTableViewBottomInset: CGFloat = { + guard let superview = self.autoCompleteViewController.tableView.superview else { return .zero } + let tableViewFrameInWindow = superview.convert(self.autoCompleteViewController.tableView.frame, to: nil) + let padding = tableViewFrameInWindow.maxY + ComposeContentToolbarView.toolbarHeight + AutoCompleteViewController.chevronViewHeight - endFrame.minY + return max(0, padding) + }() + self.autoCompleteViewController.tableView.contentInset.bottom = autoCompleteTableViewBottomInset + self.autoCompleteViewController.tableView.verticalScrollIndicatorInsets.bottom = autoCompleteTableViewBottomInset // adjust inset for tableView let contentFrame = self.view.convert(self.tableView.frame, to: nil) @@ -289,6 +282,15 @@ extension ComposeContentViewController { // bind toolbar bindToolbarViewModel() + + // bind attachment picker + viewModel.$attachmentViewModels + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.resetImagePicker() + } + .store(in: &disposeBag) } public override func viewDidLayoutSubviews() { @@ -327,6 +329,8 @@ extension ComposeContentViewController { } private func bindToolbarViewModel() { + viewModel.$isAttachmentButtonEnabled.assign(to: &composeContentToolbarViewModel.$isAttachmentButtonEnabled) + viewModel.$isPollButtonEnabled.assign(to: &composeContentToolbarViewModel.$isPollButtonEnabled) viewModel.$isPollActive.assign(to: &composeContentToolbarViewModel.$isPollActive) viewModel.$isEmojiActive.assign(to: &composeContentToolbarViewModel.$isEmojiActive) viewModel.$isContentWarningActive.assign(to: &composeContentToolbarViewModel.$isContentWarningActive) @@ -345,6 +349,18 @@ extension ComposeContentViewController { autoCompleteViewController.view.frame.size.width = view.frame.width } } + + private func resetImagePicker() { + let selectionLimit = max(1, viewModel.maxMediaAttachmentLimit - viewModel.attachmentViewModels.count) + let configuration = ComposeContentViewController.createPhotoLibraryPickerConfiguration(selectionLimit: selectionLimit) + photoLibraryPicker = createImagePicker(configuration: configuration) + } + + private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController { + let imagePicker = PHPickerViewController(configuration: configuration) + imagePicker.delegate = self + return imagePicker + } } // MARK: - UIScrollViewDelegate diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift index ad9dfa1d8..cdf92ec26 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel.swift @@ -14,6 +14,7 @@ import MetaTextKit import MastodonMeta import MastodonCore import MastodonSDK +import MastodonLocalization public protocol ComposeContentViewModelDelegate: AnyObject { func composeContentViewModel(_ viewModel: ComposeContentViewModel, handleAutoComplete info: ComposeContentViewModel.AutoCompleteInfo) -> Bool @@ -58,6 +59,8 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { customEmojiPickerInputViewModel.configure(textInput: textView) } } + // for hashtag: "# " + // for mention: "@ " @Published public var initialContent = "" @Published public var content = "" @Published public var contentWeightedLength = 0 @@ -115,6 +118,14 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { @Published var contentCellFrame: CGRect = .zero @Published var contentTextViewFrame: CGRect = .zero @Published var scrollViewState: ScrollViewState = .fold + + @Published var characterCount: Int = 0 + + @Published public private(set) var isPublishBarButtonItemEnabled = true + @Published var isAttachmentButtonEnabled = false + @Published var isPollButtonEnabled = false + + @Published public private(set) var shouldDismiss = true public init( context: AppContext, @@ -165,6 +176,70 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { super.init() // end init + // setup initial value + switch kind { + case .reply(let record): + context.managedObjectContext.performAndWait { + guard let status = record.object(in: context.managedObjectContext) else { + assertionFailure() + return + } + let author = authContext.mastodonAuthenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user + + var mentionAccts: [String] = [] + if author?.id != status.author.id { + mentionAccts.append("@" + status.author.acct) + } + let mentions = status.mentions + .filter { author?.id != $0.id } + for mention in mentions { + let acct = "@" + mention.acct + guard !mentionAccts.contains(acct) else { continue } + mentionAccts.append(acct) + } + for acct in mentionAccts { + UITextChecker.learnWord(acct) + } + if let spoilerText = status.spoilerText, !spoilerText.isEmpty { + self.isContentWarningActive = true + self.contentWarning = spoilerText + } + + let initialComposeContent = mentionAccts.joined(separator: " ") + let preInsertedContent: String? = initialComposeContent.isEmpty ? nil : initialComposeContent + " " + self.initialContent = preInsertedContent ?? "" + self.content = preInsertedContent ?? "" + } + case .hashtag(let hashtag): + let initialComposeContent = "#" + hashtag + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.initialContent = preInsertedContent + self.content = preInsertedContent + case .mention(let record): + context.managedObjectContext.performAndWait { + guard let user = record.object(in: context.managedObjectContext) else { return } + let initialComposeContent = "@" + user.acct + UITextChecker.learnWord(initialComposeContent) + let preInsertedContent = initialComposeContent + " " + self.initialContent = preInsertedContent + self.content = preInsertedContent + } + case .post: + break + } + + bind() + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ComposeContentViewModel { + private func bind() { // bind author $authContext .sink { [weak self] authContext in @@ -210,12 +285,129 @@ public final class ComposeContentViewModel: NSObject, ObservableObject { // bind emoji inputView $isEmojiActive.assign(to: &customEmojiPickerInputViewModel.$isCustomEmojiComposing) - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } + // bind toolbar + Publishers.CombineLatest3( + $isPollActive, + $attachmentViewModels, + $maxMediaAttachmentLimit + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isPollActive, attachmentViewModels, maxMediaAttachmentLimit in + guard let self = self else { return } + let shouldMediaDisable = isPollActive || attachmentViewModels.count >= maxMediaAttachmentLimit + let shouldPollDisable = attachmentViewModels.count > 0 + + self.isAttachmentButtonEnabled = !shouldMediaDisable + self.isPollButtonEnabled = !shouldPollDisable + } + .store(in: &disposeBag) + + // bind status content character count + Publishers.CombineLatest3( + $contentWeightedLength, + $contentWarningWeightedLength, + $isContentWarningActive + ) + .map { contentWeightedLength, contentWarningWeightedLength, isContentWarningActive -> Int in + var count = contentWeightedLength + if isContentWarningActive { + count += contentWarningWeightedLength + } + return count + } + .assign(to: &$characterCount) + + // bind compose bar button item UI state + let isComposeContentEmpty = $content + .map { $0.isEmpty } + let isComposeContentValid = Publishers.CombineLatest( + $characterCount, + $maxTextInputLimit + ) + .map { characterCount, maxTextInputLimit in + characterCount <= maxTextInputLimit + } + let isMediaEmpty = $attachmentViewModels + .map { $0.isEmpty } + let isMediaUploadAllSuccess = $attachmentViewModels + .map { attachmentViewModels in + return Publishers.MergeMany(attachmentViewModels.map { $0.$uploadState }) + .delay(for: 0.5, scheduler: DispatchQueue.main) // convert to outputs with delay. Due to @Published emit before changes + .map { _ in attachmentViewModels.map { $0.uploadState } } + } + .switchToLatest() + .map { outputs in + guard outputs.allSatisfy({ $0 == .finish }) else { return false } + return true + } + + isMediaUploadAllSuccess.sink { result in + print(result) + } + .store(in: &disposeBag) + + let isPollOptionsAllValid = $pollOptions + .map { options in + return Publishers.MergeMany(options.map { $0.$text }) + .delay(for: 0.5, scheduler: DispatchQueue.main) // convert to outputs with delay. Due to @Published emit before changes + .map { _ in options.map { $0.text } } + } + .switchToLatest() + .map { outputs in + return outputs.allSatisfy { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + } + + let isPublishBarButtonItemEnabledPrecondition1 = Publishers.CombineLatest4( + isComposeContentEmpty, + isComposeContentValid, + isMediaEmpty, + isMediaUploadAllSuccess + ) + .map { isComposeContentEmpty, isComposeContentValid, isMediaEmpty, isMediaUploadAllSuccess -> Bool in + if isMediaEmpty { + return isComposeContentValid && !isComposeContentEmpty + } else { + return isComposeContentValid && isMediaUploadAllSuccess + } + } + .eraseToAnyPublisher() + + let isPublishBarButtonItemEnabledPrecondition2 = Publishers.CombineLatest4( + isComposeContentEmpty, + isComposeContentValid, + $isPollActive, + isPollOptionsAllValid + ) + .map { isComposeContentEmpty, isComposeContentValid, isPollComposing, isPollOptionsAllValid -> Bool in + if isPollComposing { + return isComposeContentValid && !isComposeContentEmpty && isPollOptionsAllValid + } else { + return isComposeContentValid && !isComposeContentEmpty + } + } + .eraseToAnyPublisher() + + Publishers.CombineLatest( + isPublishBarButtonItemEnabledPrecondition1, + isPublishBarButtonItemEnabledPrecondition2 + ) + .map { $0 && $1 } + .assign(to: &$isPublishBarButtonItemEnabled) + + // bind modal dismiss state + $content + .receive(on: DispatchQueue.main) + .map { [weak self] content in + guard let self = self else { return } + if content.isEmpty { + return true + } + // if the trimmed content equal to initial content + return content.trimmingCharacters(in: .whitespacesAndNewlines) == self.initialContent + } + .assign(to: &$shouldDismiss) + } } extension ComposeContentViewModel { @@ -325,6 +517,58 @@ extension ComposeContentViewModel { } // end func publisher() } +extension ComposeContentViewModel { + public enum AttachmentPrecondition: Error, LocalizedError { + case videoAttachWithPhoto + case moreThanOneVideo + + public var errorDescription: String? { + return L10n.Common.Alerts.PublishPostFailure.title + } + + public var failureReason: String? { + switch self { + case .videoAttachWithPhoto: + return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.videoAttachWithPhoto + case .moreThanOneVideo: + return L10n.Common.Alerts.PublishPostFailure.AttachmentsMessage.moreThanOneVideo + } + } + } + + // check exclusive limit: + // - up to 1 video + // - up to N photos + public func checkAttachmentPrecondition() throws { + let attachmentViewModels = self.attachmentViewModels + guard !attachmentViewModels.isEmpty else { return } + var photoAttachmentViewModels: [AttachmentViewModel] = [] + var videoAttachmentViewModels: [AttachmentViewModel] = [] + attachmentViewModels.forEach { attachmentViewModel in + guard let output = attachmentViewModel.output else { + assertionFailure() + return + } + switch output { + case .image: + photoAttachmentViewModels.append(attachmentViewModel) + case .video: + videoAttachmentViewModels.append(attachmentViewModel) + } + } + + if !videoAttachmentViewModels.isEmpty { + guard videoAttachmentViewModels.count == 1 else { + throw AttachmentPrecondition.moreThanOneVideo + } + guard photoAttachmentViewModels.isEmpty else { + throw AttachmentPrecondition.videoAttachWithPhoto + } + } + } + +} + // MARK: - DeleteBackwardResponseTextFieldRelayDelegate extension ComposeContentViewModel: DeleteBackwardResponseTextFieldRelayDelegate { diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift index 5143bea35..fa409c114 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Poll/PollOptionTextField.swift @@ -39,7 +39,7 @@ public struct PollOptionTextField: UIViewRepresentable { textField.text = text textField.placeholder = { if index >= 0 { - return L10n.Scene.Compose.Poll.optionNumber(index) + return L10n.Scene.Compose.Poll.optionNumber(index + 1) } else { assertionFailure() return "" diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift similarity index 97% rename from MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift index 4a34c77d4..ee58bace4 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView+ViewModel.swift @@ -27,6 +27,9 @@ extension ComposeContentToolbarView { @Published var isEmojiActive = false @Published var isContentWarningActive = false + @Published var isAttachmentButtonEnabled = false + @Published var isPollButtonEnabled = false + @Published public var maxTextInputLimit = 500 @Published public var contentWeightedLength = 0 @Published public var contentWarningWeightedLength = 0 diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift similarity index 87% rename from MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift rename to MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift index 52026c636..7efb15340 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/View/ComposeContentToolbarView.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/Toolbar/ComposeContentToolbarView.swift @@ -44,7 +44,9 @@ struct ComposeContentToolbarView: View { } } label: { label(for: action) + .opacity(viewModel.isAttachmentButtonEnabled ? 1.0 : 0.5) } + .disabled(!viewModel.isAttachmentButtonEnabled) .frame(width: 48, height: 48) case .visibility: Menu { @@ -63,6 +65,16 @@ struct ComposeContentToolbarView: View { label(for: viewModel.visibility.image) } .frame(width: 48, height: 48) + case .poll: + Button { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))") + viewModel.delegate?.composeContentToolbarView(viewModel, toolbarItemDidPressed: action) + } label: { + label(for: action) + .opacity(viewModel.isPollButtonEnabled ? 1.0 : 0.5) + } + .disabled(!viewModel.isPollButtonEnabled) + .frame(width: 48, height: 48) default: Button { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): \(String(describing: action))")