mastodon-ios/Mastodon/Scene/Compose/ComposeViewController.swift

986 lines
46 KiB
Swift
Raw Normal View History

2021-03-11 08:41:27 +01:00
//
// ComposeViewController.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-3-11.
//
import os.log
import UIKit
import Combine
import PhotosUI
import Kingfisher
import MastodonSDK
import TwitterTextEditor
2021-03-11 08:41:27 +01:00
final class ComposeViewController: UIViewController, NeedsDependency {
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
var disposeBag = Set<AnyCancellable>()
var viewModel: ComposeViewModel!
private var suffixedAttachmentViews: [UIView] = []
2021-03-18 10:33:07 +01:00
let publishButton: UIButton = {
let button = RoundedEdgesButton(type: .custom)
button.setTitle(L10n.Scene.Compose.composeAction, for: .normal)
button.titleLabel?.font = .systemFont(ofSize: 14, weight: .bold)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color), for: .normal)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.normal.color.withAlphaComponent(0.5)), for: .highlighted)
button.setBackgroundImage(.placeholder(color: Asset.Colors.Button.disabled.color), for: .disabled)
button.setTitleColor(.white, for: .normal)
button.contentEdgeInsets = UIEdgeInsets(top: 5.5, left: 16, bottom: 5.5, right: 16) // set 28pt height
button.adjustsImageWhenHighlighted = false
2021-03-18 10:33:07 +01:00
return button
}()
private(set) lazy var publishBarButtonItem: UIBarButtonItem = {
let barButtonItem = UIBarButtonItem(customView: publishButton)
return barButtonItem
}()
let collectionView: UICollectionView = {
let collectionViewLayout = ComposeViewController.createLayout()
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionViewLayout)
2021-04-01 08:39:15 +02:00
collectionView.register(ComposeRepliedToStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeRepliedToStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusContentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusContentCollectionViewCell.self))
collectionView.register(ComposeStatusAttachmentCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusAttachmentCollectionViewCell.self))
2021-03-23 11:47:21 +01:00
collectionView.register(ComposeStatusPollOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionCollectionViewCell.self))
collectionView.register(ComposeStatusPollOptionAppendEntryCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollOptionAppendEntryCollectionViewCell.self))
collectionView.register(ComposeStatusPollExpiresOptionCollectionViewCell.self, forCellWithReuseIdentifier: String(describing: ComposeStatusPollExpiresOptionCollectionViewCell.self))
collectionView.backgroundColor = Asset.Scene.Compose.background.color
return collectionView
2021-03-11 08:41:27 +01:00
}()
2021-03-25 08:56:17 +01:00
var systemKeyboardHeight: CGFloat = .zero {
didSet {
// note: some system AutoLayout warning here
customEmojiPickerInputView.frame.size.height = systemKeyboardHeight != .zero ? systemKeyboardHeight : 300
}
}
// CustomEmojiPickerView
let customEmojiPickerInputView: CustomEmojiPickerInputView = {
let view = CustomEmojiPickerInputView(frame: CGRect(x: 0, y: 0, width: 0, height: 300), inputViewStyle: .keyboard)
return view
}()
let composeToolbarView = ComposeToolbarView()
2021-03-12 08:23:28 +01:00
var composeToolbarViewBottomLayoutConstraint: NSLayoutConstraint!
let composeToolbarBackgroundView = UIView()
2021-03-12 08:23:28 +01:00
private(set) lazy var imagePicker: PHPickerViewController = {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 4
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}()
private(set) lazy var imagePickerController: UIImagePickerController = {
let imagePickerController = UIImagePickerController()
imagePickerController.sourceType = .camera
imagePickerController.delegate = self
return imagePickerController
}()
private(set) lazy var documentPickerController: UIDocumentPickerViewController = {
2021-03-31 04:37:38 +02:00
let documentPickerController = UIDocumentPickerViewController(forOpeningContentTypes: [.image])
documentPickerController.delegate = self
return documentPickerController
}()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
2021-03-11 08:41:27 +01:00
}
extension ComposeViewController {
private static func createLayout() -> UICollectionViewLayout {
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(44))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
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
// section.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 10, bottom: 10, trailing: 10)
return UICollectionViewCompositionalLayout(section: section)
}
}
2021-03-11 08:41:27 +01:00
extension ComposeViewController {
override func viewDidLoad() {
super.viewDidLoad()
viewModel.title
.receive(on: DispatchQueue.main)
.sink { [weak self] title in
guard let self = self else { return }
self.title = title
}
.store(in: &disposeBag)
view.backgroundColor = Asset.Scene.Compose.background.color
2021-03-11 08:41:27 +01:00
navigationItem.leftBarButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ComposeViewController.cancelBarButtonItemPressed(_:)))
2021-03-18 10:33:07 +01:00
navigationItem.rightBarButtonItem = publishBarButtonItem
publishButton.addTarget(self, action: #selector(ComposeViewController.publishBarButtonItemPressed(_:)), for: .touchUpInside)
collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView)
2021-03-11 08:41:27 +01:00
NSLayoutConstraint.activate([
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
2021-03-11 08:41:27 +01:00
])
2021-03-12 08:23:28 +01:00
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),
2021-03-12 08:23:28 +01:00
])
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),
])
collectionView.delegate = self
viewModel.setupDiffableDataSource(
for: collectionView,
dependency: self,
2021-03-25 08:56:17 +01:00
customEmojiPickerInputViewModel: viewModel.customEmojiPickerInputViewModel,
textEditorViewTextAttributesDelegate: self,
2021-03-23 11:47:21 +01:00
composeStatusAttachmentTableViewCellDelegate: self,
composeStatusPollOptionCollectionViewCellDelegate: self,
composeStatusNewPollOptionCollectionViewCellDelegate: self,
composeStatusPollExpiresOptionCollectionViewCellDelegate: self
)
2021-03-24 08:46:40 +01:00
let longPressReorderGesture = UILongPressGestureRecognizer(target: self, action: #selector(ComposeViewController.longPressReorderGestureHandler(_:)))
collectionView.addGestureRecognizer(longPressReorderGesture)
2021-03-25 08:56:17 +01:00
customEmojiPickerInputView.collectionView.delegate = self
viewModel.customEmojiPickerInputViewModel.customEmojiPickerInputView = customEmojiPickerInputView
viewModel.setupCustomEmojiPickerDiffableDataSource(
for: customEmojiPickerInputView.collectionView,
dependency: self
)
2021-03-12 08:23:28 +01:00
// respond scrollView overlap change
view.layoutIfNeeded()
// update layout when keyboard show/dismiss
2021-03-25 08:56:17 +01:00
Publishers.CombineLatest4(
2021-03-12 08:23:28 +01:00
KeyboardResponderService.shared.isShow.eraseToAnyPublisher(),
KeyboardResponderService.shared.state.eraseToAnyPublisher(),
2021-03-25 08:56:17 +01:00
KeyboardResponderService.shared.endFrame.eraseToAnyPublisher(),
viewModel.isCustomEmojiComposing.eraseToAnyPublisher()
2021-03-12 08:23:28 +01:00
)
2021-03-25 08:56:17 +01:00
.sink(receiveValue: { [weak self] isShow, state, endFrame, isCustomEmojiComposing in
2021-03-12 08:23:28 +01:00
guard let self = self else { return }
let extraMargin: CGFloat = {
if self.view.safeAreaInsets.bottom == .zero {
// needs extra margin for zero inset device to workaround UIKit issue
return self.composeToolbarView.frame.height
} else {
// default some magic 16 extra margin
return 16
}
}()
// update keyboard background color
2021-03-12 08:23:28 +01:00
guard isShow, state == .dock else {
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
2021-03-12 08:23:28 +01:00
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
2021-03-12 08:23:28 +01:00
self.view.layoutIfNeeded()
}
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
2021-03-12 08:23:28 +01:00
return
}
// isShow AND dock state
2021-03-25 08:56:17 +01:00
self.systemKeyboardHeight = endFrame.height
let contentFrame = self.view.convert(self.collectionView.frame, to: nil)
2021-03-12 08:23:28 +01:00
let padding = contentFrame.maxY - endFrame.minY
guard padding > 0 else {
self.collectionView.contentInset.bottom = self.view.safeAreaInsets.bottom + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = self.view.safeAreaInsets.bottom + extraMargin
2021-03-12 08:23:28 +01:00
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = self.view.safeAreaInsets.bottom
2021-03-12 08:23:28 +01:00
self.view.layoutIfNeeded()
}
self.updateKeyboardBackground(isKeyboardDisplay: false)
2021-03-12 08:23:28 +01:00
return
}
self.collectionView.contentInset.bottom = padding + extraMargin
self.collectionView.verticalScrollIndicatorInsets.bottom = padding + extraMargin
2021-03-12 08:23:28 +01:00
UIView.animate(withDuration: 0.3) {
self.composeToolbarViewBottomLayoutConstraint.constant = padding
self.view.layoutIfNeeded()
}
self.updateKeyboardBackground(isKeyboardDisplay: isShow)
2021-03-12 08:23:28 +01:00
})
.store(in: &disposeBag)
// bind publish bar button state
2021-03-18 12:42:26 +01:00
viewModel.isPublishBarButtonItemEnabled
.receive(on: DispatchQueue.main)
2021-03-18 10:33:07 +01:00
.assign(to: \.isEnabled, on: publishBarButtonItem)
.store(in: &disposeBag)
2021-03-23 11:47:21 +01:00
// bind media button toolbar state
2021-03-23 11:47:21 +01:00
viewModel.isMediaToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.mediaButton)
.store(in: &disposeBag)
// bind poll button toolbar state
2021-03-23 11:47:21 +01:00
viewModel.isPollToolbarButtonEnabled
.receive(on: DispatchQueue.main)
.assign(to: \.isEnabled, on: composeToolbarView.pollButton)
.store(in: &disposeBag)
2021-03-18 10:33:07 +01:00
// 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
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.visibilityButton.setImage(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()
.sink(receiveValue: { [weak self] emojis in
guard let self = self else { return }
for emoji in emojis {
UITextChecker.learnWord(emoji.shortcode)
UITextChecker.learnWord(":" + emoji.shortcode + ":")
}
self.textEditorView()?.setNeedsUpdateTextAttributes()
})
.store(in: &disposeBag)
// bind custom emoji picker UI
viewModel.customEmojiViewModel
.receive(on: DispatchQueue.main)
.map { viewModel -> AnyPublisher<[Mastodon.Entity.Emoji], Never> in
guard let viewModel = viewModel else {
return Just([]).eraseToAnyPublisher()
}
return viewModel.emojis.eraseToAnyPublisher()
}
.switchToLatest()
.sink(receiveValue: { [weak self] emojis in
guard let self = self else { return }
if emojis.isEmpty {
self.customEmojiPickerInputView.activityIndicatorView.startAnimating()
} else {
self.customEmojiPickerInputView.activityIndicatorView.stopAnimating()
}
})
.store(in: &disposeBag)
2021-03-11 08:41:27 +01:00
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// Fix AutoLayout conflict issue
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.markTextEditorViewBecomeFirstResponser()
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
viewModel.traitCollectionDidChangePublisher.send()
}
}
extension ComposeViewController {
private func textEditorView() -> TextEditorView? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers
for item in items {
switch item {
2021-03-16 04:23:19 +01:00
case .input:
guard let indexPath = diffableDataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusContentCollectionViewCell else {
continue
}
return cell.textEditorView
default:
continue
}
}
return nil
}
private func markTextEditorViewBecomeFirstResponser() {
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
}
2021-03-23 11:47:21 +01:00
private func pollOptionCollectionViewCell(of item: ComposeStatusItem) -> ComposeStatusPollOptionCollectionViewCell? {
guard case .pollOption = item else { return nil }
2021-03-23 11:47:21 +01:00
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
guard let indexPath = diffableDataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? ComposeStatusPollOptionCollectionViewCell else {
return nil
}
return cell
}
private func firstPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
let firstPollItem = items.first { item -> Bool in
guard case .pollOption = item else { return false }
2021-03-23 11:47:21 +01:00
return true
}
guard let item = firstPollItem else {
return nil
}
return pollOptionCollectionViewCell(of: item)
}
private func lastPollOptionCollectionViewCell() -> ComposeStatusPollOptionCollectionViewCell? {
guard let diffableDataSource = viewModel.diffableDataSource else { return nil }
let items = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
let lastPollItem = items.last { item -> Bool in
guard case .pollOption = item else { return false }
2021-03-23 11:47:21 +01:00
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(
2021-03-15 06:42:46 +01:00
title: L10n.Common.Alerts.DiscardPostContent.title,
message: L10n.Common.Alerts.DiscardPostContent.message,
preferredStyle: .alert
)
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)
present(alertController, animated: true, completion: nil)
}
2021-03-18 10:33:07 +01:00
private func resetImagePicker() {
var configuration = PHPickerConfiguration()
configuration.filter = .images
let selectionLimit = max(1, 4 - viewModel.attachmentServices.value.count)
configuration.selectionLimit = selectionLimit
imagePicker = createImagePicker(configuration: configuration)
}
private func createImagePicker(configuration: PHPickerConfiguration) -> PHPickerViewController {
let imagePicker = PHPickerViewController(configuration: configuration)
imagePicker.delegate = self
return imagePicker
}
private func updateKeyboardBackground(isKeyboardDisplay: Bool) {
guard isKeyboardDisplay else {
composeToolbarBackgroundView.backgroundColor = Asset.Scene.Compose.toolbarBackground.color
return
}
composeToolbarBackgroundView.backgroundColor = UIColor(dynamicProvider: { traitCollection -> UIColor in
// avoid elevated color
switch traitCollection.userInterfaceStyle {
case .light: return .white
default: return .black
}
})
}
2021-03-11 08:41:27 +01:00
}
extension ComposeViewController {
@objc private func cancelBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard viewModel.shouldDismiss.value else {
showDismissConfirmAlertController()
return
}
2021-03-11 08:41:27 +01:00
dismiss(animated: true, completion: nil)
}
2021-03-18 10:33:07 +01:00
@objc private func publishBarButtonItemPressed(_ sender: UIBarButtonItem) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
guard viewModel.publishStateMachine.enter(ComposeViewModel.PublishState.Publishing.self) else {
// TODO: handle error
return
}
context.statusPublishService.publish(composeViewModel: viewModel)
2021-03-18 10:33:07 +01:00
dismiss(animated: true, completion: nil)
}
2021-03-24 08:46:40 +01:00
// seealso: ComposeViewModel.setupDiffableDataSource()
@objc private func longPressReorderGestureHandler(_ sender: UILongPressGestureRecognizer) {
switch(sender.state) {
case .began:
2021-03-24 08:46:40 +01:00
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let cell = collectionView.cellForItem(at: selectedIndexPath) as? ComposeStatusPollOptionCollectionViewCell else {
break
}
2021-03-24 08:46:40 +01:00
// check if pressing reorder bar no not
let locationInCell = sender.location(in: cell)
guard cell.reorderBarImageView.frame.contains(locationInCell) else {
return
}
collectionView.beginInteractiveMovementForItem(at: selectedIndexPath)
case .changed:
guard let selectedIndexPath = collectionView.indexPathForItem(at: sender.location(in: collectionView)),
let diffableDataSource = viewModel.diffableDataSource else {
break
}
guard let item = diffableDataSource.itemIdentifier(for: selectedIndexPath),
2021-03-24 08:46:40 +01:00
case .pollOption = item else {
collectionView.cancelInteractiveMovement()
return
}
2021-03-24 08:46:40 +01:00
var position = sender.location(in: collectionView)
position.x = collectionView.frame.width * 0.5
collectionView.updateInteractiveMovementTargetPosition(position)
case .ended:
collectionView.endInteractiveMovement()
default:
collectionView.cancelInteractiveMovement()
}
}
2021-03-11 08:41:27 +01:00
}
// MARK: - TextEditorViewTextAttributesDelegate
extension ComposeViewController: TextEditorViewTextAttributesDelegate {
func textEditorView(
_ textEditorView: TextEditorView,
updateAttributedString attributedString: NSAttributedString,
completion: @escaping (NSAttributedString?) -> Void
) {
DispatchQueue.global().async {
let string = attributedString.string
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: update: %s", ((#file as NSString).lastPathComponent), #line, #function, string)
let stringRange = NSRange(location: 0, length: string.length)
let highlightMatches = string.matches(pattern: "(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))")
// accept ^\B: or \s: but not accept \B: to force user input a space to make emoji take effect
// precondition :\B with following space
let emojiMatches = string.matches(pattern: "(?:(^\\B:|\\s:)([a-zA-Z0-9_]+)(:\\B(?=\\s)))")
// only accept http/https scheme
let urlMatches = string.matches(pattern: "(?i)https?://\\S+(?:/|\\b)")
DispatchQueue.main.async { [weak self] in
guard let self = self else {
completion(nil)
return
}
let customEmojiViewModel = self.viewModel.customEmojiViewModel.value
for view in self.suffixedAttachmentViews {
view.removeFromSuperview()
}
self.suffixedAttachmentViews.removeAll()
// set normal apperance
let attributedString = NSMutableAttributedString(attributedString: attributedString)
attributedString.removeAttribute(.suffixedAttachment, range: stringRange)
attributedString.removeAttribute(.underlineStyle, range: stringRange)
attributedString.addAttribute(.foregroundColor, value: Asset.Colors.Label.primary.color, range: stringRange)
attributedString.addAttribute(.font, value: UIFont.preferredFont(forTextStyle: .body), range: stringRange)
2021-03-17 11:17:44 +01:00
// hashtag
for match in highlightMatches {
// set highlight
var attributes = [NSAttributedString.Key: Any]()
attributes[.foregroundColor] = Asset.Colors.Label.highlight.color
2021-03-17 11:17:44 +01:00
// See `traitCollectionDidChange(_:)`
// set accessibility
if #available(iOS 13.0, *) {
switch self.traitCollection.accessibilityContrast {
case .high:
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
default:
break
}
}
attributedString.addAttributes(attributes, range: match.range)
}
2021-03-17 11:17:44 +01:00
// emoji
let emojis = customEmojiViewModel?.emojis.value ?? []
if !emojis.isEmpty {
for match in emojiMatches {
guard let name = string.substring(with: match, at: 2) else { continue }
guard let emoji = emojis.first(where: { $0.shortcode == name }) else { continue }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name)
// set emoji token invisiable (without upper bounce space)
var attributes = [NSAttributedString.Key: Any]()
attributes[.font] = UIFont.systemFont(ofSize: 0.01)
attributedString.addAttributes(attributes, range: match.range)
// append emoji attachment
let imageViewSize = CGSize(width: 20, height: 20)
let imageView = UIImageView(frame: CGRect(origin: .zero, size: imageViewSize))
textEditorView.textContentView.addSubview(imageView)
self.suffixedAttachmentViews.append(imageView)
let processor = DownsamplingImageProcessor(size: imageViewSize)
imageView.kf.setImage(
with: URL(string: emoji.url),
placeholder: UIImage.placeholder(size: imageViewSize, color: .systemFill),
options: [
.processor(processor),
.scaleFactor(textEditorView.traitCollection.displayScale),
], completionHandler: nil
)
let layoutInTextContainer = { [weak textEditorView] (view: UIView, frame: CGRect) in
// `textEditorView` retains `textStorage`, which retains this block as a part of attributes.
guard let textEditorView = textEditorView else {
return
}
let insets = textEditorView.textContentInsets
view.frame = frame.offsetBy(dx: insets.left, dy: insets.top)
}
let attachment = TextAttributes.SuffixedAttachment(
size: imageViewSize,
attachment: .view(view: imageView, layoutInTextContainer: layoutInTextContainer)
)
let index = match.range.upperBound - 1
attributedString.addAttribute(
.suffixedAttachment,
value: attachment,
range: NSRange(location: index, length: 1)
)
}
}
2021-03-17 11:17:44 +01:00
// url
for match in urlMatches {
2021-03-17 11:17:44 +01:00
guard let name = string.substring(with: match, at: 0) else { continue }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: handle emoji: %s", ((#file as NSString).lastPathComponent), #line, #function, name)
// set highlight
var attributes = [NSAttributedString.Key: Any]()
attributes[.foregroundColor] = Asset.Colors.Label.highlight.color
// See `traitCollectionDidChange(_:)`
// set accessibility
if #available(iOS 13.0, *) {
switch self.traitCollection.accessibilityContrast {
case .high:
attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
default:
break
}
}
2021-03-17 11:17:44 +01:00
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..<boundEnd
attributedString.addAttributes(attributes, range: NSRange(range, in: string))
}
completion(attributedString)
}
}
2021-03-11 08:41:27 +01:00
}
}
2021-03-12 08:23:28 +01:00
// MARK: - ComposeToolbarViewDelegate
extension ComposeViewController: ComposeToolbarViewDelegate {
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, cameraButtonDidPressed sender: UIButton, mediaSelectionType type: ComposeToolbarView.MediaSelectionType) {
switch type {
case .photoLibrary:
present(imagePicker, animated: true, completion: nil)
case .camera:
present(imagePickerController, animated: true, completion: nil)
case .browse:
present(documentPickerController, animated: true, completion: nil)
}
2021-03-12 08:23:28 +01:00
}
2021-03-23 11:47:21 +01:00
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, pollButtonDidPressed sender: UIButton) {
viewModel.isPollComposing.value.toggle()
// setup initial poll option if needs
2021-03-24 08:46:40 +01:00
if viewModel.isPollComposing.value, viewModel.pollOptionAttributes.value.isEmpty {
viewModel.pollOptionAttributes.value = [ComposeStatusItem.ComposePollOptionAttribute(), ComposeStatusItem.ComposePollOptionAttribute()]
2021-03-23 11:47:21 +01:00
}
if viewModel.isPollComposing.value {
// Magic RunLoop
DispatchQueue.main.async {
self.markFirstPollOptionCollectionViewCellBecomeFirstResponser()
}
} else {
markTextEditorViewBecomeFirstResponser()
}
2021-03-12 08:23:28 +01:00
}
2021-03-23 11:47:21 +01:00
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, emojiButtonDidPressed sender: UIButton) {
2021-03-25 08:56:17 +01:00
viewModel.isCustomEmojiComposing.value.toggle()
2021-03-12 08:23:28 +01:00
}
2021-03-23 11:47:21 +01:00
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, contentWarningButtonDidPressed sender: UIButton) {
// restore first responder for text editor when content warning dismiss
if viewModel.isContentWarningComposing.value {
if contentWarningEditorTextView()?.isFirstResponder == true {
markTextEditorViewBecomeFirstResponser()
}
}
// toggle composing status
viewModel.isContentWarningComposing.value.toggle()
// active content warning after toggled
if viewModel.isContentWarningComposing.value {
contentWarningEditorTextView()?.becomeFirstResponder()
}
2021-03-12 08:23:28 +01:00
}
func composeToolbarView(_ composeToolbarView: ComposeToolbarView, visibilityButtonDidPressed sender: UIButton, visibilitySelectionType type: ComposeToolbarView.VisibilitySelectionType) {
viewModel.selectedStatusVisibility.value = type
2021-03-12 08:23:28 +01:00
}
}
2021-03-11 08:41:27 +01:00
// MARK: - UITableViewDelegate
extension ComposeViewController: UICollectionViewDelegate {
2021-03-25 08:56:17 +01:00
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: select %s", ((#file as NSString).lastPathComponent), #line, #function, indexPath.debugDescription)
if collectionView === customEmojiPickerInputView.collectionView {
guard let diffableDataSource = viewModel.customEmojiPickerDiffableDataSource else { return }
let item = diffableDataSource.itemIdentifier(for: indexPath)
guard case let .emoji(attribute) = item else { return }
let emoji = attribute.emoji
let textEditorView = self.textEditorView()
// retrive active text input and insert emoji
// the leading and trailing space is REQUIRED to fix `UITextStorage` layout issue
let reference = viewModel.customEmojiPickerInputViewModel.insertText(" :\(emoji.shortcode): ")
// workaround: non-user interactive change do not trigger value update event
if reference?.value === textEditorView {
viewModel.composeStatusAttribute.composeContent.value = textEditorView?.text
// update text storage
textEditorView?.setNeedsUpdateTextAttributes()
// collection self-size
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.collectionView.collectionViewLayout.invalidateLayout()
}
}
} else {
// do nothing
}
}
2021-03-11 08:41:27 +01:00
}
2021-03-15 07:40:10 +01:00
// MARK: - UIAdaptivePresentationControllerDelegate
2021-03-11 08:41:27 +01:00
extension ComposeViewController: UIAdaptivePresentationControllerDelegate {
2021-03-11 08:41:27 +01:00
func presentationControllerShouldDismiss(_ presentationController: UIPresentationController) -> Bool {
return viewModel.shouldDismiss.value
}
func presentationControllerDidAttemptToDismiss(_ presentationController: UIPresentationController) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
showDismissConfirmAlertController()
2021-03-11 08:41:27 +01:00
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
// MARK: - PHPickerViewControllerDelegate
extension ComposeViewController: PHPickerViewControllerDelegate {
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true, completion: nil)
2021-03-18 12:42:26 +01:00
let attachmentServices: [MastodonAttachmentService] = results.map { result in
let service = MastodonAttachmentService(
context: context,
pickerResult: result,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
return service
}
viewModel.attachmentServices.value = viewModel.attachmentServices.value + attachmentServices
}
}
// MARK: - UIImagePickerControllerDelegate
extension ComposeViewController: UIImagePickerControllerDelegate & UINavigationControllerDelegate {
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
picker.dismiss(animated: true, completion: nil)
guard let image = info[.originalImage] as? UIImage else { return }
let attachmentService = MastodonAttachmentService(
context: context,
image: image,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
picker.dismiss(animated: true, completion: nil)
}
}
// MARK: - UIDocumentPickerDelegate
extension ComposeViewController: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
do {
guard url.startAccessingSecurityScopedResource() else { return }
defer { url.stopAccessingSecurityScopedResource() }
let imageData = try Data(contentsOf: url)
let attachmentService = MastodonAttachmentService(
context: context,
imageData: imageData,
initalAuthenticationBox: viewModel.activeAuthenticationBox.value
)
viewModel.attachmentServices.value = viewModel.attachmentServices.value + [attachmentService]
} catch {
os_log("%{public}s[%{public}ld], %{public}s: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
}
}
}
// MARK: - ComposeStatusAttachmentTableViewCellDelegate
extension ComposeViewController: ComposeStatusAttachmentCollectionViewCellDelegate {
func composeStatusAttachmentCollectionViewCell(_ cell: ComposeStatusAttachmentCollectionViewCell, removeButtonDidPressed button: UIButton) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = 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.value
guard let index = attachmentServices.firstIndex(of: attachmentService) else { return }
attachmentServices.remove(at: index)
viewModel.attachmentServices.value = attachmentServices
}
}
2021-03-23 11:47:21 +01:00
// MARK: - ComposeStatusPollOptionCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollOptionCollectionViewCellDelegate {
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textFieldDidBeginEditing textField: UITextField) {
// FIXME: make poll section visible
// DispatchQueue.main.async {
// self.collectionView.scroll(to: .bottom, animated: true)
// }
}
2021-03-23 11:47:21 +01:00
// handle delete backward event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, textBeforeDeleteBackward text: String?) {
guard (text ?? "").isEmpty else { return }
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
guard case let .pollOption(attribute) = item else { return }
2021-03-23 11:47:21 +01:00
2021-03-24 08:46:40 +01:00
var pollAttributes = viewModel.pollOptionAttributes.value
2021-03-23 11:47:21 +01:00
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 = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll)
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
2021-03-24 08:46:40 +01:00
viewModel.pollOptionAttributes.value = pollAttributes
2021-03-23 11:47:21 +01:00
}
// handle keyboard return event for poll option input
func composeStatusPollOptionCollectionViewCell(_ cell: ComposeStatusPollOptionCollectionViewCell, pollOptionTextFieldDidReturn: UITextField) {
guard let diffableDataSource = viewModel.diffableDataSource else { return }
guard let indexPath = collectionView.indexPath(for: cell) else { return }
let pollItems = diffableDataSource.snapshot().itemIdentifiers(inSection: .poll).filter { item in
guard case .pollOption = item else { return false }
2021-03-23 11:47:21 +01:00
return true
}
guard let item = diffableDataSource.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) {
2021-03-23 11:47:21 +01:00
viewModel.createNewPollOptionIfPossible()
DispatchQueue.main.async {
self.markLastPollOptionCollectionViewCellBecomeFirstResponser()
}
}
}
// MARK: - ComposeStatusPollExpiresOptionCollectionViewCellDelegate
extension ComposeViewController: ComposeStatusPollExpiresOptionCollectionViewCellDelegate {
func composeStatusPollExpiresOptionCollectionViewCell(_ cell: ComposeStatusPollExpiresOptionCollectionViewCell, didSelectExpiresOption expiresOption: ComposeStatusItem.ComposePollExpiresOptionAttribute.ExpiresOption) {
viewModel.pollExpiresOptionAttribute.expiresOption.value = expiresOption
}
}