2021-03-11 08:41:27 +01:00
//
// C o m p o s e V i e w C o n t r o l l e r . s w i f t
// M a s t o d o n
//
// C r e a t e d b y M a i n a s u K C i r n o o n 2 0 2 1 - 3 - 1 1 .
//
import os . log
import UIKit
import Combine
2021-03-18 08:16:35 +01:00
import PhotosUI
2021-03-26 12:16:19 +01:00
import MastodonSDK
2021-07-22 13:34:24 +02:00
import MetaTextKit
2021-06-28 13:41:41 +02:00
import MastodonMeta
import Meta
2021-07-16 10:21:47 +02:00
import MastodonUI
2021-03-11 08:41:27 +01:00
final class ComposeViewController : UIViewController , NeedsDependency {
2021-05-18 08:25:32 +02:00
static let minAutoCompleteVisibleHeight : CGFloat = 100
2021-05-14 14:02:59 +02:00
2021-03-11 08:41:27 +01:00
weak var context : AppContext ! { willSet { precondition ( ! isViewLoaded ) } }
weak var coordinator : SceneCoordinator ! { willSet { precondition ( ! isViewLoaded ) } }
var disposeBag = Set < AnyCancellable > ( )
var viewModel : ComposeViewModel !
2021-06-28 13:41:41 +02:00
let logger = Logger ( subsystem : " ComposeViewController " , category : " logic " )
2021-03-16 07:19:12 +01:00
private var suffixedAttachmentViews : [ UIView ] = [ ]
2021-03-18 10:33:07 +01:00
let publishButton : UIButton = {
2021-03-12 07:18:07 +01:00
let button = RoundedEdgesButton ( type : . custom )
button . setTitle ( L10n . Scene . Compose . composeAction , for : . normal )
button . titleLabel ? . font = . systemFont ( ofSize : 14 , weight : . bold )
2021-06-22 14:52:30 +02:00
button . setBackgroundImage ( . placeholder ( color : Asset . Colors . brandBlue . color ) , for : . normal )
button . setBackgroundImage ( . placeholder ( color : Asset . Colors . brandBlue . color . withAlphaComponent ( 0.5 ) ) , for : . highlighted )
2021-03-12 07:18:07 +01:00
button . setBackgroundImage ( . placeholder ( color : Asset . Colors . Button . disabled . color ) , for : . disabled )
button . setTitleColor ( . white , for : . normal )
2021-04-15 06:10:43 +02:00
button . contentEdgeInsets = UIEdgeInsets ( top : 6 , left : 16 , bottom : 5 , right : 16 ) // s e t 2 8 p t h e i g h t
2021-03-12 07:18:07 +01:00
button . adjustsImageWhenHighlighted = false
2021-03-18 10:33:07 +01:00
return button
} ( )
2021-05-21 13:12:01 +02:00
private ( set ) lazy var cancelBarButtonItem = UIBarButtonItem ( title : L10n . Common . Controls . Actions . cancel , style : . plain , target : self , action : #selector ( ComposeViewController . cancelBarButtonItemPressed ( _ : ) ) )
2021-03-18 10:33:07 +01:00
private ( set ) lazy var publishBarButtonItem : UIBarButtonItem = {
let barButtonItem = UIBarButtonItem ( customView : publishButton )
2021-03-12 07:18:07 +01:00
return barButtonItem
} ( )
2021-06-28 13:41:41 +02:00
let tableView : ComposeTableView = {
let tableView = ComposeTableView ( )
tableView . register ( ComposeRepliedToStatusContentTableViewCell . self , forCellReuseIdentifier : String ( describing : ComposeRepliedToStatusContentTableViewCell . self ) )
tableView . register ( ComposeStatusContentTableViewCell . self , forCellReuseIdentifier : String ( describing : ComposeStatusContentTableViewCell . self ) )
2021-06-29 10:41:58 +02:00
tableView . register ( ComposeStatusAttachmentTableViewCell . self , forCellReuseIdentifier : String ( describing : ComposeStatusAttachmentTableViewCell . self ) )
2021-06-28 13:41:41 +02:00
tableView . alwaysBounceVertical = true
tableView . separatorStyle = . none
tableView . tableFooterView = UIView ( )
return tableView
2021-03-11 08:41:27 +01:00
} ( )
2021-03-25 08:56:17 +01:00
var systemKeyboardHeight : CGFloat = . zero {
didSet {
// n o t e : s o m e s y s t e m A u t o L a y o u t w a r n i n g h e r e
2021-06-07 08:22:03 +02:00
let height = max ( 300 , systemKeyboardHeight )
customEmojiPickerInputView . frame . size . height = height
2021-03-25 08:56:17 +01:00
}
}
// C u s t o m E m o j i P i c k e r V i e w
let customEmojiPickerInputView : CustomEmojiPickerInputView = {
let view = CustomEmojiPickerInputView ( frame : CGRect ( x : 0 , y : 0 , width : 0 , height : 300 ) , inputViewStyle : . keyboard )
return view
} ( )
2021-04-14 09:24:54 +02:00
let composeToolbarView = ComposeToolbarView ( )
2021-03-12 08:23:28 +01:00
var composeToolbarViewBottomLayoutConstraint : NSLayoutConstraint !
2021-07-20 13:24:24 +02:00
let composeToolbarBackgroundView = UIView ( )
2021-03-12 08:23:28 +01:00
2021-05-31 10:42:49 +02:00
static func createPhotoLibraryPickerConfiguration ( selectionLimit : Int = 4 ) -> PHPickerConfiguration {
2021-03-18 08:16:35 +01:00
var configuration = PHPickerConfiguration ( )
2021-05-31 10:42:49 +02:00
configuration . filter = . any ( of : [ . images , . videos ] )
configuration . selectionLimit = selectionLimit
return configuration
}
private ( set ) lazy var photoLibraryPicker : PHPickerViewController = {
let imagePicker = PHPickerViewController ( configuration : ComposeViewController . createPhotoLibraryPickerConfiguration ( ) )
2021-03-18 08:16:35 +01:00
imagePicker . delegate = self
return imagePicker
} ( )
2021-03-19 12:49:48 +01:00
private ( set ) lazy var imagePickerController : UIImagePickerController = {
let imagePickerController = UIImagePickerController ( )
imagePickerController . sourceType = . camera
imagePickerController . delegate = self
return imagePickerController
} ( )
private ( set ) lazy var documentPickerController : UIDocumentPickerViewController = {
2021-05-31 12:03:31 +02:00
let documentPickerController = UIDocumentPickerViewController ( forOpeningContentTypes : [ . image , . movie ] )
2021-03-19 12:49:48 +01:00
documentPickerController . delegate = self
return documentPickerController
} ( )
2021-03-18 08:16:35 +01:00
2021-05-18 08:25:32 +02:00
private ( set ) lazy var autoCompleteViewController : AutoCompleteViewController = {
let viewController = AutoCompleteViewController ( )
viewController . viewModel = AutoCompleteViewModel ( context : context )
2021-05-18 09:06:00 +02:00
viewController . delegate = self
2021-05-18 08:25:32 +02:00
viewModel . customEmojiViewModel
. assign ( to : \ . value , on : viewController . viewModel . customEmojiViewModel )
. store ( in : & disposeBag )
2021-05-14 14:02:59 +02:00
return viewController
} ( )
2021-03-26 12:16:19 +01:00
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
}
2021-03-22 10:48:35 +01:00
extension ComposeViewController {
private static func createLayout ( ) -> UICollectionViewLayout {
2021-03-26 12:16:19 +01:00
let itemSize = NSCollectionLayoutSize ( widthDimension : . fractionalWidth ( 1.0 ) , heightDimension : . estimated ( 44 ) )
2021-03-22 10:48:35 +01:00
let item = NSCollectionLayoutItem ( layoutSize : itemSize )
2021-03-26 12:16:19 +01:00
let groupSize = NSCollectionLayoutSize ( widthDimension : . fractionalWidth ( 1.0 ) , heightDimension : . estimated ( 44 ) )
let group = NSCollectionLayoutGroup . vertical ( layoutSize : groupSize , subitems : [ item ] )
2021-03-22 10:48:35 +01:00
let section = NSCollectionLayoutSection ( group : group )
section . contentInsetsReference = . readableContent
// s e c t i o n . i n t e r G r o u p S p a c i n g = 1 0
// s e c t i o n . c o n t e n t I n s e t s = N S D i r e c t i o n a l E d g e I n s e t s ( t o p : 1 0 , l e a d i n g : 1 0 , b o t t o m : 1 0 , t r a i l i n g : 1 0 )
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 )
2021-07-06 12:00:39 +02:00
self . setupBackgroundColor ( theme : ThemeService . shared . currentTheme . value )
2021-07-05 10:07:17 +02:00
ThemeService . shared . currentTheme
. receive ( on : RunLoop . main )
. sink { [ weak self ] theme in
guard let self = self else { return }
2021-07-06 12:00:39 +02:00
self . setupBackgroundColor ( theme : theme )
2021-07-05 10:07:17 +02:00
}
. store ( in : & disposeBag )
2021-05-21 13:12:01 +02:00
navigationItem . leftBarButtonItem = cancelBarButtonItem
2021-03-18 10:33:07 +01:00
navigationItem . rightBarButtonItem = publishBarButtonItem
publishButton . addTarget ( self , action : #selector ( ComposeViewController . publishBarButtonItemPressed ( _ : ) ) , for : . touchUpInside )
2021-06-28 13:41:41 +02:00
tableView . translatesAutoresizingMaskIntoConstraints = false
view . addSubview ( tableView )
2021-03-11 08:41:27 +01:00
NSLayoutConstraint . activate ( [
2021-06-28 13:41:41 +02:00
tableView . topAnchor . constraint ( equalTo : view . topAnchor ) ,
tableView . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
tableView . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
tableView . 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 ,
2021-03-12 08:57:58 +01:00
composeToolbarView . heightAnchor . constraint ( equalToConstant : ComposeToolbarView . toolbarHeight ) ,
2021-03-12 08:23:28 +01:00
] )
composeToolbarView . preservesSuperviewLayoutMargins = true
composeToolbarView . delegate = self
2021-03-12 12:25:28 +01:00
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 ) ,
] )
2021-06-28 13:41:41 +02:00
tableView . delegate = self
2021-07-19 11:12:45 +02:00
viewModel . setupDataSource (
2021-06-28 13:41:41 +02:00
tableView : tableView ,
metaTextDelegate : self ,
metaTextViewDelegate : self ,
2021-06-29 10:41:58 +02:00
customEmojiPickerInputViewModel : viewModel . customEmojiPickerInputViewModel ,
composeStatusAttachmentCollectionViewCellDelegate : self ,
composeStatusPollOptionCollectionViewCellDelegate : self ,
composeStatusPollOptionAppendEntryCollectionViewCellDelegate : self ,
composeStatusPollExpiresOptionCollectionViewCellDelegate : self
2021-03-12 12:25:28 +01:00
)
2021-06-28 13:41:41 +02:00
viewModel . composeStatusAttribute . composeContent
. removeDuplicates ( )
. receive ( on : RunLoop . main )
. sink { [ weak self ] _ in
guard let self = self else { return }
2021-06-29 10:41:58 +02:00
guard self . view . window != nil else { return }
2021-06-28 13:41:41 +02:00
UIView . performWithoutAnimation {
self . tableView . beginUpdates ( )
self . tableView . endUpdates ( )
}
}
. store ( in : & disposeBag )
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:57:58 +01:00
// u p d a t e l a y o u t w h e n k e y b o a r d s h o w / d i s m i s s
2021-06-29 11:46:43 +02:00
view . layoutIfNeeded ( )
2021-05-14 14:02:59 +02:00
let keyboardEventPublishers = Publishers . CombineLatest3 (
KeyboardResponderService . shared . isShow ,
KeyboardResponderService . shared . state ,
KeyboardResponderService . shared . endFrame
2021-03-12 08:23:28 +01:00
)
2021-05-14 14:02:59 +02:00
Publishers . CombineLatest3 (
keyboardEventPublishers ,
viewModel . isCustomEmojiComposing ,
2021-05-18 08:25:32 +02:00
viewModel . autoCompleteInfo
2021-05-14 14:02:59 +02:00
)
2021-05-18 08:25:32 +02:00
. sink ( receiveValue : { [ weak self ] keyboardEvents , isCustomEmojiComposing , autoCompleteInfo in
2021-03-12 08:23:28 +01:00
guard let self = self else { return }
2021-05-14 14:02:59 +02:00
let ( isShow , state , endFrame ) = keyboardEvents
2021-04-14 12:11:59 +02:00
let extraMargin : CGFloat = {
2021-05-14 14:02:59 +02:00
var margin = self . composeToolbarView . frame . height
2021-05-18 08:25:32 +02:00
if autoCompleteInfo != nil {
margin += ComposeViewController . minAutoCompleteVisibleHeight
2021-04-14 12:11:59 +02:00
}
2021-05-14 14:02:59 +02:00
return margin
2021-04-14 12:11:59 +02:00
} ( )
2021-03-22 10:48:35 +01:00
2021-03-12 08:23:28 +01:00
guard isShow , state = = . dock else {
2021-06-29 10:41:58 +02:00
self . tableView . contentInset . bottom = extraMargin
self . tableView . verticalScrollIndicatorInsets . bottom = extraMargin
2021-05-18 08:25:32 +02:00
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
}
2021-03-12 08:23:28 +01:00
UIView . animate ( withDuration : 0.3 ) {
2021-03-22 10:48:35 +01:00
self . composeToolbarViewBottomLayoutConstraint . constant = self . view . safeAreaInsets . bottom
2021-04-19 11:50:58 +02:00
if self . view . window != nil {
self . view . layoutIfNeeded ( )
}
2021-03-12 08:23:28 +01:00
}
return
}
// i s S h o w A N D d o c k s t a t e
2021-03-25 08:56:17 +01:00
self . systemKeyboardHeight = endFrame . height
2021-05-18 08:25:32 +02:00
// a d j u s t i n s e t f o r a u t o - c o m p l e t e
let autoCompleteTableViewBottomInset : CGFloat = {
2021-05-21 13:12:01 +02:00
guard let superview = self . autoCompleteViewController . tableView . superview else { return . zero }
let tableViewFrameInWindow = superview . convert ( self . autoCompleteViewController . tableView . frame , to : nil )
2021-05-18 08:25:32 +02:00
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
2021-07-19 11:12:45 +02:00
// a d j u s t i n s e t f o r t a b l e V i e w
2021-06-28 13:41:41 +02:00
let contentFrame = self . view . convert ( self . tableView . frame , to : nil )
2021-05-14 14:02:59 +02:00
let padding = contentFrame . maxY + extraMargin - endFrame . minY
2021-03-12 08:23:28 +01:00
guard padding > 0 else {
2021-06-28 13:41:41 +02:00
self . tableView . contentInset . bottom = self . view . safeAreaInsets . bottom + extraMargin
self . tableView . verticalScrollIndicatorInsets . bottom = self . view . safeAreaInsets . bottom + extraMargin
2021-03-12 08:23:28 +01:00
return
}
2021-06-29 10:41:58 +02:00
self . tableView . contentInset . bottom = padding - self . view . safeAreaInsets . bottom
self . tableView . verticalScrollIndicatorInsets . bottom = padding - self . view . safeAreaInsets . bottom
2021-03-12 08:23:28 +01:00
UIView . animate ( withDuration : 0.3 ) {
2021-05-14 14:02:59 +02:00
self . composeToolbarViewBottomLayoutConstraint . constant = endFrame . height
2021-03-12 08:23:28 +01:00
self . view . layoutIfNeeded ( )
}
} )
. store ( in : & disposeBag )
2021-05-14 14:02:59 +02:00
// b i n d a u t o - c o m p l e t e
2021-05-18 08:25:32 +02:00
viewModel . autoCompleteInfo
2021-05-14 14:02:59 +02:00
. receive ( on : DispatchQueue . main )
2021-05-18 08:25:32 +02:00
. sink { [ weak self ] info in
2021-05-14 14:02:59 +02:00
guard let self = self else { return }
guard let textEditorView = self . textEditorView ( ) else { return }
2021-05-18 08:25:32 +02:00
if self . autoCompleteViewController . view . superview = = nil {
self . autoCompleteViewController . view . frame = self . view . bounds
2021-05-14 14:02:59 +02:00
// a d d t o c o n t a i n e r v i e w . s e e a l s o : ` v i e w D i d L a y o u t S u b v i e w s ( ) `
2021-06-28 13:41:41 +02:00
self . viewModel . composeStatusContentTableViewCell . textEditorViewContainerView . addSubview ( self . autoCompleteViewController . view )
2021-05-18 08:25:32 +02:00
self . addChild ( self . autoCompleteViewController )
self . autoCompleteViewController . didMove ( toParent : self )
self . autoCompleteViewController . view . isHidden = true
2021-06-28 13:41:41 +02:00
self . tableView . autoCompleteViewController = self . autoCompleteViewController
2021-05-14 14:02:59 +02:00
}
2021-06-28 13:41:41 +02:00
self . updateAutoCompleteViewControllerLayout ( )
2021-05-18 08:25:32 +02:00
self . autoCompleteViewController . view . isHidden = info = = nil
guard let info = info else { return }
2021-06-28 13:41:41 +02:00
let symbolBoundingRectInContainer = textEditorView . textView . convert ( info . symbolBoundingRect , to : self . autoCompleteViewController . chevronView )
2021-05-18 08:25:32 +02:00
self . autoCompleteViewController . view . frame . origin . y = info . textBoundingRect . maxY
self . autoCompleteViewController . viewModel . symbolBoundingRect . value = symbolBoundingRectInContainer
self . autoCompleteViewController . viewModel . inputText . value = String ( info . inputText )
2021-05-14 14:02:59 +02:00
}
. store ( in : & disposeBag )
2021-03-22 10:48:35 +01:00
2021-03-26 12:16:19 +01:00
// b i n d p u b l i s h b a r b u t t o n s t a t e
2021-03-18 12:42:26 +01:00
viewModel . isPublishBarButtonItemEnabled
2021-03-12 08:57:58 +01:00
. receive ( on : DispatchQueue . main )
2021-03-18 10:33:07 +01:00
. assign ( to : \ . isEnabled , on : publishBarButtonItem )
2021-03-12 08:57:58 +01:00
. store ( in : & disposeBag )
2021-03-23 11:47:21 +01:00
2021-03-26 12:16:19 +01:00
// b i n d m e d i a b u t t o n t o o l b a r s t a t e
2021-03-23 11:47:21 +01:00
viewModel . isMediaToolbarButtonEnabled
. receive ( on : DispatchQueue . main )
. assign ( to : \ . isEnabled , on : composeToolbarView . mediaButton )
. store ( in : & disposeBag )
2021-03-26 12:16:19 +01:00
// b i n d p o l l b u t t o n t o o l b a r s t a t e
2021-03-23 11:47:21 +01:00
viewModel . isPollToolbarButtonEnabled
. receive ( on : DispatchQueue . main )
. assign ( to : \ . isEnabled , on : composeToolbarView . pollButton )
. store ( in : & disposeBag )
2021-05-13 08:27:57 +02:00
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 {
self . composeToolbarView . pollButton . accessibilityLabel = L10n . Scene . Compose . Accessibility . appendPoll
return
}
self . composeToolbarView . pollButton . accessibilityLabel = isPollComposing ? L10n . Scene . Compose . Accessibility . removePoll : L10n . Scene . Compose . Accessibility . appendPoll
}
. store ( in : & disposeBag )
2021-03-22 10:48:35 +01:00
2021-03-18 10:33:07 +01:00
// b i n d i m a g e p i c k e r t o o l b a r s t a t e
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 )
2021-03-25 12:34:30 +01:00
2021-05-13 08:27:57 +02:00
// b i n d c o n t e n t w a r n i n g b u t t o n s t a t e
viewModel . isContentWarningComposing
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] isContentWarningComposing in
guard let self = self else { return }
self . composeToolbarView . contentWarningButton . accessibilityLabel = isContentWarningComposing ? L10n . Scene . Compose . Accessibility . disableContentWarning : L10n . Scene . Compose . Accessibility . enableContentWarning
}
. store ( in : & disposeBag )
2021-03-26 12:16:19 +01:00
// b i n d v i s i b i l i t y t o o l b a r U I
2021-04-14 09:24:54 +02:00
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 )
2021-06-15 12:58:43 +02:00
self . composeToolbarView . activeVisibilityType . value = type
2021-04-14 09:24:54 +02:00
}
. store ( in : & disposeBag )
2021-03-26 12:16:19 +01:00
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 :
2021-06-21 12:06:10 +02:00
self . composeToolbarView . characterCountLabel . font = . monospacedDigitSystemFont ( ofSize : 24 , weight : . bold )
2021-03-26 12:16:19 +01:00
self . composeToolbarView . characterCountLabel . textColor = Asset . Colors . danger . color
2021-08-09 13:44:04 +02:00
self . composeToolbarView . characterCountLabel . accessibilityLabel = L10n . A11y . Plural . Count . inputLimitExceeds ( abs ( count ) )
2021-03-26 12:16:19 +01:00
default :
2021-06-21 12:06:10 +02:00
self . composeToolbarView . characterCountLabel . font = . monospacedDigitSystemFont ( ofSize : 15 , weight : . regular )
2021-03-26 12:16:19 +01:00
self . composeToolbarView . characterCountLabel . textColor = Asset . Colors . Label . secondary . color
2021-08-09 13:44:04 +02:00
self . composeToolbarView . characterCountLabel . accessibilityLabel = L10n . A11y . Plural . Count . inputLimitRemains ( count )
2021-03-26 12:16:19 +01:00
}
}
. store ( in : & disposeBag )
// b i n d c u s t o m e m o j i p i c k e r U I
viewModel . customEmojiViewModel
. map { viewModel -> AnyPublisher < [ Mastodon . Entity . Emoji ] , Never > in
guard let viewModel = viewModel else {
return Just ( [ ] ) . eraseToAnyPublisher ( )
}
return viewModel . emojis . eraseToAnyPublisher ( )
}
. switchToLatest ( )
2021-05-06 10:24:21 +02:00
. receive ( on : DispatchQueue . main )
2021-03-26 12:16:19 +01:00
. 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-04-15 06:10:43 +02:00
// s e t u p s n a p b e h a v i o r
Publishers . CombineLatest (
2021-06-29 11:46:43 +02:00
viewModel . repliedToCellFrame ,
viewModel . collectionViewState
2021-04-15 06:10:43 +02:00
)
. receive ( on : DispatchQueue . main )
. sink { [ weak self ] repliedToCellFrame , collectionViewState in
guard let self = self else { return }
guard repliedToCellFrame != . zero else { return }
switch collectionViewState {
case . fold :
2021-06-28 13:41:41 +02:00
self . tableView . contentInset . top = - repliedToCellFrame . height
2021-06-29 11:46:43 +02:00
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: set contentInset.top: -%s " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , repliedToCellFrame . height . description )
2021-04-15 06:10:43 +02:00
case . expand :
2021-06-28 13:41:41 +02:00
self . tableView . contentInset . top = 0
2021-04-15 06:10:43 +02:00
}
}
. store ( in : & disposeBag )
2021-03-11 08:41:27 +01:00
}
2021-03-11 12:26:10 +01:00
override func viewWillAppear ( _ animated : Bool ) {
super . viewWillAppear ( animated )
2021-06-29 11:46:43 +02:00
// u s i n g i n d e x t o m a k e t a b l e v i e w l a y o u t
// o t h e r w i s e , t h e c o n t e n t o f f s e t w i l l b e w r o n g
guard let indexPath = tableView . indexPath ( for : viewModel . composeStatusContentTableViewCell ) ,
let cell = tableView . cellForRow ( at : indexPath ) as ? ComposeStatusContentTableViewCell else {
assertionFailure ( )
return
2021-03-11 12:26:10 +01:00
}
2021-06-29 11:46:43 +02:00
cell . metaText . textView . becomeFirstResponder ( )
2021-03-11 12:26:10 +01:00
}
2021-06-29 10:41:58 +02:00
override func viewDidAppear ( _ animated : Bool ) {
super . viewDidAppear ( animated )
viewModel . isViewAppeared = true
}
2021-03-11 12:26:10 +01:00
2021-04-14 09:24:54 +02:00
override func traitCollectionDidChange ( _ previousTraitCollection : UITraitCollection ? ) {
super . traitCollectionDidChange ( previousTraitCollection )
viewModel . traitCollectionDidChangePublisher . send ( )
}
2021-05-14 14:02:59 +02:00
override func viewDidLayoutSubviews ( ) {
super . viewDidLayoutSubviews ( )
2021-06-28 13:41:41 +02:00
updateAutoCompleteViewControllerLayout ( )
}
func updateAutoCompleteViewControllerLayout ( ) {
// p i n a u t o C o m p l e t e V i e w C o n t r o l l e r f r a m e t o c u r r e n t v i e w
2021-05-18 08:25:32 +02:00
if let containerView = autoCompleteViewController . view . superview {
2021-06-28 13:41:41 +02:00
let viewFrameInWindow = containerView . convert ( autoCompleteViewController . view . frame , to : view )
2021-05-14 14:02:59 +02:00
if viewFrameInWindow . origin . x != 0 {
2021-05-18 08:25:32 +02:00
autoCompleteViewController . view . frame . origin . x = - viewFrameInWindow . origin . x
2021-05-14 14:02:59 +02:00
}
2021-06-28 13:41:41 +02:00
autoCompleteViewController . view . frame . size . width = view . frame . width
2021-05-14 14:02:59 +02:00
}
}
2021-03-11 12:26:10 +01:00
}
extension ComposeViewController {
2021-03-16 07:19:12 +01:00
2021-06-28 13:41:41 +02:00
private func textEditorView ( ) -> MetaText ? {
return viewModel . composeStatusContentTableViewCell . metaText
2021-03-16 07:19:12 +01:00
}
private func markTextEditorViewBecomeFirstResponser ( ) {
2021-06-28 13:41:41 +02:00
textEditorView ( ) ? . textView . becomeFirstResponder ( )
2021-03-11 12:26:10 +01:00
}
2021-03-12 08:57:58 +01:00
2021-03-26 12:16:19 +01:00
private func contentWarningEditorTextView ( ) -> UITextView ? {
2021-06-28 13:41:41 +02:00
viewModel . composeStatusContentTableViewCell . statusContentWarningEditorView . textView
2021-03-26 12:16:19 +01:00
}
2021-06-29 10:41:58 +02:00
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
}
2021-03-23 11:47:21 +01:00
2021-06-29 10:41:58 +02:00
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 )
}
2021-03-23 11:47:21 +01:00
2021-06-29 10:41:58 +02:00
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 )
}
2021-03-23 11:47:21 +01:00
2021-06-29 10:41:58 +02:00
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 ( )
}
2021-03-23 11:47:21 +01:00
2021-03-12 08:57:58 +01:00
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 ,
2021-03-12 08:57:58 +01:00
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 ( ) {
let selectionLimit = max ( 1 , 4 - viewModel . attachmentServices . value . count )
2021-05-31 10:42:49 +02:00
let configuration = ComposeViewController . createPhotoLibraryPickerConfiguration ( selectionLimit : selectionLimit )
photoLibraryPicker = createImagePicker ( configuration : configuration )
2021-03-18 10:33:07 +01:00
}
private func createImagePicker ( configuration : PHPickerConfiguration ) -> PHPickerViewController {
let imagePicker = PHPickerViewController ( configuration : configuration )
imagePicker . delegate = self
return imagePicker
}
2021-03-26 12:16:19 +01:00
2021-07-06 12:00:39 +02:00
private func setupBackgroundColor ( theme : Theme ) {
view . backgroundColor = theme . systemElevatedBackgroundColor
tableView . backgroundColor = theme . systemElevatedBackgroundColor
2021-07-20 13:24:24 +02:00
composeToolbarBackgroundView . backgroundColor = theme . composeToolbarBackgroundColor
2021-07-06 12:00:39 +02:00
}
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 )
2021-03-12 08:57:58 +01:00
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 )
2021-05-31 10:42:49 +02:00
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
}
2021-03-18 10:33:07 +01:00
guard viewModel . publishStateMachine . enter ( ComposeViewModel . PublishState . Publishing . self ) else {
// TODO: h a n d l e e r r o r
return
}
2021-03-29 11:44:52 +02:00
context . statusPublishService . publish ( composeViewModel : viewModel )
2021-03-18 10:33:07 +01:00
dismiss ( animated : true , completion : nil )
}
2021-06-28 13:41:41 +02:00
}
// MARK: - M e t a T e x t D e l e g a t e
extension ComposeViewController : MetaTextDelegate {
func metaText ( _ metaText : MetaText , processEditing textStorage : MetaTextStorage ) -> MetaContent ? {
let string = metaText . textStorage . string
let content = MastodonContent (
content : string ,
emojis : viewModel . customEmojiViewModel . value ? . emojiMapping . value ? ? [ : ]
)
let metaContent = MastodonMetaContent . convert ( text : content )
return metaContent
}
}
// MARK: - U I T e x t V i e w D e l e g a t e
extension ComposeViewController : UITextViewDelegate {
func textViewDidChange ( _ textView : UITextView ) {
2021-06-29 10:41:58 +02:00
if textEditorView ( ) ? . textView = = = textView {
2021-06-28 13:41:41 +02:00
// u p d a t e m o d e l
guard let metaText = textEditorView ( ) else { return }
let backedString = metaText . backedString
viewModel . composeStatusAttribute . composeContent . value = backedString
logger . debug ( " \( ( #file as NSString ) . lastPathComponent , privacy : . public ) [ \( #line , privacy : . public ) ], \( #function , privacy : . public ) : \( backedString ) " )
// c o n f i g u r e a u t o c o m p l e t i o n
setupAutoComplete ( for : textView )
}
}
struct AutoCompleteInfo {
// m o d e l
let inputText : Substring
// r a n g e
let symbolRange : Range < String . Index >
let symbolString : Substring
let toCursorRange : Range < String . Index >
let toCursorString : Substring
let toHighlightEndRange : Range < String . Index >
let toHighlightEndString : Substring
// g e o m e t r y
var textBoundingRect : CGRect = . zero
var symbolBoundingRect : CGRect = . zero
}
private func setupAutoComplete ( for textView : UITextView ) {
guard var autoCompletion = ComposeViewController . scanAutoCompleteInfo ( textView : textView ) else {
viewModel . autoCompleteInfo . value = nil
return
}
os_log ( . info , log : . debug , " %{public}s[%{public}ld], %{public}s: auto complete %s (%s) " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function , String ( autoCompletion . toHighlightEndString ) , String ( autoCompletion . toCursorString ) )
// g e t l a y o u t t e x t b o u n d i n g r e c t
var glyphRange = NSRange ( )
textView . layoutManager . characterRange ( forGlyphRange : NSRange ( autoCompletion . toCursorRange , in : textView . text ) , actualGlyphRange : & glyphRange )
let textContainer = textView . layoutManager . textContainers [ 0 ]
let textBoundingRect = textView . layoutManager . boundingRect ( forGlyphRange : glyphRange , in : textContainer )
let retryLayoutTimes = viewModel . autoCompleteRetryLayoutTimes . value
guard textBoundingRect . size != . zero else {
viewModel . autoCompleteRetryLayoutTimes . value += 1
// a v o i d i n f i n i t e l o o p
guard retryLayoutTimes < 3 else { return }
// n e e d s r e t r y c a l c u l a t e l a y o u t w h e n t h e r e c t p o s i t i o n c h a n g i n g
DispatchQueue . main . async {
self . setupAutoComplete ( for : textView )
2021-03-24 08:46:40 +01:00
}
2021-06-28 13:41:41 +02:00
return
}
viewModel . autoCompleteRetryLayoutTimes . value = 0
// g e t s y m b o l b o u n d i n g r e c t
textView . layoutManager . characterRange ( forGlyphRange : NSRange ( autoCompletion . symbolRange , in : textView . text ) , actualGlyphRange : & glyphRange )
let symbolBoundingRect = textView . layoutManager . boundingRect ( forGlyphRange : glyphRange , in : textContainer )
// s e t b o u n d i n g r e c t a n d t r i g g e r l a y o u t
autoCompletion . textBoundingRect = textBoundingRect
autoCompletion . symbolBoundingRect = symbolBoundingRect
viewModel . autoCompleteInfo . value = autoCompletion
}
private static func scanAutoCompleteInfo ( textView : UITextView ) -> AutoCompleteInfo ? {
guard let text = textView . text ,
textView . selectedRange . location > 0 , ! text . isEmpty ,
let selectedRange = Range ( textView . selectedRange , in : text ) else {
return nil
}
let cursorIndex = selectedRange . upperBound
let _highlightStartIndex : String . Index ? = {
var index = text . index ( before : cursorIndex )
while index > text . startIndex {
let char = text [ index ]
if char = = " @ " || char = = " # " || char = = " : " {
return index
}
index = text . index ( before : index )
2021-03-22 10:48:35 +01:00
}
2021-06-28 13:41:41 +02:00
assert ( index = = text . startIndex )
let char = text [ index ]
if char = = " @ " || char = = " # " || char = = " : " {
return index
} else {
return nil
2021-03-22 10:48:35 +01:00
}
2021-06-28 13:41:41 +02:00
} ( )
2021-03-22 10:48:35 +01:00
2021-06-28 13:41:41 +02:00
guard let highlightStartIndex = _highlightStartIndex else { return nil }
let scanRange = NSRange ( highlightStartIndex . . < text . endIndex , in : text )
guard let match = text . firstMatch ( pattern : MastodonRegex . autoCompletePattern , options : [ ] , range : scanRange ) else { return nil }
guard let matchRange = Range ( match . range ( at : 0 ) , in : text ) else { return nil }
let matchStartIndex = matchRange . lowerBound
let matchEndIndex = matchRange . upperBound
guard matchStartIndex = = highlightStartIndex , matchEndIndex >= cursorIndex else { return nil }
let symbolRange = highlightStartIndex . . < text . index ( after : highlightStartIndex )
let symbolString = text [ symbolRange ]
let toCursorRange = highlightStartIndex . . < cursorIndex
let toCursorString = text [ toCursorRange ]
let toHighlightEndRange = matchStartIndex . . < matchEndIndex
let toHighlightEndString = text [ toHighlightEndRange ]
let inputText = toHighlightEndString
let autoCompleteInfo = AutoCompleteInfo (
inputText : inputText ,
symbolRange : symbolRange ,
symbolString : symbolString ,
toCursorRange : toCursorRange ,
toCursorString : toCursorString ,
toHighlightEndRange : toHighlightEndRange ,
toHighlightEndString : toHighlightEndString
)
return autoCompleteInfo
2021-03-22 10:48:35 +01:00
}
2021-06-28 13:41:41 +02:00
2021-06-29 13:27:08 +02:00
func textView ( _ textView : UITextView , shouldInteractWith URL : URL , in characterRange : NSRange , interaction : UITextItemInteraction ) -> Bool {
if textView = = = textEditorView ( ) ? . textView {
return false
}
return true
}
func textView ( _ textView : UITextView , shouldInteractWith textAttachment : NSTextAttachment , in characterRange : NSRange , interaction : UITextItemInteraction ) -> Bool {
if textView = = = textEditorView ( ) ? . textView {
return false
}
return true
}
2021-03-11 08:41:27 +01:00
}
2021-03-12 08:23:28 +01:00
// MARK: - C o m p o s e T o o l b a r V i e w D e l e g a t e
extension ComposeViewController : ComposeToolbarViewDelegate {
2021-03-25 12:34:30 +01:00
func composeToolbarView ( _ composeToolbarView : ComposeToolbarView , cameraButtonDidPressed sender : UIButton , mediaSelectionType type : ComposeToolbarView . MediaSelectionType ) {
switch type {
2021-03-19 12:49:48 +01:00
case . photoLibrary :
2021-05-31 10:42:49 +02:00
present ( photoLibraryPicker , animated : true , completion : nil )
2021-03-19 12:49:48 +01:00
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 ) {
2021-07-06 11:06:39 +02:00
// t o g g l e p o l l c o m p o s i n g s t a t e
2021-03-23 11:47:21 +01:00
viewModel . isPollComposing . value . toggle ( )
2021-07-06 11:06:39 +02:00
// c a n c e l c u s t o m p i c k e r i n p u t
viewModel . isCustomEmojiComposing . value = false
2021-03-23 11:47:21 +01:00
// s e t u p i n i t i a l p o l l o p t i o n i f n e e d s
2021-03-24 08:46:40 +01:00
if viewModel . isPollComposing . value , viewModel . pollOptionAttributes . value . isEmpty {
2021-06-29 10:41:58 +02:00
viewModel . pollOptionAttributes . value = [ ComposeStatusPollItem . PollOptionAttribute ( ) , ComposeStatusPollItem . PollOptionAttribute ( ) ]
2021-03-23 11:47:21 +01:00
}
2021-06-29 10:41:58 +02:00
if viewModel . isPollComposing . value {
// M a g i c R u n L o o p
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 ) {
2021-07-06 11:06:39 +02:00
// c a n c e l c u s t o m p i c k e r i n p u t
viewModel . isCustomEmojiComposing . value = false
2021-03-26 12:16:19 +01:00
// r e s t o r e f i r s t r e s p o n d e r f o r t e x t e d i t o r w h e n c o n t e n t w a r n i n g d i s m i s s
if viewModel . isContentWarningComposing . value {
if contentWarningEditorTextView ( ) ? . isFirstResponder = = true {
markTextEditorViewBecomeFirstResponser ( )
}
}
// t o g g l e c o m p o s i n g s t a t u s
2021-03-25 11:17:05 +01:00
viewModel . isContentWarningComposing . value . toggle ( )
2021-03-26 12:16:19 +01:00
// a c t i v e c o n t e n t w a r n i n g a f t e r t o g g l e d
if viewModel . isContentWarningComposing . value {
contentWarningEditorTextView ( ) ? . becomeFirstResponder ( )
}
2021-03-12 08:23:28 +01:00
}
2021-03-25 12:34:30 +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-04-15 06:10:43 +02:00
// MARK: - U I S c r o l l V i e w D e l e g a t e
extension ComposeViewController {
func scrollViewWillEndDragging ( _ scrollView : UIScrollView , withVelocity velocity : CGPoint , targetContentOffset : UnsafeMutablePointer < CGPoint > ) {
2021-06-28 13:41:41 +02:00
guard scrollView = = = tableView else { return }
2021-04-15 06:10:43 +02:00
let repliedToCellFrame = viewModel . repliedToCellFrame . value
guard repliedToCellFrame != . zero else { return }
2021-07-06 10:38:14 +02:00
// t r y t o f i n d s o m e p a t t e r n s :
// p r i n t ( " " "
// r e p l i e d T o C e l l F r a m e : \ ( v i e w M o d e l . r e p l i e d T o C e l l F r a m e . v a l u e . h e i g h t )
// s c r o l l V i e w . c o n t e n t O f f s e t . y : \ ( s c r o l l V i e w . c o n t e n t O f f s e t . y )
// s c r o l l V i e w . c o n t e n t S i z e . h e i g h t : \ ( s c r o l l V i e w . c o n t e n t S i z e . h e i g h t )
// s c r o l l V i e w . f r a m e : \ ( s c r o l l V i e w . f r a m e )
// s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . t o p : \ ( s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . t o p )
// s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . b o t t o m : \ ( s c r o l l V i e w . a d j u s t e d C o n t e n t I n s e t . b o t t o m )
// " " " )
2021-04-15 06:10:43 +02:00
switch viewModel . collectionViewState . value {
case . fold :
2021-07-06 10:38:14 +02:00
os_log ( " %{public}s[%{public}ld], %{public}s: fold " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
guard velocity . y < 0 else { return }
let offsetY = scrollView . contentOffset . y + scrollView . adjustedContentInset . top
if offsetY < - 44 {
tableView . contentInset . top = 0
targetContentOffset . pointee = CGPoint ( x : 0 , y : - scrollView . adjustedContentInset . top )
2021-04-15 06:10:43 +02:00
viewModel . collectionViewState . value = . expand
}
case . expand :
2021-07-06 10:38:14 +02:00
os_log ( " %{public}s[%{public}ld], %{public}s: expand " , ( ( #file as NSString ) . lastPathComponent ) , #line , #function )
guard velocity . y > 0 else { return }
// c h e c k i f t o p a c r o s s
let topOffset = ( scrollView . contentOffset . y + scrollView . adjustedContentInset . top ) - repliedToCellFrame . height
// c h e c k i f b o t t o m b o u n c e
let bottomOffsetY = scrollView . contentOffset . y + ( scrollView . frame . height - scrollView . adjustedContentInset . bottom )
let bottomOffset = bottomOffsetY - scrollView . contentSize . height
if topOffset > 44 {
// d o n o t i n t e r r u p t u s e r s c r o l l i n g
viewModel . collectionViewState . value = . fold
} else if bottomOffset > 44 {
tableView . contentInset . top = - repliedToCellFrame . height
targetContentOffset . pointee = CGPoint ( x : 0 , y : - repliedToCellFrame . height )
2021-04-15 06:10:43 +02:00
viewModel . collectionViewState . value = . fold
}
}
}
}
2021-06-28 13:41:41 +02:00
// MARK: - U I T a b l e V i e w D e l e g a t e
extension ComposeViewController : UITableViewDelegate { }
2021-05-18 08:25:32 +02:00
// MARK: - U I C o l l e c t i o n V i e w D e l e g a t e
2021-03-22 10:48:35 +01:00
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
2021-06-28 13:41:41 +02:00
// m a k e c l i c k s o u n d
UIDevice . current . playInputClick ( )
2021-06-02 08:59:39 +02:00
// r e t r i e v e a c t i v e t e x t i n p u t a n d i n s e r t e m o j i
2021-06-28 13:41:41 +02:00
// t h e t r a i l i n g s p a c e i s R E Q U I R E D t o m a k e r e g e x h a p p y
_ = viewModel . customEmojiPickerInputViewModel . insertText ( " : \( emoji . shortcode ) : " )
2021-03-25 08:56:17 +01:00
} else {
// d o n o t h i n g
}
}
2021-03-11 08:41:27 +01:00
}
2021-03-15 07:40:10 +01:00
// MARK: - U I A d a p t i v e P r e s e n t a t i o n C o n t r o l l e r D e l e g a t e
2021-03-11 08:41:27 +01:00
extension ComposeViewController : UIAdaptivePresentationControllerDelegate {
2021-04-15 06:10:43 +02:00
func adaptivePresentationStyle ( for controller : UIPresentationController , traitCollection : UITraitCollection ) -> UIModalPresentationStyle {
2021-07-15 09:49:30 +02:00
return . overFullScreen
2021-07-06 10:38:14 +02:00
// r e t u r n t r a i t C o l l e c t i o n . u s e r I n t e r f a c e I d i o m = = . p a d ? . f o r m S h e e t : . a u t o m a t i c
2021-04-15 06:10:43 +02:00
}
2021-03-12 08:57:58 +01:00
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 )
2021-03-12 08:57:58 +01:00
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 )
}
}
2021-03-18 08:16:35 +01:00
// MARK: - P H P i c k e r V i e w C o n t r o l l e r D e l e g a t e
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 ,
2021-06-02 08:59:39 +02:00
initialAuthenticationBox : viewModel . activeAuthenticationBox . value
2021-03-18 12:42:26 +01:00
)
return service
}
2021-03-18 08:16:35 +01:00
viewModel . attachmentServices . value = viewModel . attachmentServices . value + attachmentServices
}
}
2021-03-19 12:49:48 +01:00
// MARK: - U I I m a g e P i c k e r C o n t r o l l e r D e l e g a t e
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 ,
2021-06-02 08:59:39 +02:00
initialAuthenticationBox : viewModel . activeAuthenticationBox . value
2021-03-19 12:49:48 +01:00
)
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: - U I D o c u m e n t P i c k e r D e l e g a t e
extension ComposeViewController : UIDocumentPickerDelegate {
func documentPicker ( _ controller : UIDocumentPickerViewController , didPickDocumentsAt urls : [ URL ] ) {
guard let url = urls . first else { return }
2021-05-31 12:03:31 +02:00
let attachmentService = MastodonAttachmentService (
context : context ,
documentURL : url ,
2021-06-02 08:59:39 +02:00
initialAuthenticationBox : viewModel . activeAuthenticationBox . value
2021-05-31 12:03:31 +02:00
)
viewModel . attachmentServices . value = viewModel . attachmentServices . value + [ attachmentService ]
2021-03-19 12:49:48 +01:00
}
}
2021-03-18 08:16:35 +01:00
// MARK: - C o m p o s e S t a t u s A t t a c h m e n t T a b l e V i e w C e l l D e l e g a t e
2021-03-22 10:48:35 +01:00
extension ComposeViewController : ComposeStatusAttachmentCollectionViewCellDelegate {
2021-03-18 08:16:35 +01:00
2021-03-22 10:48:35 +01:00
func composeStatusAttachmentCollectionViewCell ( _ cell : ComposeStatusAttachmentCollectionViewCell , removeButtonDidPressed button : UIButton ) {
2021-06-29 10:41:58 +02:00
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 . value
guard let index = attachmentServices . firstIndex ( of : attachmentService ) else { return }
let removedItem = attachmentServices [ index ]
attachmentServices . remove ( at : index )
viewModel . attachmentServices . value = attachmentServices
// c a n c e l t a s k
removedItem . disposeBag . removeAll ( )
2021-03-18 08:16:35 +01:00
}
}
2021-03-23 11:47:21 +01:00
// MARK: - C o m p o s e S t a t u s P o l l O p t i o n C o l l e c t i o n V i e w C e l l D e l e g a t e
extension ComposeViewController : ComposeStatusPollOptionCollectionViewCellDelegate {
2021-03-26 12:16:19 +01:00
func composeStatusPollOptionCollectionViewCell ( _ cell : ComposeStatusPollOptionCollectionViewCell , textFieldDidBeginEditing textField : UITextField ) {
// FIXME: m a k e p o l l s e c t i o n v i s i b l e
// D i s p a t c h Q u e u e . m a i n . a s y n c {
// s e l f . c o l l e c t i o n V i e w . s c r o l l ( t o : . b o t t o m , a n i m a t e d : t r u e )
// }
}
2021-03-23 11:47:21 +01:00
// h a n d l e d e l e t e b a c k w a r d e v e n t f o r p o l l o p t i o n i n p u t
func composeStatusPollOptionCollectionViewCell ( _ cell : ComposeStatusPollOptionCollectionViewCell , textBeforeDeleteBackward text : String ? ) {
2021-06-29 10:41:58 +02:00
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 . value
guard let index = pollAttributes . firstIndex ( of : attribute ) else { return }
// m a r k p r e v i o u s ( f a l l b a c k t o n e x t ) i t e m o f r e m o v e d m i d d l e p o l l o p t i o n b e c o m e f i r s t r e s p o n d e r
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 )
// u p d a t e d a t a s o u r c e
viewModel . pollOptionAttributes . value = pollAttributes
2021-03-23 11:47:21 +01:00
}
// h a n d l e k e y b o a r d r e t u r n e v e n t f o r p o l l o p t i o n i n p u t
func composeStatusPollOptionCollectionViewCell ( _ cell : ComposeStatusPollOptionCollectionViewCell , pollOptionTextFieldDidReturn : UITextField ) {
2021-06-29 10:41:58 +02:00
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 {
// i s t h e l a s t
viewModel . createNewPollOptionIfPossible ( )
DispatchQueue . main . async {
self . markLastPollOptionCollectionViewCellBecomeFirstResponser ( )
}
} else {
// n o t t h e l a s t
let indexAfter = pollItems . index ( after : index )
let itemAfter = pollItems [ indexAfter ]
let cell = pollOptionCollectionViewCell ( of : itemAfter )
cell ? . pollOptionView . optionTextField . becomeFirstResponder ( )
}
2021-03-23 11:47:21 +01:00
}
}
2021-03-24 07:49:27 +01:00
// MARK: - C o m p o s e S t a t u s P o l l O p t i o n A p p e n d E n t r y C o l l e c t i o n V i e w C e l l D e l e g a t e
extension ComposeViewController : ComposeStatusPollOptionAppendEntryCollectionViewCellDelegate {
func composeStatusPollOptionAppendEntryCollectionViewCellDidPressed ( _ cell : ComposeStatusPollOptionAppendEntryCollectionViewCell ) {
2021-03-23 11:47:21 +01:00
viewModel . createNewPollOptionIfPossible ( )
2021-06-29 10:41:58 +02:00
DispatchQueue . main . async {
self . markLastPollOptionCollectionViewCellBecomeFirstResponser ( )
}
2021-03-23 11:47:21 +01:00
}
}
2021-03-24 07:49:27 +01:00
// MARK: - C o m p o s e S t a t u s P o l l E x p i r e s O p t i o n C o l l e c t i o n V i e w C e l l D e l e g a t e
extension ComposeViewController : ComposeStatusPollExpiresOptionCollectionViewCellDelegate {
2021-06-29 10:41:58 +02:00
func composeStatusPollExpiresOptionCollectionViewCell ( _ cell : ComposeStatusPollExpiresOptionCollectionViewCell , didSelectExpiresOption expiresOption : ComposeStatusPollItem . PollExpiresOptionAttribute . ExpiresOption ) {
2021-03-24 07:49:27 +01:00
viewModel . pollExpiresOptionAttribute . expiresOption . value = expiresOption
}
}
2021-05-18 09:06:00 +02:00
// MARK: - A u t o C o m p l e t e V i e w C o n t r o l l e r D e l e g a t e
extension ComposeViewController : AutoCompleteViewControllerDelegate {
func autoCompleteViewController ( _ viewController : AutoCompleteViewController , didSelectItem item : AutoCompleteItem ) {
guard let info = viewModel . autoCompleteInfo . value else { return }
let _replacedText : String ? = {
var text : String
switch item {
case . hashtag ( let hashtag ) :
text = " # " + hashtag . name
case . hashtagV1 ( let hashtagName ) :
text = " # " + hashtagName
case . account ( let account ) :
text = " @ " + account . acct
case . emoji ( let emoji ) :
text = " : " + emoji . shortcode + " : "
case . bottomLoader :
return nil
}
text . append ( " " )
return text
} ( )
guard let replacedText = _replacedText else { return }
2021-06-28 13:41:41 +02:00
guard let textEditorView = textEditorView ( ) ,
let text = textEditorView . textView . text else { return }
let range = NSRange ( info . toHighlightEndRange , in : text )
textEditorView . textStorage . replaceCharacters ( in : range , with : replacedText )
viewModel . autoCompleteInfo . value = nil
switch item {
case . emoji , . bottomLoader :
break
default :
// s e t s e l e c t e d r a n g e e x c e p t e m o j i
let newRange = NSRange ( location : range . location + ( replacedText as NSString ) . length , length : 0 )
guard textEditorView . textStorage . length <= newRange . location else { return }
textEditorView . textView . selectedRange = newRange
2021-05-18 09:06:00 +02:00
}
}
}
2021-05-21 13:12:01 +02:00
extension ComposeViewController {
override var keyCommands : [ UIKeyCommand ] ? {
composeKeyCommands
}
}
extension ComposeViewController {
enum ComposeKeyCommand : String , CaseIterable {
case discardPost
case publishPost
case mediaBrowse
case mediaPhotoLibrary
case mediaCamera
case togglePoll
case toggleContentWarning
case selectVisibilityPublic
2021-06-15 12:58:43 +02:00
// TODO: r e m o v e s e l e c t V i s i b i l i t y U n l i s t e d f r o m c o d e b a s e
// c a s e s e l e c t V i s i b i l i t y U n l i s t e d
2021-05-21 13:12:01 +02:00
case selectVisibilityPrivate
case selectVisibilityDirect
var title : String {
switch self {
case . discardPost : return L10n . Scene . Compose . Keyboard . discardPost
case . publishPost : return L10n . Scene . Compose . Keyboard . publishPost
case . mediaBrowse : return L10n . Scene . Compose . Keyboard . appendAttachmentEntry ( L10n . Scene . Compose . MediaSelection . browse )
case . mediaPhotoLibrary : return L10n . Scene . Compose . Keyboard . appendAttachmentEntry ( L10n . Scene . Compose . MediaSelection . photoLibrary )
case . mediaCamera : return L10n . Scene . Compose . Keyboard . appendAttachmentEntry ( L10n . Scene . Compose . MediaSelection . camera )
case . togglePoll : return L10n . Scene . Compose . Keyboard . togglePoll
case . toggleContentWarning : return L10n . Scene . Compose . Keyboard . toggleContentWarning
case . selectVisibilityPublic : return L10n . Scene . Compose . Keyboard . selectVisibilityEntry ( L10n . Scene . Compose . Visibility . public )
2021-06-15 12:58:43 +02:00
// c a s e . s e l e c t V i s i b i l i t y U n l i s t e d : r e t u r n L 1 0 n . S c e n e . C o m p o s e . K e y b o a r d . s e l e c t V i s i b i l i t y E n t r y ( L 1 0 n . S c e n e . C o m p o s e . V i s i b i l i t y . u n l i s t e d )
2021-05-21 13:12:01 +02:00
case . selectVisibilityPrivate : return L10n . Scene . Compose . Keyboard . selectVisibilityEntry ( L10n . Scene . Compose . Visibility . private )
case . selectVisibilityDirect : return L10n . Scene . Compose . Keyboard . selectVisibilityEntry ( L10n . Scene . Compose . Visibility . direct )
}
}
// U I K e y C o m m a n d i n p u t
var input : String {
switch self {
case . discardPost : return " w " // + c o m m a n d
case . publishPost : return " \r " // ( e n t e r ) + c o m m a n d
case . mediaBrowse : return " b " // + o p t i o n + c o m m a n d
case . mediaPhotoLibrary : return " p " // + o p t i o n + c o m m a n d
case . mediaCamera : return " c " // + o p t i o n + c o m m a n d
case . togglePoll : return " p " // + s h i f t + c o m m a n d
case . toggleContentWarning : return " c " // + s h i f t + c o m m a n d
case . selectVisibilityPublic : return " 1 " // + c o m m a n d
2021-06-15 12:58:43 +02:00
// c a s e . s e l e c t V i s i b i l i t y U n l i s t e d : r e t u r n " 2 " / / + c o m m a n d
case . selectVisibilityPrivate : return " 2 " // + c o m m a n d
case . selectVisibilityDirect : return " 3 " // + c o m m a n d
2021-05-21 13:12:01 +02:00
}
}
var modifierFlags : UIKeyModifierFlags {
switch self {
case . discardPost : return [ . command ]
case . publishPost : return [ . command ]
case . mediaBrowse : return [ . alternate , . command ]
case . mediaPhotoLibrary : return [ . alternate , . command ]
case . mediaCamera : return [ . alternate , . command ]
case . togglePoll : return [ . shift , . command ]
case . toggleContentWarning : return [ . shift , . command ]
case . selectVisibilityPublic : return [ . command ]
2021-06-15 12:58:43 +02:00
// c a s e . s e l e c t V i s i b i l i t y U n l i s t e d : r e t u r n [ . c o m m a n d ]
2021-05-21 13:12:01 +02:00
case . selectVisibilityPrivate : return [ . command ]
case . selectVisibilityDirect : return [ . command ]
}
}
var propertyList : Any {
return rawValue
}
}
var composeKeyCommands : [ UIKeyCommand ] ? {
ComposeKeyCommand . allCases . map { command in
UIKeyCommand (
title : command . title ,
image : nil ,
action : #selector ( Self . composeKeyCommandHandler ( _ : ) ) ,
input : command . input ,
modifierFlags : command . modifierFlags ,
propertyList : command . propertyList ,
alternates : [ ] ,
discoverabilityTitle : nil ,
attributes : [ ] ,
state : . off
)
}
}
@objc private func composeKeyCommandHandler ( _ sender : UIKeyCommand ) {
guard let rawValue = sender . propertyList as ? String ,
let command = ComposeKeyCommand ( rawValue : rawValue ) else { return }
switch command {
case . discardPost :
cancelBarButtonItemPressed ( cancelBarButtonItem )
case . publishPost :
publishBarButtonItemPressed ( publishBarButtonItem )
case . mediaBrowse :
present ( documentPickerController , animated : true , completion : nil )
case . mediaPhotoLibrary :
2021-05-31 10:42:49 +02:00
present ( photoLibraryPicker , animated : true , completion : nil )
2021-05-21 13:12:01 +02:00
case . mediaCamera :
guard UIImagePickerController . isSourceTypeAvailable ( . camera ) else {
return
}
present ( imagePickerController , animated : true , completion : nil )
case . togglePoll :
composeToolbarView . pollButton . sendActions ( for : . touchUpInside )
case . toggleContentWarning :
composeToolbarView . contentWarningButton . sendActions ( for : . touchUpInside )
case . selectVisibilityPublic :
viewModel . selectedStatusVisibility . value = . public
2021-06-15 12:58:43 +02:00
// c a s e . s e l e c t V i s i b i l i t y U n l i s t e d :
// v i e w M o d e l . s e l e c t e d S t a t u s V i s i b i l i t y . v a l u e = . u n l i s t e d
2021-05-21 13:12:01 +02:00
case . selectVisibilityPrivate :
viewModel . selectedStatusVisibility . value = . private
case . selectVisibilityDirect :
viewModel . selectedStatusVisibility . value = . direct
}
}
}