diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 3e3fc4680..aff684185 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -1171,8 +1171,8 @@ DB789A2125F9F76D0071ACA0 /* CollectionViewCell */, DB789A0A25F9F2950071ACA0 /* ComposeViewController.swift */, DB789A1125F9F2CC0071ACA0 /* ComposeViewModel.swift */, - DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, DB66728B25F9F8DC00D60309 /* ComposeViewModel+Diffable.swift */, + DB9A488926034D40008B817C /* ComposeViewModel+PublishState.swift */, ); path = Compose; sourceTree = ""; diff --git a/Mastodon/Diffiable/Section/ComposeStatusSection.swift b/Mastodon/Diffiable/Section/ComposeStatusSection.swift index 81efbee18..222c40246 100644 --- a/Mastodon/Diffiable/Section/ComposeStatusSection.swift +++ b/Mastodon/Diffiable/Section/ComposeStatusSection.swift @@ -85,15 +85,8 @@ extension ComposeStatusSection { UIView.animate(withDuration: 0.33, delay: 0, options: [.curveEaseOut]) { cell.statusContentWarningEditorView.alpha = 1 } completion: { _ in - if isContentWarningComposing { - cell.statusContentWarningEditorView.textView.becomeFirstResponder() - } // do nothing } - // restore responder if needs - if cell.statusContentWarningEditorView.textView.isFirstResponder { - cell.textEditorView.isEditing = true - } } .store(in: &cell.disposeBag) cell.contentWarningContent diff --git a/Mastodon/Generated/Assets.swift b/Mastodon/Generated/Assets.swift index 366c70649..8bf3b168b 100644 --- a/Mastodon/Generated/Assets.swift +++ b/Mastodon/Generated/Assets.swift @@ -75,10 +75,10 @@ internal enum Asset { internal static let buttonDefault = ColorAsset(name: "Colors/buttonDefault") internal static let buttonDisabled = ColorAsset(name: "Colors/buttonDisabled") internal static let buttonInactive = ColorAsset(name: "Colors/buttonInactive") + internal static let danger = ColorAsset(name: "Colors/danger") internal static let lightAlertYellow = ColorAsset(name: "Colors/lightAlertYellow") internal static let lightBackground = ColorAsset(name: "Colors/lightBackground") internal static let lightBrandBlue = ColorAsset(name: "Colors/lightBrandBlue") - internal static let lightDangerRed = ColorAsset(name: "Colors/lightDangerRed") internal static let lightDarkGray = ColorAsset(name: "Colors/lightDarkGray") internal static let lightDisabled = ColorAsset(name: "Colors/lightDisabled") internal static let lightInactive = ColorAsset(name: "Colors/lightInactive") diff --git a/Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json b/Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json similarity index 100% rename from Mastodon/Resources/Assets.xcassets/Colors/lightDangerRed.colorset/Contents.json rename to Mastodon/Resources/Assets.xcassets/Colors/danger.colorset/Contents.json diff --git a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift index 712fea745..8846e56ed 100644 --- a/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift +++ b/Mastodon/Scene/Compose/CollectionViewCell/ComposeStatusPollOptionCollectionViewCell.swift @@ -10,6 +10,7 @@ import UIKit import Combine protocol ComposeStatusPollOptionCollectionViewCellDelegate: class { + func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) } @@ -132,6 +133,12 @@ extension ComposeStatusPollOptionCollectionViewCell: DeleteBackwardResponseTextF // MARK: - UITextFieldDelegate extension ComposeStatusPollOptionCollectionViewCell: UITextFieldDelegate { + + func textFieldDidBeginEditing(_ textField: UITextField) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + delegate?.composeStatusPollOptionCollectionViewCell(self, textFieldDidBeginEditing: textField) + } + func textFieldShouldReturn(_ textField: UITextField) -> Bool { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) if textField === pollOptionView.optionTextField { diff --git a/Mastodon/Scene/Compose/ComposeViewController.swift b/Mastodon/Scene/Compose/ComposeViewController.swift index 1de686201..5e3a6a8a9 100644 --- a/Mastodon/Scene/Compose/ComposeViewController.swift +++ b/Mastodon/Scene/Compose/ComposeViewController.swift @@ -10,6 +10,7 @@ import UIKit import Combine import PhotosUI import Kingfisher +import MastodonSDK import TwitterTextEditor final class ComposeViewController: UIViewController, NeedsDependency { @@ -102,14 +103,18 @@ final class ComposeViewController: UIViewController, NeedsDependency { return documentPickerController }() + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ComposeViewController { private static func createLayout() -> UICollectionViewLayout { - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(100)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44)) + let group = NSCollectionLayoutGroup.vertical(layoutSize: groupSize, subitems: [item]) let section = NSCollectionLayoutSection(group: group) section.contentInsetsReference = .readableContent // section.interGroupSpacing = 10 @@ -232,22 +237,61 @@ extension ComposeViewController { }) .store(in: &disposeBag) + // bind publish bar button state viewModel.isPublishBarButtonItemEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: publishBarButtonItem) .store(in: &disposeBag) + // bind media button toolbar state viewModel.isMediaToolbarButtonEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeToolbarView.mediaButton) .store(in: &disposeBag) + // bind poll button toolbar state viewModel.isPollToolbarButtonEnabled .receive(on: DispatchQueue.main) .assign(to: \.isEnabled, on: composeToolbarView.pollButton) .store(in: &disposeBag) - // bind custom emojis + // bind image picker toolbar state + viewModel.attachmentServices + .receive(on: DispatchQueue.main) + .sink { [weak self] attachmentServices in + guard let self = self else { return } + self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 + self.resetImagePicker() + } + .store(in: &disposeBag) + + // bind visibility toolbar UI + viewModel.selectedStatusVisibility + .receive(on: DispatchQueue.main) + .sink { [weak self] type in + guard let self = self else { return } + self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal) + } + .store(in: &disposeBag) + + viewModel.characterCount + .receive(on: DispatchQueue.main) + .sink { [weak self] characterCount in + guard let self = self else { return } + let count = ComposeViewModel.composeContentLimit - characterCount + self.composeToolbarView.characterCountLabel.text = "\(count)" + switch count { + case _ where count < 0: + self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 24, weight: .bold) + self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.danger.color + default: + self.composeToolbarView.characterCountLabel.font = .systemFont(ofSize: 15, weight: .regular) + self.composeToolbarView.characterCountLabel.textColor = Asset.Colors.Label.secondary.color + } + } + .store(in: &disposeBag) + + // bind text editor for custom emojis update event viewModel.customEmojiViewModel .compactMap { $0?.emojis } .switchToLatest() @@ -261,22 +305,24 @@ extension ComposeViewController { }) .store(in: &disposeBag) - // bind image picker toolbar state - viewModel.attachmentServices + // bind custom emoji picker UI + viewModel.customEmojiViewModel .receive(on: DispatchQueue.main) - .sink { [weak self] attachmentServices in - guard let self = self else { return } - self.composeToolbarView.mediaButton.isEnabled = attachmentServices.count < 4 - self.resetImagePicker() + .map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in + guard let viewModel = viewModel else { + return Just([]).eraseToAnyPublisher() + } + return viewModel.emojis.eraseToAnyPublisher() } - .store(in: &disposeBag) - - viewModel.selectedStatusVisibility - .receive(on: DispatchQueue.main) - .sink { [weak self] type in + .switchToLatest() + .sink(receiveValue: { [weak self] emojis in guard let self = self else { return } - self.composeToolbarView.visibilityButton.setImage(type.image, for: .normal) - } + if emojis.isEmpty { + self.customEmojiPickerInputView.activityIndicatorView.startAnimating() + } else { + self.customEmojiPickerInputView.activityIndicatorView.stopAnimating() + } + }) .store(in: &disposeBag) } @@ -317,6 +363,25 @@ extension ComposeViewController { textEditorView()?.isEditing = true } + private func contentWarningEditorTextView() -> UITextView? { + guard let diffableDataSource = viewModel.diffableDataSource else { return nil } + let items = diffableDataSource.snapshot().itemIdentifiers + for item in items { + switch item { + case .input: + guard let indexPath = diffableDataSource.indexPath(for: item), + let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else { + continue + } + return cell.statusContentWarningEditorView.textView + default: + continue + } + } + + return nil + } + private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? { guard case .pollOption = item else { return nil } guard let diffableDataSource = viewModel.diffableDataSource else { return nil } @@ -398,7 +463,7 @@ extension ComposeViewController { imagePicker.delegate = self return imagePicker } - + } extension ComposeViewController { @@ -587,6 +652,15 @@ extension ComposeViewController: TextEditorViewTextAttributesDelegate { attributedString.addAttributes(attributes, range: match.range) } + if string.count > ComposeViewModel.composeContentLimit { + var attributes = [NSAttributedString.Key: Any]() + attributes[.foregroundColor] = Asset.Colors.danger.color + let boundStart = string.index(string.startIndex, offsetBy: ComposeViewModel.composeContentLimit) + let boundEnd = string.endIndex + let range = boundStart..() snapshot.appendSections([.repliedTo, .status, .attachment, .poll]) diff --git a/Mastodon/Scene/Compose/ComposeViewModel.swift b/Mastodon/Scene/Compose/ComposeViewModel.swift index 07570d796..036351d87 100644 --- a/Mastodon/Scene/Compose/ComposeViewModel.swift +++ b/Mastodon/Scene/Compose/ComposeViewModel.swift @@ -5,6 +5,7 @@ // Created by MainasuK Cirno on 2021-3-11. // +import os.log import UIKit import Combine import CoreData @@ -14,6 +15,8 @@ import MastodonSDK final class ComposeViewModel { + static let composeContentLimit: Int = 500 + var disposeBag = Set() // input @@ -48,11 +51,13 @@ final class ComposeViewModel { let isPublishBarButtonItemEnabled = CurrentValueSubject(false) let isMediaToolbarButtonEnabled = CurrentValueSubject(true) let isPollToolbarButtonEnabled = CurrentValueSubject(true) + let characterCount = CurrentValueSubject(0) // custom emojis var customEmojiViewModelSubscription: AnyCancellable? let customEmojiViewModel = CurrentValueSubject(nil) let customEmojiPickerInputViewModel = CustomEmojiPickerInputViewModel() + let isLoadingCustomEmoji = CurrentValueSubject(false) // attachment let attachmentServices = CurrentValueSubject<[MastodonAttachmentService], Never>([]) @@ -109,10 +114,30 @@ final class ComposeViewModel { } .store(in: &disposeBag) + // bind character count + Publishers.CombineLatest3( + composeStatusAttribute.composeContent.eraseToAnyPublisher(), + composeStatusAttribute.isContentWarningComposing.eraseToAnyPublisher(), + composeStatusAttribute.contentWarningContent.eraseToAnyPublisher() + ) + .map { composeContent, isContentWarningComposing, contentWarningContent -> Int in + let composeContent = composeContent ?? "" + var count = composeContent.count + if isContentWarningComposing { + count += contentWarningContent.count + } + return count + } + .assign(to: \.value, on: characterCount) + .store(in: &disposeBag) // bind compose bar button item UI state let isComposeContentEmpty = composeStatusAttribute.composeContent .map { ($0 ?? "").isEmpty } - let isComposeContentValid = Just(true).eraseToAnyPublisher() + let isComposeContentValid = composeStatusAttribute.composeContent + .map { composeContent -> Bool in + let composeContent = composeContent ?? "" + return composeContent.count <= ComposeViewModel.composeContentLimit + } let isMediaEmpty = attachmentServices .map { $0.isEmpty } let isMediaUploadAllSuccess = attachmentServices @@ -278,6 +303,10 @@ final class ComposeViewModel { .store(in: &disposeBag) } + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + } extension ComposeViewModel { @@ -301,6 +330,6 @@ extension ComposeViewModel: MastodonAttachmentServiceDelegate { extension ComposeViewModel: ComposePollAttributeDelegate { func composePollAttribute(_ attribute: ComposeStatusItem.ComposePollOptionAttribute, pollOptionDidChange: String?) { // trigger update - pollOptionAttributes.value = pollOptionAttributes.value + // pollOptionAttributes.value = pollOptionAttributes.value } } diff --git a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift index 8ba879c02..efe408265 100644 --- a/Mastodon/Scene/Compose/View/ComposeToolbarView.swift +++ b/Mastodon/Scene/Compose/View/ComposeToolbarView.swift @@ -59,6 +59,14 @@ final class ComposeToolbarView: UIView { return button }() + let characterCountLabel: UILabel = { + let label = UILabel() + label.font = .systemFont(ofSize: 15, weight: .regular) + label.text = "500" + label.textColor = Asset.Colors.Label.secondary.color + return label + }() + override init(frame: CGRect) { super.init(frame: frame) _init() @@ -102,6 +110,16 @@ extension ComposeToolbarView { ]) } + characterCountLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(characterCountLabel) + NSLayoutConstraint.activate([ + characterCountLabel.topAnchor.constraint(equalTo: topAnchor), + characterCountLabel.leadingAnchor.constraint(greaterThanOrEqualTo: stackView.trailingAnchor, constant: 8), + characterCountLabel.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + characterCountLabel.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + characterCountLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + mediaButton.menu = createMediaContextMenu() mediaButton.showsMenuAsPrimaryAction = true pollButton.addTarget(self, action: #selector(ComposeToolbarView.pollButtonDidPressed(_:)), for: .touchUpInside) diff --git a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift index 87b1ee481..6bfe31d34 100644 --- a/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift +++ b/Mastodon/Scene/Compose/View/CustomEmojiPickerInputView.swift @@ -17,6 +17,8 @@ final class CustomEmojiPickerInputView: UIInputView { return collectionView }() + let activityIndicatorView = UIActivityIndicatorView(style: .large) + override init(frame: CGRect, inputViewStyle: UIInputView.Style) { super.init(frame: frame, inputViewStyle: inputViewStyle) _init() @@ -33,6 +35,13 @@ extension CustomEmojiPickerInputView { private func _init() { allowsSelfSizing = true + activityIndicatorView.translatesAutoresizingMaskIntoConstraints = false + addSubview(activityIndicatorView) + NSLayoutConstraint.activate([ + activityIndicatorView.centerXAnchor.constraint(equalTo: centerXAnchor), + activityIndicatorView.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + collectionView.translatesAutoresizingMaskIntoConstraints = false addSubview(collectionView) NSLayoutConstraint.activate([ @@ -41,6 +50,9 @@ extension CustomEmojiPickerInputView { collectionView.trailingAnchor.constraint(equalTo: trailingAnchor), collectionView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) + + activityIndicatorView.hidesWhenStopped = true + activityIndicatorView.startAnimating() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift index dc7b8a47b..b14b42aa8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineNavigationBarView.swift @@ -15,7 +15,7 @@ final class HomeTimelineNavigationBarView { }() static let offlineView: UIView = { - let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.lightDangerRed.color) + let view = HomeTimelineNavigationBarView.backgroundViewWithColor(color: Asset.Colors.danger.color) let label = HomeTimelineNavigationBarView.contentLabel(text: L10n.Scene.HomeTimeline.NavigationBarState.offline) HomeTimelineNavigationBarView.addLabelToView(label: label, view: view) return view diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift index d66f9717c..04aea3c19 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewController.swift @@ -106,7 +106,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let usernameErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -146,7 +146,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let emailErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -177,7 +177,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let passwordErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() @@ -201,7 +201,7 @@ final class MastodonRegisterViewController: UIViewController, NeedsDependency, O let reasonErrorPromptLabel: UILabel = { let label = UILabel() - let color = Asset.Colors.lightDangerRed.color + let color = Asset.Colors.danger.color let font = UIFont.preferredFont(forTextStyle: .caption1) return label }() diff --git a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift index 7089aef7c..45b4599a9 100644 --- a/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift +++ b/Mastodon/Scene/Onboarding/Register/MastodonRegisterViewModel.swift @@ -198,10 +198,10 @@ extension MastodonRegisterViewModel { let attributeString = NSMutableAttributedString() let image = MastodonRegisterViewModel.xmarkImage(font: font) - attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.lightDangerRed.color)) + attributeString.append(attributedStringImage(with: image, tintColor: Asset.Colors.danger.color)) attributeString.append(NSAttributedString(string: " ")) - let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.lightDangerRed.color]) + let promptAttributedString = NSAttributedString(string: prompt, attributes: [NSAttributedString.Key.font: font, NSAttributedString.Key.foregroundColor: Asset.Colors.danger.color]) attributeString.append(promptAttributedString) return attributeString