metatext-app-ios-iphone-ipad/Views/UIKit/CompositionView.swift

408 lines
18 KiB
Swift
Raw Normal View History

2020-12-10 03:44:06 +01:00
// Copyright © 2020 Metabolist. All rights reserved.
import Combine
2021-02-23 00:59:33 +01:00
import SDWebImage
2020-12-10 03:44:06 +01:00
import UIKit
2021-01-01 01:49:59 +01:00
import ViewModels
2020-12-10 03:44:06 +01:00
2020-12-19 07:30:19 +01:00
final class CompositionView: UIView {
2021-02-23 00:59:33 +01:00
let avatarImageView = SDAnimatedImageView()
2021-01-27 02:42:32 +01:00
let changeIdentityButton = UIButton()
2021-01-01 01:49:59 +01:00
let spoilerTextField = UITextField()
2021-02-07 22:00:17 +01:00
let textView = ImagePastableTextView()
2021-01-03 22:55:04 +01:00
let textViewPlaceholder = UILabel()
2021-01-10 07:32:41 +01:00
let removeButton = UIButton(type: .close)
let inReplyToView = UIView()
let hasReplyFollowingView = UIView()
2021-01-08 07:11:33 +01:00
let attachmentsView = AttachmentsView()
let attachmentUploadsStackView = UIStackView()
2021-01-11 01:06:20 +01:00
let pollView: CompositionPollView
2021-01-10 03:25:19 +01:00
let markAttachmentsSensitiveView: MarkAttachmentsSensitiveView
2020-12-10 03:44:06 +01:00
2021-01-01 01:49:59 +01:00
private let viewModel: CompositionViewModel
private let parentViewModel: NewStatusViewModel
2020-12-10 03:44:06 +01:00
private var cancellables = Set<AnyCancellable>()
2021-01-01 01:49:59 +01:00
init(viewModel: CompositionViewModel, parentViewModel: NewStatusViewModel) {
self.viewModel = viewModel
self.parentViewModel = parentViewModel
2020-12-10 03:44:06 +01:00
2021-01-10 03:25:19 +01:00
markAttachmentsSensitiveView = MarkAttachmentsSensitiveView(viewModel: viewModel)
2021-01-14 18:49:53 +01:00
pollView = CompositionPollView(viewModel: viewModel, parentViewModel: parentViewModel)
2020-12-19 07:30:19 +01:00
2020-12-10 03:44:06 +01:00
super.init(frame: .zero)
initialSetup()
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
2021-01-01 01:49:59 +01:00
extension CompositionView {
var id: CompositionViewModel.Id { viewModel.id }
2020-12-10 03:44:06 +01:00
}
2020-12-16 02:39:38 +01:00
extension CompositionView: UITextViewDelegate {
func textViewDidChange(_ textView: UITextView) {
2021-01-01 01:49:59 +01:00
viewModel.text = textView.text
2021-02-14 03:28:30 +01:00
if let textToSelectedRange = textView.textToSelectedRange {
viewModel.textToSelectedRange = textToSelectedRange
}
2020-12-16 02:39:38 +01:00
}
}
2020-12-10 03:44:06 +01:00
private extension CompositionView {
2021-01-03 02:22:17 +01:00
static let attachmentCollectionViewHeight: CGFloat = 200
2020-12-17 07:48:06 +01:00
2021-01-01 01:49:59 +01:00
// swiftlint:disable:next function_body_length
2020-12-10 03:44:06 +01:00
func initialSetup() {
2021-01-01 01:49:59 +01:00
tag = viewModel.id.hashValue
2020-12-10 03:44:06 +01:00
addSubview(avatarImageView)
avatarImageView.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.layer.cornerRadius = .avatarDimension / 2
avatarImageView.clipsToBounds = true
2021-01-10 07:32:41 +01:00
avatarImageView.setContentHuggingPriority(.required, for: .horizontal)
2020-12-10 03:44:06 +01:00
2021-01-27 02:42:32 +01:00
changeIdentityButton.translatesAutoresizingMaskIntoConstraints = false
avatarImageView.addSubview(changeIdentityButton)
avatarImageView.isUserInteractionEnabled = true
changeIdentityButton.setBackgroundImage(.highlightedButtonBackground, for: .highlighted)
changeIdentityButton.showsMenuAsPrimaryAction = true
changeIdentityButton.menu =
changeIdentityMenu(identities: parentViewModel.identityContext.authenticatedOtherIdentities)
2021-01-27 02:42:32 +01:00
2020-12-10 03:44:06 +01:00
let stackView = UIStackView()
addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
stackView.axis = .vertical
2021-01-01 01:49:59 +01:00
stackView.spacing = .defaultSpacing
2021-01-14 18:49:53 +01:00
let spoilerTextinputAccessoryView = CompositionInputAccessoryView(
viewModel: viewModel,
2021-02-14 03:28:30 +01:00
parentViewModel: parentViewModel,
autocompleteQueryPublisher: viewModel.$contentWarningAutocompleteQuery.eraseToAnyPublisher())
2021-01-14 18:49:53 +01:00
2021-01-01 01:49:59 +01:00
stackView.addArrangedSubview(spoilerTextField)
2021-01-03 22:55:04 +01:00
spoilerTextField.borderStyle = .roundedRect
2021-01-01 01:49:59 +01:00
spoilerTextField.adjustsFontForContentSizeCategory = true
spoilerTextField.font = .preferredFont(forTextStyle: .body)
spoilerTextField.placeholder = NSLocalizedString("status.spoiler-text-placeholder", comment: "")
2021-01-14 18:49:53 +01:00
spoilerTextField.inputAccessoryView = spoilerTextinputAccessoryView
spoilerTextField.tag = spoilerTextinputAccessoryView.tagForInputView
spoilerTextField.isHidden_stackViewSafe = !viewModel.displayContentWarning
2021-01-01 01:49:59 +01:00
spoilerTextField.addAction(
2021-02-15 22:52:28 +01:00
UIAction { [weak self] _ in self?.spoilerTextFieldEditingChanged() },
2021-01-01 01:49:59 +01:00
for: .editingChanged)
2020-12-10 03:44:06 +01:00
2021-01-03 22:55:04 +01:00
let textViewFont = UIFont.preferredFont(forTextStyle: .body)
2021-01-14 18:49:53 +01:00
let textInputAccessoryView = CompositionInputAccessoryView(
viewModel: viewModel,
2021-02-14 03:28:30 +01:00
parentViewModel: parentViewModel,
autocompleteQueryPublisher: viewModel.$autocompleteQuery.eraseToAnyPublisher())
2021-01-03 22:55:04 +01:00
2020-12-10 03:44:06 +01:00
stackView.addArrangedSubview(textView)
2021-01-31 17:41:06 +01:00
textView.keyboardType = .twitter
2020-12-10 03:44:06 +01:00
textView.isScrollEnabled = false
textView.adjustsFontForContentSizeCategory = true
2021-01-03 22:55:04 +01:00
textView.font = textViewFont
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
2021-01-14 18:49:53 +01:00
textView.inputAccessoryView = textInputAccessoryView
textView.tag = textInputAccessoryView.tagForInputView
2020-12-16 02:39:38 +01:00
textView.inputAccessoryView?.sizeToFit()
textView.delegate = self
2021-01-03 22:55:04 +01:00
textView.addSubview(textViewPlaceholder)
textViewPlaceholder.translatesAutoresizingMaskIntoConstraints = false
textViewPlaceholder.adjustsFontForContentSizeCategory = true
textViewPlaceholder.font = .preferredFont(forTextStyle: .body)
textViewPlaceholder.textColor = .secondaryLabel
textViewPlaceholder.text = NSLocalizedString("compose.prompt", comment: "")
2020-12-10 03:44:06 +01:00
2021-01-08 07:11:33 +01:00
stackView.addArrangedSubview(attachmentsView)
2021-02-01 23:11:53 +01:00
attachmentsView.isHidden_stackViewSafe = true
stackView.addArrangedSubview(attachmentUploadsStackView)
attachmentUploadsStackView.axis = .vertical
attachmentUploadsStackView.isHidden_stackViewSafe = true
2021-01-10 03:25:19 +01:00
stackView.addArrangedSubview(markAttachmentsSensitiveView)
2021-02-01 23:11:53 +01:00
markAttachmentsSensitiveView.isHidden_stackViewSafe = true
2021-01-11 01:06:20 +01:00
stackView.addArrangedSubview(pollView)
2021-02-01 23:11:53 +01:00
pollView.isHidden_stackViewSafe = true
2021-01-10 07:32:41 +01:00
addSubview(removeButton)
removeButton.translatesAutoresizingMaskIntoConstraints = false
removeButton.showsMenuAsPrimaryAction = true
removeButton.menu = UIMenu(
children: [
UIAction(
title: NSLocalizedString("remove", comment: ""),
image: UIImage(systemName: "trash"),
attributes: .destructive) { [weak self] _ in
guard let self = self else { return }
self.parentViewModel.remove(viewModel: self.viewModel)
}])
removeButton.setContentHuggingPriority(.required, for: .horizontal)
removeButton.setContentCompressionResistancePriority(.required, for: .horizontal)
for view in [inReplyToView, hasReplyFollowingView] {
addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = .opaqueSeparator
view.widthAnchor.constraint(equalToConstant: .hairline).isActive = true
}
2020-12-17 07:48:06 +01:00
2021-01-01 01:49:59 +01:00
textView.text = viewModel.text
spoilerTextField.text = viewModel.contentWarning
2020-12-12 01:41:37 +01:00
2021-01-03 22:55:04 +01:00
let textViewBaselineConstraint = textView.topAnchor.constraint(
lessThanOrEqualTo: avatarImageView.centerYAnchor,
constant: -textViewFont.lineHeight / 2)
viewModel.$text.map(\.isEmpty)
2021-02-01 23:11:53 +01:00
.sink { [weak self] in self?.textViewPlaceholder.isHidden_stackViewSafe = !$0 }
2021-01-03 22:55:04 +01:00
.store(in: &cancellables)
2021-01-01 01:49:59 +01:00
viewModel.$displayContentWarning
2021-02-01 23:11:53 +01:00
.throttle(for: .seconds(TimeInterval.zeroIfReduceMotion(.shortAnimationDuration)),
scheduler: DispatchQueue.main,
latest: true)
2021-01-19 20:59:20 +01:00
.sink { [weak self] displayContentWarning in
2021-01-01 01:49:59 +01:00
guard let self = self else { return }
2020-12-12 01:41:37 +01:00
2021-01-19 20:59:20 +01:00
if self.spoilerTextField.isHidden && self.textView.isFirstResponder && displayContentWarning {
2021-01-01 01:49:59 +01:00
self.spoilerTextField.becomeFirstResponder()
2021-01-19 20:59:20 +01:00
} else if !self.spoilerTextField.isHidden
&& self.spoilerTextField.isFirstResponder
&& !displayContentWarning {
2021-01-01 01:49:59 +01:00
self.textView.becomeFirstResponder()
}
2020-12-20 00:05:14 +01:00
2021-01-19 20:59:20 +01:00
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
2021-02-01 23:11:53 +01:00
self.spoilerTextField.isHidden_stackViewSafe = !displayContentWarning
2021-01-19 20:59:20 +01:00
textViewBaselineConstraint.isActive = !displayContentWarning
}
2021-01-01 01:49:59 +01:00
}
.store(in: &cancellables)
2020-12-10 03:44:06 +01:00
2021-02-04 02:50:25 +01:00
parentViewModel.$identityContext
2021-02-01 23:00:40 +01:00
.sink { [weak self] in
guard let self = self else { return }
2021-02-04 02:50:25 +01:00
let avatarURL = $0.appPreferences.animateAvatars == .everywhere
? $0.identity.account?.avatar
: $0.identity.account?.avatarStatic
2021-03-29 08:04:14 +02:00
self.avatarImageView.sd_setImage(with: avatarURL?.url)
2021-02-04 02:50:25 +01:00
self.changeIdentityButton.accessibilityLabel = $0.identity.handle
2021-02-01 23:00:40 +01:00
self.changeIdentityButton.accessibilityHint =
NSLocalizedString("compose.change-identity-button.accessibility-hint", comment: "")
}
2020-12-10 03:44:06 +01:00
.store(in: &cancellables)
2020-12-17 07:48:06 +01:00
parentViewModel.identityContext.$authenticatedOtherIdentities
2021-01-27 02:42:32 +01:00
.sink { [weak self] in self?.changeIdentityButton.menu = self?.changeIdentityMenu(identities: $0) }
.store(in: &cancellables)
2021-01-01 01:49:59 +01:00
viewModel.$attachmentViewModels
2021-02-01 23:11:53 +01:00
.throttle(for: .seconds(TimeInterval.zeroIfReduceMotion(.shortAnimationDuration)),
scheduler: DispatchQueue.main,
latest: true)
2021-01-19 20:59:20 +01:00
.sink { [weak self] attachmentViewModels in
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
self?.attachmentsView.viewModel = self?.viewModel
2021-02-01 23:11:53 +01:00
self?.attachmentsView.isHidden_stackViewSafe = attachmentViewModels.isEmpty
self?.markAttachmentsSensitiveView.isHidden_stackViewSafe = attachmentViewModels.isEmpty
2021-01-19 20:59:20 +01:00
}
2020-12-19 07:30:19 +01:00
}
.store(in: &cancellables)
2021-02-07 22:00:17 +01:00
viewModel.$canAddAttachment
.sink { [weak self] in self?.textView.canPasteImage = $0 }
.store(in: &cancellables)
viewModel.$attachmentUploadViewModels
.throttle(for: .seconds(TimeInterval.zeroIfReduceMotion(.shortAnimationDuration)),
scheduler: DispatchQueue.main,
latest: true)
.sink { [weak self] attachmentUploadViewModels in
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
self?.attachmentUploadsStackView.isHidden_stackViewSafe = attachmentUploadViewModels.isEmpty
self?.update(attachmentUploadViewModels: attachmentUploadViewModels)
}
}
.store(in: &cancellables)
2021-02-09 05:33:49 +01:00
textView.pastedItemProviders.sink { [weak self] in
2021-02-07 22:00:17 +01:00
guard let self = self else { return }
self.viewModel.attach(itemProviders: [$0],
2021-02-07 22:00:17 +01:00
parentViewModel: self.parentViewModel)
}
.store(in: &cancellables)
2021-01-11 01:06:20 +01:00
viewModel.$displayPoll
2021-02-01 23:11:53 +01:00
.throttle(for: .seconds(TimeInterval.zeroIfReduceMotion(.shortAnimationDuration)),
scheduler: DispatchQueue.main,
latest: true)
2021-01-19 20:59:20 +01:00
.sink { [weak self] displayPoll in
if !displayPoll {
2021-01-11 01:06:20 +01:00
self?.textView.becomeFirstResponder()
}
2021-01-19 20:59:20 +01:00
UIView.animate(withDuration: .zeroIfReduceMotion(.shortAnimationDuration)) {
2021-02-01 23:11:53 +01:00
self?.pollView.isHidden_stackViewSafe = !displayPoll
2021-01-19 20:59:20 +01:00
}
2021-01-11 01:06:20 +01:00
}
.store(in: &cancellables)
2021-02-15 22:52:28 +01:00
textInputAccessoryView.autocompleteSelections
.sink { [weak self] in self?.autocompleteSelected($0) }
.store(in: &cancellables)
spoilerTextinputAccessoryView.autocompleteSelections
.sink { [weak self] in self?.spoilerTextAutocompleteSelected($0) }
.store(in: &cancellables)
2021-01-01 01:49:59 +01:00
let guide = UIDevice.current.userInterfaceIdiom == .pad ? readableContentGuide : layoutMarginsGuide
let constraints = [
avatarImageView.heightAnchor.constraint(equalToConstant: .avatarDimension),
avatarImageView.widthAnchor.constraint(equalToConstant: .avatarDimension),
avatarImageView.topAnchor.constraint(equalTo: guide.topAnchor),
avatarImageView.leadingAnchor.constraint(equalTo: guide.leadingAnchor),
avatarImageView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor),
2021-01-27 02:42:32 +01:00
changeIdentityButton.leadingAnchor.constraint(equalTo: avatarImageView.leadingAnchor),
changeIdentityButton.topAnchor.constraint(equalTo: avatarImageView.topAnchor),
changeIdentityButton.bottomAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
changeIdentityButton.trailingAnchor.constraint(equalTo: avatarImageView.trailingAnchor),
2021-01-01 01:49:59 +01:00
stackView.leadingAnchor.constraint(equalTo: avatarImageView.trailingAnchor, constant: .defaultSpacing),
2021-01-03 22:55:04 +01:00
stackView.topAnchor.constraint(greaterThanOrEqualTo: guide.topAnchor),
2021-01-01 01:49:59 +01:00
stackView.bottomAnchor.constraint(lessThanOrEqualTo: guide.bottomAnchor),
2021-01-03 22:55:04 +01:00
textViewPlaceholder.leadingAnchor.constraint(equalTo: textView.leadingAnchor),
textViewPlaceholder.topAnchor.constraint(equalTo: textView.topAnchor),
2021-01-10 07:32:41 +01:00
textViewPlaceholder.trailingAnchor.constraint(equalTo: textView.trailingAnchor),
removeButton.leadingAnchor.constraint(equalTo: stackView.trailingAnchor, constant: .defaultSpacing),
removeButton.topAnchor.constraint(equalTo: guide.topAnchor),
removeButton.trailingAnchor.constraint(equalTo: guide.trailingAnchor),
inReplyToView.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
inReplyToView.topAnchor.constraint(equalTo: topAnchor),
inReplyToView.bottomAnchor.constraint(equalTo: avatarImageView.topAnchor),
hasReplyFollowingView.centerXAnchor.constraint(equalTo: avatarImageView.centerXAnchor),
hasReplyFollowingView.topAnchor.constraint(equalTo: avatarImageView.bottomAnchor),
hasReplyFollowingView.bottomAnchor.constraint(equalTo: bottomAnchor)
2021-01-01 01:49:59 +01:00
]
NSLayoutConstraint.activate(constraints)
2020-12-10 03:44:06 +01:00
}
2021-01-27 02:42:32 +01:00
func changeIdentityMenu(identities: [Identity]) -> UIMenu {
2021-02-23 00:59:33 +01:00
let imageTransformer = SDImageRoundCornerTransformer(
radius: .greatestFiniteMagnitude,
corners: .allCorners,
borderWidth: 0,
borderColor: nil)
2021-01-31 07:30:12 +01:00
return UIMenu(children: identities.map { identity in
2021-01-27 02:42:32 +01:00
UIDeferredMenuElement { completion in
let action = UIAction(title: identity.handle) { [weak self] _ in
self?.parentViewModel.changeIdentity(identity)
}
if let image = identity.image {
2021-02-23 00:59:33 +01:00
SDWebImageManager.shared.loadImage(
with: image,
options: [.transformAnimatedImage],
context: [.imageTransformer: imageTransformer],
progress: nil) { (image, _, _, _, _, _) in
action.image = image
2021-01-27 02:42:32 +01:00
completion([action])
}
} else {
completion([action])
}
}
})
}
2021-02-15 22:52:28 +01:00
func spoilerTextFieldEditingChanged() {
guard let text = spoilerTextField.text else { return }
viewModel.contentWarning = text
if let textToSelectedRange = spoilerTextField.textToSelectedRange {
viewModel.contentWarningTextToSelectedRange = textToSelectedRange
}
}
func autocompleteSelected(_ autocompleteText: String) {
guard let autocompleteQuery = viewModel.autocompleteQuery,
let queryRange = viewModel.textToSelectedRange.range(of: autocompleteQuery, options: .backwards),
let textToSelectedRangeRange = viewModel.text.range(of: viewModel.textToSelectedRange)
else { return }
let replaced = viewModel.textToSelectedRange.replacingOccurrences(
of: autocompleteQuery,
with: autocompleteText.appending(" "),
range: queryRange)
textView.text = viewModel.text.replacingOccurrences(
of: viewModel.textToSelectedRange,
with: replaced,
range: textToSelectedRangeRange)
textViewDidChange(textView)
}
func spoilerTextAutocompleteSelected(_ autocompleteText: String) {
guard let autocompleteQuery = viewModel.contentWarningAutocompleteQuery,
2021-02-17 04:53:43 +01:00
let queryRange =
viewModel.contentWarningTextToSelectedRange.range(of: autocompleteQuery, options: .backwards),
let textToSelectedRangeRange =
viewModel.contentWarning.range(of: viewModel.contentWarningTextToSelectedRange)
2021-02-15 22:52:28 +01:00
else { return }
let replaced = viewModel.contentWarningTextToSelectedRange.replacingOccurrences(
of: autocompleteQuery,
with: autocompleteText.appending(" "),
range: queryRange)
spoilerTextField.text = viewModel.contentWarning.replacingOccurrences(
of: viewModel.contentWarningTextToSelectedRange,
with: replaced,
range: textToSelectedRangeRange)
spoilerTextFieldEditingChanged()
}
func update(attachmentUploadViewModels: [AttachmentUploadViewModel]) {
let diff = attachmentUploadViewModels.map(\.id)
.difference(from: attachmentUploadsStackView
.arrangedSubviews
.compactMap { ($0 as? AttachmentUploadView)?.id })
for insertion in diff.insertions {
guard case let .insert(index, id, _) = insertion,
let attachmentUploadViewModel = attachmentUploadViewModels.first(where: { $0.id == id })
else { continue }
let attachmentUploadView = AttachmentUploadView(viewModel: attachmentUploadViewModel)
attachmentUploadsStackView.insertArrangedSubview(attachmentUploadView, at: index)
}
for removal in diff.removals {
guard case let .remove(_, id, _) = removal,
2021-03-31 07:58:11 +02:00
let index = attachmentUploadsStackView.arrangedSubviews
.firstIndex(where: { ($0 as? AttachmentUploadView)?.id == id })
else { continue }
attachmentUploadsStackView.arrangedSubviews[index].removeFromSuperview()
}
}
2020-12-10 03:44:06 +01:00
}