2020-08-31 23:28:50 +00:00
//
// C o m p o s e H o s t i n g C o n t r o l l e r . s w i f t
// T u s k e r
//
// C r e a t e d b y S h a d o w f a c t s o n 8 / 2 2 / 2 0 .
// C o p y r i g h t © 2 0 2 0 S h a d o w f a c t s . A l l r i g h t s r e s e r v e d .
//
import SwiftUI
import Combine
import Pachyderm
import PencilKit
2021-05-22 17:42:53 +00:00
protocol ComposeHostingControllerDelegate : AnyObject {
2020-12-14 03:37:37 +00:00
func dismissCompose ( mode : ComposeUIState . DismissMode ) -> Bool
}
2020-08-31 23:28:50 +00:00
class ComposeHostingController : UIHostingController < ComposeContainerView > {
2020-12-14 03:37:37 +00:00
weak var delegate : ComposeHostingControllerDelegate ?
2020-08-31 23:28:50 +00:00
let mastodonController : MastodonController
let uiState : ComposeUIState
var draft : Draft { uiState . draft }
private var cancellables = [ AnyCancellable ] ( )
2020-09-07 21:05:50 +00:00
private var toolbarHeight : CGFloat = 44
2020-08-31 23:28:50 +00:00
private var mainToolbar : UIToolbar !
private var inputAccessoryToolbar : UIToolbar !
override var inputAccessoryView : UIView ? { inputAccessoryToolbar }
init ( draft : Draft ? = nil , mastodonController : MastodonController ) {
self . mastodonController = mastodonController
let realDraft = draft ? ? Draft ( accountID : mastodonController . accountInfo ! . id )
DraftsManager . shared . add ( realDraft )
self . uiState = ComposeUIState ( draft : realDraft )
// w e n e e d o u r o w n e n v i r o n m e n t o b j e c t w r a p p e r s o t h a t w e c a n s e t t h e m a s t o d o n c o n t r o l l e r a s a n
// e n v i r o n m e n t o b j e c t a n d s e t u p t h e d r a f t c h a n g e l i s t e n e r w h i l e s t i l l h a v i n g a c o n c r e t e t y p e
// t o u s e a s t h e U I H o s t i n g C o n t r o l l e r t y p e p a r a m e t e r
let container = ComposeContainerView (
mastodonController : mastodonController ,
uiState : uiState
)
super . init ( rootView : container )
self . uiState . delegate = self
// m a i n t o o l b a r i s s h o w n a t t h e b o t t o m o f t h e s c r e e n , t h e i n p u t a c c e s s o r y i s a t t a c h e d t o t h e k e y b o a r d w h i l e e d i t i n g
2022-01-24 04:44:38 +00:00
// ( e x c e p t f o r M a i n C o m p o s e T e x t V i e w w h i c h h a s i t s o w n a c c e s s o r y t o a d d f o r m a t t i n g b u t t o n s )
2022-04-09 15:41:27 +00:00
mainToolbar = UIToolbar ( )
mainToolbar . translatesAutoresizingMaskIntoConstraints = false
mainToolbar . isAccessibilityElement = true
setupToolbarItems ( toolbar : mainToolbar , input : nil )
inputAccessoryToolbar = UIToolbar ( )
inputAccessoryToolbar . translatesAutoresizingMaskIntoConstraints = false
inputAccessoryToolbar . isAccessibilityElement = true
setupToolbarItems ( toolbar : inputAccessoryToolbar , input : nil )
2020-08-31 23:28:50 +00:00
2022-04-25 20:30:44 +00:00
NotificationCenter . default . addObserver ( self , selector : #selector ( composeKeyboardWillShow ( _ : ) ) , name : UIResponder . keyboardWillShowNotification , object : nil )
NotificationCenter . default . addObserver ( self , selector : #selector ( composeKeyboardWillHide ( _ : ) ) , name : UIResponder . keyboardWillHideNotification , object : nil )
NotificationCenter . default . addObserver ( self , selector : #selector ( composeKeyboardDidHide ( _ : ) ) , name : UIResponder . keyboardDidHideNotification , object : nil )
2020-08-31 23:28:50 +00:00
// a d d t h e h e i g h t o f t h e t o o l b a r i t s e l f t o t h e b o t t o m o f t h e s a f e a r e a s o c o n t e n t i n s i d e S w i f t U I S c r o l l V i e w d o e s n ' t u n d e r f l o w i t
2020-09-07 21:05:50 +00:00
updateAdditionalSafeAreaInsets ( )
2020-08-31 23:28:50 +00:00
pasteConfiguration = UIPasteConfiguration ( forAccepting : CompositionAttachment . self )
2020-12-14 03:37:37 +00:00
userActivity = UserActivityManager . newPostActivity ( accountID : mastodonController . accountInfo ! . id )
2020-08-31 23:28:50 +00:00
self . uiState . $ draft
. flatMap ( \ . $ visibility )
. sink ( receiveValue : self . visibilityChanged )
. store ( in : & cancellables )
2022-01-24 04:44:38 +00:00
self . uiState . $ draft
. flatMap ( \ . $ localOnly )
. sink ( receiveValue : self . localOnlyChanged )
. store ( in : & cancellables )
2020-08-31 23:28:50 +00:00
self . uiState . $ draft
. flatMap ( \ . objectWillChange )
. debounce ( for : . milliseconds ( 250 ) , scheduler : DispatchQueue . global ( qos : . utility ) )
. sink {
DraftsManager . save ( )
}
. store ( in : & cancellables )
2022-04-09 15:41:27 +00:00
self . uiState . $ currentInput
. sink { [ unowned self ] in
self . setupToolbarItems ( toolbar : self . inputAccessoryToolbar , input : $0 )
}
. store ( in : & cancellables )
2020-08-31 23:28:50 +00:00
}
required init ? ( coder aDecoder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
2020-09-13 17:19:56 +00:00
override func didMove ( toParent parent : UIViewController ? ) {
super . didMove ( toParent : parent )
if let parent = parent {
parent . view . addSubview ( mainToolbar )
2020-08-31 23:28:50 +00:00
NSLayoutConstraint . activate ( [
mainToolbar . leadingAnchor . constraint ( equalTo : view . leadingAnchor ) ,
mainToolbar . trailingAnchor . constraint ( equalTo : view . trailingAnchor ) ,
// u s e t h e t o p a n c h o r o f t h e t o o l b a r s o o u r a d d i t i o n a l S a f e A r e a I n s e t s ( w h i c h h a s t h e b o t t o m a s t h e t o o l b a r h e i g h t ) d o n ' t a f f e c t i t
mainToolbar . topAnchor . constraint ( equalTo : view . safeAreaLayoutGuide . bottomAnchor ) ,
] )
}
}
override func viewWillDisappear ( _ animated : Bool ) {
super . viewWillDisappear ( animated )
if ! draft . hasContent {
DraftsManager . shared . remove ( draft )
}
DraftsManager . save ( )
}
2022-04-09 15:41:27 +00:00
private func setupToolbarItems ( toolbar : UIToolbar , input : ComposeInput ? ) {
var items : [ UIBarButtonItem ] = [ ]
items . append ( UIBarButtonItem ( title : " CW " , style : . plain , target : self , action : #selector ( cwButtonPressed ) ) )
2020-08-31 23:28:50 +00:00
2022-01-24 04:44:38 +00:00
let visibilityItem = UIBarButtonItem ( image : nil , style : . plain , target : nil , action : nil )
2022-06-10 03:33:31 +00:00
visibilityItem . tag = ViewTags . composeVisibilityBarButton
2022-04-09 15:41:27 +00:00
items . append ( visibilityItem )
2022-01-24 04:44:38 +00:00
if mastodonController . instanceFeatures . localOnlyPosts {
let item = UIBarButtonItem ( image : nil , style : . plain , target : nil , action : nil )
2022-06-10 03:33:31 +00:00
item . tag = ViewTags . composeLocalOnlyBarButton
2022-04-09 15:41:27 +00:00
items . append ( item )
2022-01-24 04:44:38 +00:00
localOnlyChanged ( draft . localOnly )
}
2022-04-09 15:52:09 +00:00
if input ? . toolbarElements . contains ( . emojiPicker ) = = true {
items . append ( UIBarButtonItem ( image : UIImage ( systemName : " face.smiling " ) , style : . plain , target : self , action : #selector ( emojiPickerButtonPressed ) ) )
}
2022-04-09 15:41:27 +00:00
items . append ( UIBarButtonItem ( systemItem : . flexibleSpace ) )
if input ? . toolbarElements . contains ( . formattingButtons ) = = true ,
Preferences . shared . statusContentType != . plain {
for ( idx , format ) in StatusFormat . allCases . enumerated ( ) {
let item : UIBarButtonItem
if let image = format . image {
item = UIBarButtonItem ( image : image , style : . plain , target : self , action : #selector ( formatButtonPressed ( _ : ) ) )
} else if let ( str , attributes ) = format . title {
item = UIBarButtonItem ( title : str , style : . plain , target : self , action : #selector ( formatButtonPressed ( _ : ) ) )
item . setTitleTextAttributes ( attributes , for : . normal )
item . setTitleTextAttributes ( attributes , for : . highlighted )
} else {
fatalError ( " StatusFormat must have either image or title " )
}
item . tag = StatusFormat . allCases . firstIndex ( of : format ) !
item . accessibilityLabel = format . accessibilityLabel
items . append ( item )
if idx != StatusFormat . allCases . count - 1 {
let spacer = UIBarButtonItem ( systemItem : . fixedSpace )
spacer . width = 8
items . append ( spacer )
}
}
items . append ( UIBarButtonItem ( systemItem : . flexibleSpace ) )
}
items . append ( UIBarButtonItem ( title : " Drafts " , style : . plain , target : self , action : #selector ( draftsButtonPresed ) ) )
toolbar . items = items
visibilityChanged ( draft . visibility )
localOnlyChanged ( draft . localOnly )
2020-08-31 23:28:50 +00:00
}
2020-09-07 21:05:50 +00:00
private func updateAdditionalSafeAreaInsets ( ) {
2021-02-06 18:47:45 +00:00
additionalSafeAreaInsets = UIEdgeInsets ( top : 0 , left : 0 , bottom : toolbarHeight , right : 0 )
2020-09-07 21:05:50 +00:00
}
2020-08-31 23:28:50 +00:00
2022-04-25 20:30:44 +00:00
@objc private func composeKeyboardWillShow ( _ notification : Foundation . Notification ) {
2020-08-31 23:28:50 +00:00
keyboardWillShow ( accessoryView : inputAccessoryToolbar , notification : notification )
}
func keyboardWillShow ( accessoryView : UIView , notification : Foundation . Notification ) {
mainToolbar . isHidden = true
accessoryView . alpha = 1
accessoryView . isHidden = false
}
2022-04-25 20:30:44 +00:00
@objc private func composeKeyboardWillHide ( _ notification : Foundation . Notification ) {
2020-08-31 23:28:50 +00:00
keyboardWillHide ( accessoryView : inputAccessoryToolbar , notification : notification )
}
func keyboardWillHide ( accessoryView : UIView , notification : Foundation . Notification ) {
mainToolbar . isHidden = false
let userInfo = notification . userInfo !
let durationObj = userInfo [ UIResponder . keyboardAnimationDurationUserInfoKey ] as ! NSNumber
let duration = TimeInterval ( durationObj . doubleValue )
let curveValue = userInfo [ UIResponder . keyboardAnimationCurveUserInfoKey ] as ! NSNumber
let curve = UIView . AnimationCurve ( rawValue : curveValue . intValue ) !
let curveOption : UIView . AnimationOptions
switch curve {
case . easeInOut :
curveOption = . curveEaseInOut
case . easeIn :
curveOption = . curveEaseIn
case . easeOut :
curveOption = . curveEaseOut
case . linear :
curveOption = . curveLinear
@ unknown default :
curveOption = . curveLinear
}
UIView . animate ( withDuration : duration , delay : 0 , options : curveOption ) {
accessoryView . alpha = 0
} completion : { ( finished ) in
accessoryView . alpha = 1
}
}
2022-04-25 20:30:44 +00:00
@objc private func composeKeyboardDidHide ( _ notification : Foundation . Notification ) {
2020-08-31 23:28:50 +00:00
keyboardDidHide ( accessoryView : inputAccessoryToolbar , notification : notification )
}
func keyboardDidHide ( accessoryView : UIView , notification : Foundation . Notification ) {
accessoryView . isHidden = true
}
private func visibilityChanged ( _ newVisibility : Status . Visibility ) {
2022-04-09 15:41:27 +00:00
for toolbar in [ mainToolbar , inputAccessoryToolbar ] {
2022-06-10 03:33:31 +00:00
guard let item = toolbar ? . items ? . first ( where : { $0 . tag = = ViewTags . composeVisibilityBarButton } ) else {
2022-04-09 15:41:27 +00:00
continue
}
2020-08-31 23:28:50 +00:00
item . image = UIImage ( systemName : newVisibility . imageName )
item . accessibilityLabel = String ( format : NSLocalizedString ( " Visibility: %@ " , comment : " compose visiblity accessibility label " ) , draft . visibility . displayName )
2021-02-06 18:47:45 +00:00
let elements = Status . Visibility . allCases . map { ( visibility ) -> UIMenuElement in
let state = visibility = = newVisibility ? UIMenuElement . State . on : . off
2022-04-28 03:21:08 +00:00
return UIAction ( title : visibility . displayName , subtitle : visibility . subtitle , image : UIImage ( systemName : visibility . unfilledImageName ) , state : state ) { [ unowned self ] ( _ ) in
2021-02-06 18:47:45 +00:00
self . draft . visibility = visibility
2020-09-01 03:07:41 +00:00
}
}
2021-02-06 18:47:45 +00:00
item . menu = UIMenu ( title : " " , image : nil , identifier : nil , options : [ ] , children : elements )
2020-08-31 23:28:50 +00:00
}
}
2022-01-24 04:44:38 +00:00
private func localOnlyChanged ( _ localOnly : Bool ) {
2022-04-09 15:41:27 +00:00
for toolbar in [ mainToolbar , inputAccessoryToolbar ] {
2022-06-10 03:33:31 +00:00
guard let item = toolbar ? . items ? . first ( where : { $0 . tag = = ViewTags . composeLocalOnlyBarButton } ) else {
2022-04-09 15:41:27 +00:00
continue
}
2022-01-24 04:44:38 +00:00
if localOnly {
item . image = UIImage ( named : " link.broken " )
item . accessibilityLabel = " Local-only "
} else {
item . image = UIImage ( systemName : " link " )
item . accessibilityLabel = " Federated "
}
2022-04-28 03:21:08 +00:00
let instanceSubtitle = " Only \( mastodonController . accountInfo ! . instanceURL . host ! ) "
2022-01-24 04:44:38 +00:00
item . menu = UIMenu ( children : [
2022-04-28 03:21:08 +00:00
UIAction ( title : " Local-only " , subtitle : instanceSubtitle , image : UIImage ( named : " link.broken " ) , state : localOnly ? . on : . off ) { [ unowned self ] ( _ ) in
2022-01-24 04:44:38 +00:00
self . draft . localOnly = true
} ,
UIAction ( title : " Federated " , image : UIImage ( systemName : " link " ) , state : localOnly ? . off : . on ) { [ unowned self ] ( _ ) in
self . draft . localOnly = false
} ,
] )
}
}
2020-08-31 23:28:50 +00:00
override func canPaste ( _ itemProviders : [ NSItemProvider ] ) -> Bool {
guard itemProviders . allSatisfy ( { $0 . canLoadObject ( ofClass : CompositionAttachment . self ) } ) else { return false }
2022-01-25 03:49:18 +00:00
if mastodonController . instanceFeatures . mastodonAttachmentRestrictions {
2020-09-07 18:43:39 +00:00
guard draft . attachments . allSatisfy ( { $0 . data . type = = . image } ) else { return false }
// t o d o : i f p r o v i d e r s a r e v i d e o s , t h i s t e c h n i c a l l y a l l o w s i n v a l i d v i d e o / i m a g e c o m b i n a t i o n s
2020-08-31 23:28:50 +00:00
return itemProviders . count + draft . attachments . count <= 4
2022-01-25 03:49:18 +00:00
} else {
return true
2020-08-31 23:28:50 +00:00
}
}
override func paste ( itemProviders : [ NSItemProvider ] ) {
for provider in itemProviders where provider . canLoadObject ( ofClass : CompositionAttachment . self ) {
provider . loadObject ( ofClass : CompositionAttachment . self ) { ( object , error ) in
guard let attachment = object as ? CompositionAttachment else { return }
DispatchQueue . main . async {
self . draft . attachments . append ( attachment )
}
}
}
}
// MARK: I n t e r a c t i o n
@objc func cwButtonPressed ( ) {
draft . contentWarningEnabled = ! draft . contentWarningEnabled
}
2022-04-09 15:41:27 +00:00
@objc func formatButtonPressed ( _ sender : UIBarButtonItem ) {
let format = StatusFormat . allCases [ sender . tag ]
uiState . currentInput ? . applyFormat ( format )
}
2022-04-09 15:52:09 +00:00
@objc func emojiPickerButtonPressed ( ) {
guard uiState . autocompleteState = = nil else {
return
}
uiState . shouldEmojiAutocompletionBeginExpanded = true
uiState . currentInput ? . beginAutocompletingEmoji ( )
}
2020-08-31 23:28:50 +00:00
@objc func draftsButtonPresed ( ) {
let draftsVC = DraftsTableViewController ( account : mastodonController . accountInfo ! , exclude : draft )
draftsVC . delegate = self
present ( UINavigationController ( rootViewController : draftsVC ) , animated : true )
}
}
extension ComposeHostingController : ComposeUIStateDelegate {
var assetPickerDelegate : AssetPickerViewControllerDelegate ? { self }
2020-12-14 03:37:37 +00:00
func dismissCompose ( mode : ComposeUIState . DismissMode ) {
let dismissed = delegate ? . dismissCompose ( mode : mode ) ? ? false
if ! dismissed {
self . dismiss ( animated : true )
}
2020-08-31 23:28:50 +00:00
}
func presentAssetPickerSheet ( ) {
2021-06-09 23:12:10 +00:00
if #available ( iOS 15.0 , * ) {
let picker = AssetPickerViewController ( )
picker . assetPickerDelegate = self
2021-06-26 21:02:17 +00:00
picker . modalPresentationStyle = . pageSheet
2021-06-09 23:12:10 +00:00
picker . overrideUserInterfaceStyle = . dark
2021-06-26 21:02:17 +00:00
let sheet = picker . sheetPresentationController !
2021-06-09 23:12:10 +00:00
sheet . detents = [ . medium ( ) , . large ( ) ]
sheet . prefersEdgeAttachedInCompactHeight = true
self . present ( picker , animated : true )
} else {
presentOldAssetPickerSheet ( )
}
}
private func presentOldAssetPickerSheet ( ) {
2020-08-31 23:28:50 +00:00
let sheetContainer = AssetPickerSheetContainerViewController ( )
sheetContainer . assetPicker . assetPickerDelegate = self
self . present ( sheetContainer , animated : true )
}
func presentComposeDrawing ( ) {
let drawing : PKDrawing
if case let . edit ( id ) = uiState . composeDrawingMode ,
let attachment = draft . attachments . first ( where : { $0 . id = = id } ) ,
case let . drawing ( existingDrawing ) = attachment . data {
drawing = existingDrawing
} else {
drawing = PKDrawing ( )
}
let drawingVC = ComposeDrawingViewController ( editing : drawing )
drawingVC . delegate = self
let nav = UINavigationController ( rootViewController : drawingVC )
nav . modalPresentationStyle = . fullScreen
present ( nav , animated : true )
}
}
extension ComposeHostingController : AssetPickerViewControllerDelegate {
func assetPicker ( _ assetPicker : AssetPickerViewController , shouldAllowAssetOfType type : CompositionAttachmentData . AttachmentType ) -> Bool {
2022-01-25 03:49:18 +00:00
if mastodonController . instanceFeatures . mastodonAttachmentRestrictions {
2020-08-31 23:28:50 +00:00
if ( type = = . video && draft . attachments . count > 0 ) ||
draft . attachments . contains ( where : { $0 . data . type = = . video } ) ||
assetPicker . currentCollectionSelectedAssets . contains ( where : { $0 . type = = . video } ) {
return false
}
return draft . attachments . count + assetPicker . currentCollectionSelectedAssets . count < 4
2022-01-25 03:49:18 +00:00
} else {
return true
2020-08-31 23:28:50 +00:00
}
}
func assetPicker ( _ assetPicker : AssetPickerViewController , didSelectAttachments attachments : [ CompositionAttachmentData ] ) {
let attachments = attachments . map {
CompositionAttachment ( data : $0 )
}
2021-05-14 02:34:26 +00:00
withAnimation {
draft . attachments . append ( contentsOf : attachments )
}
2020-08-31 23:28:50 +00:00
}
}
extension ComposeHostingController : DraftsTableViewControllerDelegate {
func draftSelectionCanceled ( ) {
}
func shouldSelectDraft ( _ draft : Draft , completion : @ escaping ( Bool ) -> Void ) {
if draft . inReplyToID != self . draft . inReplyToID ,
self . draft . hasContent {
let alertController = UIAlertController ( title : " Different Reply " , message : " The selected draft is a reply to a different status, do you wish to use it? " , preferredStyle : . alert )
alertController . addAction ( UIAlertAction ( title : " Cancel " , style : . cancel , handler : { ( _ ) in
completion ( false )
} ) )
alertController . addAction ( UIAlertAction ( title : " Restore Draft " , style : . default , handler : { ( _ ) in
completion ( true )
} ) )
// w e c a n ' t p r e s e n t t h e l a e r t o u r s e l v e s s i n c e t h e c o m p o s e V C i s a l r e a d y p r e s e n t i n g t h e d r a f t s e l e c t o r
// b u t p r e s e n t i n g o n t h e p r e s e n t e d v i e w c o n t r o l l e r s e e m s h a c k y , i s t h e r e a b e t t e r w a y t o d o t h i s ?
presentedViewController ! . present ( alertController , animated : true )
} else {
completion ( true )
}
}
func draftSelected ( _ draft : Draft ) {
if self . draft . hasContent {
DraftsManager . save ( )
} else {
DraftsManager . shared . remove ( self . draft )
}
uiState . draft = draft
}
func draftSelectionCompleted ( ) {
}
}
extension ComposeHostingController : UIAdaptivePresentationControllerDelegate {
func presentationControllerShouldDismiss ( _ presentationController : UIPresentationController ) -> Bool {
return Preferences . shared . automaticallySaveDrafts || ! draft . hasContent
}
func presentationControllerDidAttemptToDismiss ( _ presentationController : UIPresentationController ) {
uiState . isShowingSaveDraftSheet = true
}
func presentationControllerDidDismiss ( _ presentationController : UIPresentationController ) {
DraftsManager . save ( )
}
}
extension ComposeHostingController : ComposeDrawingViewControllerDelegate {
func composeDrawingViewControllerClose ( _ drawingController : ComposeDrawingViewController ) {
dismiss ( animated : true )
}
func composeDrawingViewController ( _ drawingController : ComposeDrawingViewController , saveDrawing drawing : PKDrawing ) {
switch uiState . composeDrawingMode {
case nil , . createNew :
let attachment = CompositionAttachment ( data : . drawing ( drawing ) )
draft . attachments . append ( attachment )
case let . edit ( id ) :
let existing = draft . attachments . first { $0 . id = = id }
existing ? . data = . drawing ( drawing )
}
dismiss ( animated : true )
}
}