import UIKit
import CoreServices
import Photos
import PhotosUI
import PromiseKit
import SessionUtilitiesKit
import SignalUtilitiesKit
extension ConversationVC : InputViewDelegate , MessageCellDelegate , ContextMenuActionDelegate , ScrollToBottomButtonDelegate ,
SendMediaNavDelegate , UIDocumentPickerDelegate , AttachmentApprovalViewControllerDelegate , GifPickerViewControllerDelegate ,
ConversationTitleViewDelegate {
func handleTitleViewTapped ( ) {
// D o n ' t t a k e t h e u s e r t o s e t t i n g s f o r m e s s a g e r e q u e s t s
guard
let contactThread : TSContactThread = thread as ? TSContactThread ,
let contact : Contact = Storage . shared . getContact ( with : contactThread . contactSessionID ( ) ) ,
contact . isApproved ,
contact . didApproveMe
else {
return
}
openSettings ( )
}
@objc func openSettings ( ) {
let settingsVC = OWSConversationSettingsViewController ( )
settingsVC . configure ( with : thread , uiDatabaseConnection : OWSPrimaryStorage . shared ( ) . uiDatabaseConnection )
settingsVC . conversationSettingsViewDelegate = self
navigationController ! . pushViewController ( settingsVC , animated : true , completion : nil )
}
func handleScrollToBottomButtonTapped ( ) {
// T h e t a b l e v i e w ' s c o n t e n t s i z e i s c a l c u l a t e d b y t h e e s t i m a t e d h e i g h t o f c e l l s ,
// s o t h e r e s u l t m a y b e i n a c c u r a t e b e f o r e a l l t h e c e l l s a r e l o a d e d . U s e t h i s
// t o s c r o l l t o t h e l a s t r o w i n s t e a d .
let indexPath = IndexPath ( row : viewItems . count - 1 , section : 0 )
unreadViewItems . removeAll ( )
messagesTableView . scrollToRow ( at : indexPath , at : . top , animated : true )
}
// MARK: C a l l
@objc func startCall ( _ sender : Any ? ) {
guard SessionCall . isEnabled else { return }
if SSKPreferences . areCallsEnabled {
requestMicrophonePermissionIfNeeded { }
guard AVAudioSession . sharedInstance ( ) . recordPermission = = . granted else { return }
guard let contactSessionID = ( thread as ? TSContactThread ) ? . contactSessionID ( ) else { return }
guard AppEnvironment . shared . callManager . currentCall = = nil else { return }
let call = SessionCall ( for : contactSessionID , uuid : UUID ( ) . uuidString . lowercased ( ) , mode : . offer , outgoing : true )
let callVC = CallVC ( for : call )
callVC . conversationVC = self
self . inputAccessoryView ? . isHidden = true
self . inputAccessoryView ? . alpha = 0
present ( callVC , animated : true , completion : nil )
} else {
let callPermissionRequestModal = CallPermissionRequestModal ( )
self . navigationController ? . present ( callPermissionRequestModal , animated : true , completion : nil )
}
}
// MARK: B l o c k i n g
@objc func unblock ( ) {
guard let thread = thread as ? TSContactThread else { return }
let publicKey = thread . contactSessionID ( )
UIView . animate ( withDuration : 0.25 , animations : {
self . blockedBanner . alpha = 0
} , completion : { _ in
if let contact : Contact = Storage . shared . getContact ( with : publicKey ) {
Storage . shared . write { transaction in
contact . isBlocked = false
Storage . shared . setContact ( contact , using : transaction )
}
}
OWSBlockingManager . shared ( ) . removeBlockedPhoneNumber ( publicKey )
} )
}
func showBlockedModalIfNeeded ( ) -> Bool {
guard let thread = thread as ? TSContactThread else { return false }
let publicKey = thread . contactSessionID ( )
guard OWSBlockingManager . shared ( ) . isRecipientIdBlocked ( publicKey ) else { return false }
let blockedModal = BlockedModal ( publicKey : publicKey )
blockedModal . modalPresentationStyle = . overFullScreen
blockedModal . modalTransitionStyle = . crossDissolve
present ( blockedModal , animated : true , completion : nil )
return true
}
// MARK: A t t a c h m e n t s
func didPasteImageFromPasteboard ( _ image : UIImage ) {
guard let imageData = image . jpegData ( compressionQuality : 1.0 ) else { return }
let dataSource = DataSourceValue . dataSource ( with : imageData , utiType : kUTTypeJPEG as String )
let attachment = SignalAttachment . attachment ( dataSource : dataSource , dataUTI : kUTTypeJPEG as String , imageQuality : . medium )
let approvalVC = AttachmentApprovalViewController . wrappedInNavController ( attachments : [ attachment ] , approvalDelegate : self )
approvalVC . modalPresentationStyle = . fullScreen
self . present ( approvalVC , animated : true , completion : nil )
}
func sendMediaNavDidCancel ( _ sendMediaNavigationController : SendMediaNavigationController ) {
dismiss ( animated : true , completion : nil )
}
func sendMediaNav ( _ sendMediaNavigationController : SendMediaNavigationController , didApproveAttachments attachments : [ SignalAttachment ] , messageText : String ? ) {
sendAttachments ( attachments , with : messageText ? ? " " )
resetMentions ( )
self . snInputView . text = " "
dismiss ( animated : true ) { }
}
func sendMediaNavInitialMessageText ( _ sendMediaNavigationController : SendMediaNavigationController ) -> String ? {
return snInputView . text
}
func sendMediaNav ( _ sendMediaNavigationController : SendMediaNavigationController , didChangeMessageText newMessageText : String ? ) {
snInputView . text = newMessageText ? ? " "
}
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didApproveAttachments attachments : [ SignalAttachment ] , messageText : String ? ) {
sendAttachments ( attachments , with : messageText ? ? " " ) { [ weak self ] in
self ? . dismiss ( animated : true , completion : nil )
}
scrollToBottom ( isAnimated : false )
resetMentions ( )
self . snInputView . text = " "
}
func attachmentApprovalDidCancel ( _ attachmentApproval : AttachmentApprovalViewController ) {
dismiss ( animated : true , completion : nil )
}
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didChangeMessageText newMessageText : String ? ) {
snInputView . text = newMessageText ? ? " "
}
func handleCameraButtonTapped ( ) {
guard requestCameraPermissionIfNeeded ( ) else { return }
requestMicrophonePermissionIfNeeded { }
if AVAudioSession . sharedInstance ( ) . recordPermission != . granted {
SNLog ( " Proceeding without microphone access. Any recorded video will be silent. " )
}
let sendMediaNavController = SendMediaNavigationController . showingCameraFirst ( )
sendMediaNavController . sendMediaNavDelegate = self
sendMediaNavController . modalPresentationStyle = . fullScreen
present ( sendMediaNavController , animated : true , completion : nil )
}
func handleLibraryButtonTapped ( ) {
requestLibraryPermissionIfNeeded { [ weak self ] in
DispatchQueue . main . async {
let sendMediaNavController = SendMediaNavigationController . showingMediaLibraryFirst ( )
sendMediaNavController . sendMediaNavDelegate = self
sendMediaNavController . modalPresentationStyle = . fullScreen
self ? . present ( sendMediaNavController , animated : true , completion : nil )
}
}
}
func handleGIFButtonTapped ( ) {
let gifVC = GifPickerViewController ( thread : thread )
gifVC . delegate = self
let navController = OWSNavigationController ( rootViewController : gifVC )
navController . modalPresentationStyle = . fullScreen
present ( navController , animated : true ) { }
}
func gifPickerDidSelect ( attachment : SignalAttachment ) {
showAttachmentApprovalDialog ( for : [ attachment ] )
}
func handleDocumentButtonTapped ( ) {
// U I D o c u m e n t P i c k e r M o d e I m p o r t c o p i e s t o a t e m p f i l e w i t h i n o u r c o n t a i n e r .
// I t u s e s m o r e m e m o r y t h a n " o p e n " b u t l e t s u s a v o i d w o r k i n g w i t h s e c u r i t y s c o p e d U R L s .
let documentPickerVC = UIDocumentPickerViewController ( documentTypes : [ kUTTypeItem as String ] , in : UIDocumentPickerMode . import )
documentPickerVC . delegate = self
documentPickerVC . modalPresentationStyle = . fullScreen
SNAppearance . switchToDocumentPickerAppearance ( )
present ( documentPickerVC , animated : true , completion : nil )
}
func documentPickerWasCancelled ( _ controller : UIDocumentPickerViewController ) {
SNAppearance . switchToSessionAppearance ( ) // S w i t c h b a c k t o t h e c o r r e c t a p p e a r a n c e
}
func documentPicker ( _ controller : UIDocumentPickerViewController , didPickDocumentsAt urls : [ URL ] ) {
SNAppearance . switchToSessionAppearance ( )
guard let url = urls . first else { return } // TODO: H a n d l e m u l t i p l e ?
let urlResourceValues : URLResourceValues
do {
urlResourceValues = try url . resourceValues ( forKeys : [ . typeIdentifierKey , . isDirectoryKey , . nameKey ] )
} catch {
let alert = UIAlertController ( title : " Session " , message : " An error occurred. " , preferredStyle : . alert )
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : nil ) )
return presentAlert ( alert )
}
let type = urlResourceValues . typeIdentifier ? ? ( kUTTypeData as String )
guard urlResourceValues . isDirectory != true else {
DispatchQueue . main . async {
let title = NSLocalizedString ( " ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE " , comment : " " )
let message = NSLocalizedString ( " ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY " , comment : " " )
OWSAlerts . showAlert ( title : title , message : message )
}
return
}
let fileName = urlResourceValues . name ? ? NSLocalizedString ( " ATTACHMENT_DEFAULT_FILENAME " , comment : " " )
guard let dataSource = DataSourcePath . dataSource ( with : url , shouldDeleteOnDeallocation : false ) else {
DispatchQueue . main . async {
let title = NSLocalizedString ( " ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE " , comment : " " )
OWSAlerts . showAlert ( title : title )
}
return
}
dataSource . sourceFilename = fileName
// A l t h o u g h w e w a n t t o b e a b l e t o s e n d h i g h e r q u a l i t y a t t a c h m e n t s t h r o u g h t h e d o c u m e n t p i c k e r
// i t ' s m o r e i m p o r a n t t h a t w e e n s u r e t h e s e n t f o r m a t i s o n e a l l c l i e n t s c a n a c c e p t ( e . g . * n o t * q u i c k t i m e . m o v )
guard ! SignalAttachment . isInvalidVideo ( dataSource : dataSource , dataUTI : type ) else {
return showAttachmentApprovalDialogAfterProcessingVideo ( at : url , with : fileName )
}
// " D o c u m e n t p i c k e r " a t t a c h m e n t s _ S H O U L D N O T _ b e r e s i z e d
let attachment = SignalAttachment . attachment ( dataSource : dataSource , dataUTI : type , imageQuality : . original )
showAttachmentApprovalDialog ( for : [ attachment ] )
}
func showAttachmentApprovalDialog ( for attachments : [ SignalAttachment ] ) {
let navController = AttachmentApprovalViewController . wrappedInNavController ( attachments : attachments , approvalDelegate : self )
present ( navController , animated : true , completion : nil )
}
func showAttachmentApprovalDialogAfterProcessingVideo ( at url : URL , with fileName : String ) {
ModalActivityIndicatorViewController . present ( fromViewController : self , canCancel : true , message : nil ) { [ weak self ] modalActivityIndicator in
let dataSource = DataSourcePath . dataSource ( with : url , shouldDeleteOnDeallocation : false ) !
dataSource . sourceFilename = fileName
let compressionResult : SignalAttachment . VideoCompressionResult = SignalAttachment . compressVideoAsMp4 ( dataSource : dataSource , dataUTI : kUTTypeMPEG4 as String )
compressionResult . attachmentPromise . done { attachment in
guard ! modalActivityIndicator . wasCancelled , let attachment = attachment as ? SignalAttachment else { return }
modalActivityIndicator . dismiss {
if ! attachment . hasError {
self ? . showAttachmentApprovalDialog ( for : [ attachment ] )
} else {
self ? . showErrorAlert ( for : attachment , onDismiss : nil )
}
}
} . retainUntilComplete ( )
}
}
// MARK: M e s s a g e S e n d i n g
func handleSendButtonTapped ( ) {
sendMessage ( )
}
func sendMessage ( hasPermissionToSendSeed : Bool = false ) {
guard ! showBlockedModalIfNeeded ( ) else { return }
let text = replaceMentions ( in : snInputView . text . trimmingCharacters ( in : . whitespacesAndNewlines ) )
let thread = self . thread
guard ! text . isEmpty else { return }
if text . contains ( mnemonic ) && ! thread . isNoteToSelf ( ) && ! hasPermissionToSendSeed {
// W a r n t h e u s e r i f t h e y ' r e a b o u t t o s e n d t h e i r s e e d t o s o m e o n e
let modal = SendSeedModal ( )
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
modal . proceed = { self . sendMessage ( hasPermissionToSendSeed : true ) }
return present ( modal , animated : true , completion : nil )
}
let sentTimestamp : UInt64 = NSDate . millisecondTimestamp ( )
let message : VisibleMessage = VisibleMessage ( )
message . sentTimestamp = sentTimestamp
message . text = text
message . quote = VisibleMessage . Quote . from ( snInputView . quoteDraftInfo ? . model )
// N o t e : ' s h o u l d B e V i s i b l e ' i s s e t t o t r u e t h e f i r s t t i m e a t h r e a d i s s a v e d s o w e c a n
// u s e i t t o d e t e r m i n e i f t h e u s e r i s c r e a t i n g a n e w t h r e a d a n d u p d a t e t h e ' i s A p p r o v e d '
// f l a g s a p p r o p r i a t e l y
let oldThreadShouldBeVisible : Bool = thread . shouldBeVisible
let linkPreviewDraft = snInputView . linkPreviewInfo ? . draft
let tsMessage = TSOutgoingMessage . from ( message , associatedWith : thread )
let promise : Promise < Void > = self . approveMessageRequestIfNeeded (
for : self . thread ,
isNewThread : ! oldThreadShouldBeVisible ,
timestamp : ( sentTimestamp - 1 ) // S e t 1 m s e a r l i e r a s t h i s i s u s e d f o r s o r t i n g
)
. map { [ weak self ] _ in
self ? . viewModel . appendUnsavedOutgoingTextMessage ( tsMessage )
Storage . write ( with : { transaction in
message . linkPreview = VisibleMessage . LinkPreview . from ( linkPreviewDraft , using : transaction )
} , completion : { [ weak self ] in
tsMessage . linkPreview = OWSLinkPreview . from ( message . linkPreview )
Storage . shared . write (
with : { transaction in
tsMessage . save ( with : transaction as ! YapDatabaseReadWriteTransaction )
} ,
completion : { [ weak self ] in
// A t t h i s p o i n t t h e T S O u t g o i n g M e s s a g e s h o u l d h a v e i t s l i n k p r e v i e w s e t , s o w e c a n s c r o l l t o t h e b o t t o m k n o w i n g
// t h e h e i g h t o f t h e n e w m e s s a g e c e l l
self ? . scrollToBottom ( isAnimated : false )
}
)
Storage . shared . write { transaction in
MessageSender . send ( message , with : [ ] , in : thread , using : transaction as ! YapDatabaseReadWriteTransaction )
}
self ? . handleMessageSent ( )
} )
}
// S h o w a n e r r o r i n d i c a t i n g t h a t a p p r o v i n g t h e t h r e a d f a i l e d
promise . catch ( on : DispatchQueue . main ) { [ weak self ] _ in
let alert = UIAlertController ( title : " Session " , message : " An error occurred when trying to accept this message request " , preferredStyle : . alert )
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : nil ) )
self ? . present ( alert , animated : true , completion : nil )
}
promise . retainUntilComplete ( )
}
func sendAttachments ( _ attachments : [ SignalAttachment ] , with text : String , onComplete : ( ( ) -> ( ) ) ? = nil ) {
guard ! showBlockedModalIfNeeded ( ) else { return }
for attachment in attachments {
if attachment . hasError {
return showErrorAlert ( for : attachment , onDismiss : onComplete )
}
}
let thread = self . thread
let sentTimestamp : UInt64 = NSDate . millisecondTimestamp ( )
let message = VisibleMessage ( )
message . sentTimestamp = sentTimestamp
message . text = replaceMentions ( in : text )
// N o t e : ' s h o u l d B e V i s i b l e ' i s s e t t o t r u e t h e f i r s t t i m e a t h r e a d i s s a v e d s o w e c a n
// u s e i t t o d e t e r m i n e i f t h e u s e r i s c r e a t i n g a n e w t h r e a d a n d u p d a t e t h e ' i s A p p r o v e d '
// f l a g s a p p r o p r i a t e l y
let oldThreadShouldBeVisible : Bool = thread . shouldBeVisible
let tsMessage = TSOutgoingMessage . from ( message , associatedWith : thread )
let promise : Promise < Void > = self . approveMessageRequestIfNeeded (
for : self . thread ,
isNewThread : ! oldThreadShouldBeVisible ,
timestamp : ( sentTimestamp - 1 ) // S e t 1 m s e a r l i e r a s t h i s i s u s e d f o r s o r t i n g
)
. map { [ weak self ] _ in
Storage . write (
with : { transaction in
tsMessage . save ( with : transaction )
// T h e n e w m e s s a g e c e l l i s i n s e r t e d a t t h i s p o i n t , b u t t h e T S O u t g o i n g M e s s a g e d o e s n ' t h a v e i t s a t t a c h m e n t y e t
} ,
completion : { [ weak self ] in
Storage . write ( with : { transaction in
MessageSender . send ( message , with : attachments , in : thread , using : transaction )
} , completion : { [ weak self ] in
// A t t h i s p o i n t t h e T S O u t g o i n g M e s s a g e s h o u l d h a v e i t s a t t a c h m e n t s s e t , s o w e c a n s c r o l l t o t h e b o t t o m k n o w i n g
// t h e h e i g h t o f t h e n e w m e s s a g e c e l l
self ? . scrollToBottom ( isAnimated : false )
} )
self ? . handleMessageSent ( )
// A t t a c h m e n t s u c c e s s f u l l y s e n t - d i s m i s s t h e s c r e e n
onComplete ? ( )
}
)
}
// S h o w a n e r r o r i n d i c a t i n g t h a t a p p r o v i n g t h e t h r e a d f a i l e d
promise . catch ( on : DispatchQueue . main ) { [ weak self ] _ in
let alert = UIAlertController ( title : " Session " , message : " An error occurred when trying to accept this message request " , preferredStyle : . alert )
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : nil ) )
self ? . present ( alert , animated : true , completion : nil )
}
promise . retainUntilComplete ( )
}
func handleMessageSent ( ) {
resetMentions ( )
self . snInputView . text = " "
self . snInputView . quoteDraftInfo = nil
// U p d a t e t h e i n p u t s t a t e i f t h i s i s a c o n t a c t t h r e a d
if let contactThread : TSContactThread = thread as ? TSContactThread {
let contact : Contact ? = Storage . shared . getContact ( with : contactThread . contactSessionID ( ) )
// I f t h e c o n t a c t d o e s n ' t e x i s t y e t t h e n i t ' s a m e s s a g e r e q u e s t w i t h o u t t h e f i r s t m e s s a g e s e n t
// s o o n l y a l l o w t e x t - b a s e d m e s s a g e s
self . snInputView . setEnabledMessageTypes (
( thread . isNoteToSelf ( ) || contact ? . didApproveMe = = true || thread . isMessageRequest ( ) ?
. all : . textOnly
) ,
message : nil
)
}
self . markAllAsRead ( )
if Environment . shared . preferences . soundInForeground ( ) {
let soundID = OWSSounds . systemSoundID ( for : . messageSent , quiet : true )
AudioServicesPlaySystemSound ( soundID )
}
SSKEnvironment . shared . typingIndicators . didSendOutgoingMessage ( inThread : thread )
Storage . write { transaction in
self . thread . setDraft ( " " , transaction : transaction )
}
}
// MARK: I n p u t V i e w
func inputTextViewDidChangeContent ( _ inputTextView : InputTextView ) {
let newText = inputTextView . text ? ? " "
if ! newText . isEmpty {
SSKEnvironment . shared . typingIndicators . didStartTypingOutgoingInput ( inThread : thread )
}
updateMentions ( for : newText )
}
func showLinkPreviewSuggestionModal ( ) {
let linkPreviewModel = LinkPreviewModal ( ) { [ weak self ] in
self ? . snInputView . autoGenerateLinkPreview ( )
}
linkPreviewModel . modalPresentationStyle = . overFullScreen
linkPreviewModel . modalTransitionStyle = . crossDissolve
present ( linkPreviewModel , animated : true , completion : nil )
}
// MARK: M e n t i o n s
func updateMentions ( for newText : String ) {
if newText . count < oldText . count {
currentMentionStartIndex = nil
snInputView . hideMentionsUI ( )
mentions = mentions . filter { $0 . isContained ( in : newText ) }
}
if ! newText . isEmpty {
let lastCharacterIndex = newText . index ( before : newText . endIndex )
let lastCharacter = newText [ lastCharacterIndex ]
// C h e c k i f t h e r e i s w h i t e s p a c e b e f o r e t h e ' @ ' o r t h e ' @ ' i s t h e f i r s t c h a r a c t e r
let isCharacterBeforeLastWhiteSpaceOrStartOfLine : Bool
if newText . count = = 1 {
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // S t a r t o f l i n e
} else {
let characterBeforeLast = newText [ newText . index ( before : lastCharacterIndex ) ]
isCharacterBeforeLastWhiteSpaceOrStartOfLine = characterBeforeLast . isWhitespace
}
if lastCharacter = = " @ " && isCharacterBeforeLastWhiteSpaceOrStartOfLine {
let candidates = MentionsManager . getMentionCandidates ( for : " " , in : thread . uniqueId ! )
currentMentionStartIndex = lastCharacterIndex
snInputView . showMentionsUI ( for : candidates , in : thread )
} else if lastCharacter . isWhitespace || lastCharacter = = " @ " { // t h e l a s t C h a r a c t e r = = " @ " i s t o c h e c k f o r @ @
currentMentionStartIndex = nil
snInputView . hideMentionsUI ( )
} else {
if let currentMentionStartIndex = currentMentionStartIndex {
let query = String ( newText [ newText . index ( after : currentMentionStartIndex ) . . . ] ) // + 1 t o g e t r i d o f t h e @
let candidates = MentionsManager . getMentionCandidates ( for : query , in : thread . uniqueId ! )
snInputView . showMentionsUI ( for : candidates , in : thread )
}
}
}
oldText = newText
}
func resetMentions ( ) {
oldText = " "
currentMentionStartIndex = nil
mentions = [ ]
}
func replaceMentions ( in text : String ) -> String {
var result = text
for mention in mentions {
guard let range = result . range ( of : " @ \( mention . displayName ) " ) else { continue }
result = result . replacingCharacters ( in : range , with : " @ \( mention . publicKey ) " )
}
return result
}
func handleMentionSelected ( _ mention : Mention , from view : MentionSelectionView ) {
guard let currentMentionStartIndex = currentMentionStartIndex else { return }
mentions . append ( mention )
let oldText = snInputView . text
let newText = oldText . replacingCharacters ( in : currentMentionStartIndex . . . , with : " @ \( mention . displayName ) " )
snInputView . text = newText
self . currentMentionStartIndex = nil
snInputView . hideMentionsUI ( )
self . oldText = newText
}
func showInputAccessoryView ( ) {
UIView . animate ( withDuration : 0.25 , animations : {
self . inputAccessoryView ? . isHidden = false
self . inputAccessoryView ? . alpha = 1
} )
}
// MARK: V i e w I t e m I n t e r a c t i o n
func handleViewItemLongPressed ( _ viewItem : ConversationViewItem ) {
// S h o w t h e c o n t e x t m e n u i f a p p l i c a b l e
guard let index = viewItems . firstIndex ( where : { $0 = = = viewItem } ) ,
let cell = messagesTableView . cellForRow ( at : IndexPath ( row : index , section : 0 ) ) as ? VisibleMessageCell ,
let snapshot = cell . bubbleView . snapshotView ( afterScreenUpdates : false ) , contextMenuWindow = = nil ,
! ContextMenuVC . actions ( for : viewItem , delegate : self ) . isEmpty else { return }
UIImpactFeedbackGenerator ( style : . heavy ) . impactOccurred ( )
let frame = cell . convert ( cell . bubbleView . frame , to : UIApplication . shared . keyWindow ! )
let window = ContextMenuWindow ( )
let contextMenuVC = ContextMenuVC ( snapshot : snapshot , viewItem : viewItem , frame : frame , delegate : self ) { [ weak self ] in
window . isHidden = true
guard let self = self else { return }
self . contextMenuVC = nil
self . contextMenuWindow = nil
self . scrollButton . alpha = 0
UIView . animate ( withDuration : 0.25 ) {
self . scrollButton . alpha = self . getScrollButtonOpacity ( )
self . unreadCountView . alpha = self . scrollButton . alpha
}
}
self . contextMenuVC = contextMenuVC
contextMenuWindow = window
window . rootViewController = contextMenuVC
window . makeKeyAndVisible ( )
window . backgroundColor = . clear
}
func handleViewItemTapped ( _ viewItem : ConversationViewItem , gestureRecognizer : UITapGestureRecognizer ) {
func confirmDownload ( ) {
let modal = DownloadAttachmentModal ( viewItem : viewItem )
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
present ( modal , animated : true , completion : nil )
}
if let message = viewItem . interaction as ? TSOutgoingMessage , message . messageState = = . failed {
// S h o w t h e f a i l e d m e s s a g e s h e e t
showFailedMessageSheet ( for : message )
} else {
switch viewItem . messageCellType {
case . audio :
if viewItem . interaction is TSIncomingMessage ,
let thread = self . thread as ? TSContactThread ,
Storage . shared . getContact ( with : thread . contactSessionID ( ) ) ? . isTrusted != true {
confirmDownload ( )
} else {
playOrPauseAudio ( for : viewItem )
}
case . mediaMessage :
guard let index = viewItems . firstIndex ( where : { $0 = = = viewItem } ) ,
let cell = messagesTableView . cellForRow ( at : IndexPath ( row : index , section : 0 ) ) as ? VisibleMessageCell else { return }
if viewItem . interaction is TSIncomingMessage ,
let thread = self . thread as ? TSContactThread ,
Storage . shared . getContact ( with : thread . contactSessionID ( ) ) ? . isTrusted != true {
confirmDownload ( )
} else {
guard let albumView = cell . albumView else { return }
let locationInCell = gestureRecognizer . location ( in : cell )
// F i g u r e o u t w h i c h o f t h e m e d i a v i e w s w a s t a p p e d
let locationInAlbumView = cell . convert ( locationInCell , to : albumView )
guard let mediaView = albumView . mediaView ( forLocation : locationInAlbumView ) else { return }
if albumView . isMoreItemsView ( mediaView : mediaView ) && viewItem . mediaAlbumHasFailedAttachment ( ) {
// TODO: T a p p e d a f a i l e d i n c o m i n g a t t a c h m e n t
}
let attachment = mediaView . attachment
if let pointer = attachment as ? TSAttachmentPointer {
if pointer . state = = . failed {
// TODO: T a p p e d a f a i l e d i n c o m i n g a t t a c h m e n t
}
}
guard let stream = attachment as ? TSAttachmentStream else { return }
let gallery = MediaGallery ( thread : thread , options : [ . sliderEnabled , . showAllMediaButton ] )
gallery . presentDetailView ( fromViewController : self , mediaAttachment : stream )
}
case . genericAttachment :
if viewItem . interaction is TSIncomingMessage ,
let thread = self . thread as ? TSContactThread ,
Storage . shared . getContact ( with : thread . contactSessionID ( ) ) ? . isTrusted != true {
confirmDownload ( )
}
else if (
viewItem . attachmentStream ? . isText = = true ||
viewItem . attachmentStream ? . isMicrosoftDoc = = true ||
viewItem . attachmentStream ? . contentType = = OWSMimeTypeApplicationPdf
) , let filePathString : String = viewItem . attachmentStream ? . originalFilePath {
let fileUrl : URL = URL ( fileURLWithPath : filePathString )
let interactionController : UIDocumentInteractionController = UIDocumentInteractionController ( url : fileUrl )
interactionController . delegate = self
interactionController . presentPreview ( animated : true )
}
else {
// O p e n t h e d o c u m e n t i f p o s s i b l e
guard let url = viewItem . attachmentStream ? . originalMediaURL else { return }
let shareVC = UIActivityViewController ( activityItems : [ url ] , applicationActivities : nil )
if UIDevice . current . isIPad {
shareVC . excludedActivityTypes = [ ]
shareVC . popoverPresentationController ? . permittedArrowDirections = [ ]
shareVC . popoverPresentationController ? . sourceView = self . view
shareVC . popoverPresentationController ? . sourceRect = self . view . bounds
}
navigationController ! . present ( shareVC , animated : true , completion : nil )
}
case . textOnlyMessage :
if let reply = viewItem . quotedReply {
// S c r o l l t o t h e s o u r c e o f t h e r e p l y
guard let indexPath = viewModel . ensureLoadWindowContainsQuotedReply ( reply ) else { return }
messagesTableView . scrollToRow ( at : indexPath , at : UITableView . ScrollPosition . middle , animated : true )
} else if let message = viewItem . interaction as ? TSIncomingMessage , let name = message . openGroupInvitationName ,
let url = message . openGroupInvitationURL {
joinOpenGroup ( name : name , url : url )
}
default : break
}
}
}
func handleViewItemSwiped ( _ viewItem : ConversationViewItem , state : SwipeState ) {
switch state {
case . began :
messagesTableView . isScrollEnabled = false
case . ended , . cancelled :
messagesTableView . isScrollEnabled = true
}
}
func showFailedMessageSheet ( for tsMessage : TSOutgoingMessage ) {
let thread = self . thread
let error = tsMessage . mostRecentFailureText
let sheet = UIAlertController ( title : error , message : nil , preferredStyle : . actionSheet )
sheet . addAction ( UIAlertAction ( title : " Cancel " , style : . cancel , handler : nil ) )
sheet . addAction ( UIAlertAction ( title : " Delete " , style : . destructive , handler : { _ in
Storage . write { transaction in
tsMessage . remove ( with : transaction )
Storage . shared . cancelPendingMessageSendJobIfNeeded ( for : tsMessage . timestamp , using : transaction )
}
} ) )
sheet . addAction ( UIAlertAction ( title : " Resend " , style : . default , handler : { _ in
let message = VisibleMessage . from ( tsMessage )
Storage . write { transaction in
var attachments : [ TSAttachmentStream ] = [ ]
tsMessage . attachmentIds . forEach { attachmentID in
guard let attachmentID = attachmentID as ? String else { return }
let attachment = TSAttachment . fetch ( uniqueId : attachmentID , transaction : transaction )
guard let stream = attachment as ? TSAttachmentStream else { return }
attachments . append ( stream )
}
MessageSender . prep ( attachments , for : message , using : transaction )
MessageSender . send ( message , in : thread , using : transaction )
}
} ) )
// H A C K : E x t r a c t i n g t h i s i n f o f r o m t h e e r r o r s t r i n g i s p r e t t y d o d g y
let prefix = " HTTP request failed at destination (Service node "
if error . hasPrefix ( prefix ) {
let rest = error . substring ( from : prefix . count )
if let index = rest . firstIndex ( of : " ) " ) {
let snodeAddress = String ( rest [ rest . startIndex . . < index ] )
sheet . addAction ( UIAlertAction ( title : " Copy Service Node Info " , style : . default , handler : { _ in
UIPasteboard . general . string = snodeAddress
} ) )
}
}
presentAlert ( sheet )
}
func handleViewItemDoubleTapped ( _ viewItem : ConversationViewItem ) {
switch viewItem . messageCellType {
case . audio : speedUpAudio ( for : viewItem ) // T h e u s e r c a n d o u b l e t a p a v o i c e m e s s a g e w h e n i t ' s p l a y i n g t o s p e e d i t u p
default : break
}
}
func showFullText ( _ viewItem : ConversationViewItem ) {
let longMessageVC = LongTextViewController ( viewItem : viewItem )
navigationController ! . pushViewController ( longMessageVC , animated : true )
}
func reply ( _ viewItem : ConversationViewItem ) {
var quoteDraftOrNil : OWSQuotedReplyModel ?
Storage . read { transaction in
quoteDraftOrNil = OWSQuotedReplyModel . quotedReplyForSending ( with : viewItem , threadId : viewItem . interaction . uniqueThreadId , transaction : transaction )
}
guard let quoteDraft = quoteDraftOrNil else { return }
let isOutgoing = ( viewItem . interaction . interactionType ( ) = = . outgoingMessage )
snInputView . quoteDraftInfo = ( model : quoteDraft , isOutgoing : isOutgoing )
snInputView . becomeFirstResponder ( )
}
func copy ( _ viewItem : ConversationViewItem ) {
if viewItem . canCopyMedia ( ) {
viewItem . copyMediaAction ( )
} else {
viewItem . copyTextAction ( )
}
}
func copySessionID ( _ viewItem : ConversationViewItem ) {
// FIXME: C o p y i n g m e d i a
guard let message = viewItem . interaction as ? TSIncomingMessage else { return }
UIPasteboard . general . string = message . authorId
}
func delete ( _ viewItem : ConversationViewItem ) {
guard let message = viewItem . interaction as ? TSMessage else { return self . deleteLocally ( viewItem ) }
// H a n d l e o p e n g r o u p m e s s a g e s t h e o l d w a y
if message . isOpenGroupMessage { return self . deleteForEveryone ( viewItem ) }
// H a n d l e 1 - 1 a n d c l o s e d g r o u p m e s s a g e s w i t h u n s e n d r e q u e s t
if viewItem . interaction . interactionType ( ) = = . outgoingMessage , message . serverHash != nil {
let alertVC = UIAlertController . init ( title : nil , message : nil , preferredStyle : . actionSheet )
let deleteLocallyAction = UIAlertAction . init ( title : NSLocalizedString ( " delete_message_for_me " , comment : " " ) , style : . destructive ) { _ in
self . deleteLocally ( viewItem )
self . showInputAccessoryView ( )
}
alertVC . addAction ( deleteLocallyAction )
var title = NSLocalizedString ( " delete_message_for_everyone " , comment : " " )
if ! viewItem . isGroupThread {
title = String ( format : NSLocalizedString ( " delete_message_for_me_and_recipient " , comment : " " ) , viewItem . interaction . thread . name ( ) )
}
let deleteRemotelyAction = UIAlertAction . init ( title : title , style : . destructive ) { _ in
self . deleteForEveryone ( viewItem )
self . showInputAccessoryView ( )
}
alertVC . addAction ( deleteRemotelyAction )
let cancelAction = UIAlertAction . init ( title : NSLocalizedString ( " TXT_CANCEL_TITLE " , comment : " " ) , style : . cancel ) { _ in
self . showInputAccessoryView ( )
}
alertVC . addAction ( cancelAction )
self . inputAccessoryView ? . isHidden = true
self . inputAccessoryView ? . alpha = 0
self . presentAlert ( alertVC )
} else {
deleteLocally ( viewItem )
}
}
private func buildUnsendRequest ( _ viewItem : ConversationViewItem ) -> UnsendRequest ? {
if let message = viewItem . interaction as ? TSMessage ,
message . isOpenGroupMessage || message . serverHash = = nil { return nil }
let unsendRequest = UnsendRequest ( )
switch viewItem . interaction . interactionType ( ) {
case . incomingMessage :
if let incomingMessage = viewItem . interaction as ? TSIncomingMessage {
unsendRequest . author = incomingMessage . authorId
}
case . outgoingMessage : unsendRequest . author = getUserHexEncodedPublicKey ( )
default : return nil // S h o u l d n e v e r o c c u r
}
unsendRequest . timestamp = viewItem . interaction . timestamp
return unsendRequest
}
func deleteLocally ( _ viewItem : ConversationViewItem ) {
viewItem . deleteLocallyAction ( )
if let unsendRequest = buildUnsendRequest ( viewItem ) {
SNMessagingKitConfiguration . shared . storage . write { transaction in
MessageSender . send ( unsendRequest , to : . contact ( publicKey : getUserHexEncodedPublicKey ( ) ) , using : transaction ) . retainUntilComplete ( )
}
}
}
func deleteForEveryone ( _ viewItem : ConversationViewItem ) {
viewItem . deleteLocallyAction ( )
viewItem . deleteRemotelyAction ( )
if let unsendRequest = buildUnsendRequest ( viewItem ) {
SNMessagingKitConfiguration . shared . storage . write { transaction in
MessageSender . send ( unsendRequest , in : self . thread , using : transaction as ! YapDatabaseReadWriteTransaction )
}
}
}
func save ( _ viewItem : ConversationViewItem ) {
guard viewItem . canSaveMedia ( ) else { return }
viewItem . saveMediaAction ( )
sendMediaSavedNotificationIfNeeded ( for : viewItem )
}
func ban ( _ viewItem : ConversationViewItem ) {
guard let message = viewItem . interaction as ? TSIncomingMessage , message . isOpenGroupMessage else { return }
let explanation = " This will ban the selected user from this room. It won't ban them from other rooms. "
let alert = UIAlertController ( title : " Session " , message : explanation , preferredStyle : . alert )
let threadID = thread . uniqueId !
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : { _ in
let publicKey = message . authorId
guard let openGroupV2 = Storage . shared . getV2OpenGroup ( for : threadID ) else { return }
OpenGroupAPIV2 . ban ( publicKey , from : openGroupV2 . room , on : openGroupV2 . server ) . retainUntilComplete ( )
} ) )
alert . addAction ( UIAlertAction ( title : " Cancel " , style : . default , handler : nil ) )
presentAlert ( alert )
}
func banAndDeleteAllMessages ( _ viewItem : ConversationViewItem ) {
guard let message = viewItem . interaction as ? TSIncomingMessage , message . isOpenGroupMessage else { return }
let explanation = " This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there. "
let alert = UIAlertController ( title : " Session " , message : explanation , preferredStyle : . alert )
let threadID = thread . uniqueId !
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : { _ in
let publicKey = message . authorId
guard let openGroupV2 = Storage . shared . getV2OpenGroup ( for : threadID ) else { return }
OpenGroupAPIV2 . banAndDeleteAllMessages ( publicKey , from : openGroupV2 . room , on : openGroupV2 . server ) . retainUntilComplete ( )
} ) )
alert . addAction ( UIAlertAction ( title : " Cancel " , style : . default , handler : nil ) )
presentAlert ( alert )
}
func handleQuoteViewCancelButtonTapped ( ) {
snInputView . quoteDraftInfo = nil
}
func openURL ( _ url : URL ) {
// U R L s c a n b e u n s a f e , s o a l w a y s a s k t h e u s e r w h e t h e r t h e y w a n t t o o p e n o n e
let title = NSLocalizedString ( " modal_open_url_title " , comment : " " )
let message = String ( format : NSLocalizedString ( " modal_open_url_explanation " , comment : " " ) , url . absoluteString )
let alertVC = UIAlertController . init ( title : title , message : message , preferredStyle : . actionSheet )
let openAction = UIAlertAction . init ( title : NSLocalizedString ( " modal_open_url_button_title " , comment : " " ) , style : . default ) { _ in
UIApplication . shared . open ( url , options : [ : ] , completionHandler : nil )
self . showInputAccessoryView ( )
}
alertVC . addAction ( openAction )
let copyAction = UIAlertAction . init ( title : NSLocalizedString ( " modal_copy_url_button_title " , comment : " " ) , style : . default ) { _ in
UIPasteboard . general . string = url . absoluteString
self . showInputAccessoryView ( )
}
alertVC . addAction ( copyAction )
let cancelAction = UIAlertAction . init ( title : NSLocalizedString ( " cancel " , comment : " " ) , style : . cancel ) { _ in
self . showInputAccessoryView ( )
}
alertVC . addAction ( cancelAction )
self . presentAlert ( alertVC )
}
func joinOpenGroup ( name : String , url : String ) {
// O p e n g r o u p s c a n b e u n s a f e , s o a l w a y s a s k t h e u s e r w h e t h e r t h e y w a n t t o j o i n o n e
let joinOpenGroupModal = JoinOpenGroupModal ( name : name , url : url )
joinOpenGroupModal . modalPresentationStyle = . overFullScreen
joinOpenGroupModal . modalTransitionStyle = . crossDissolve
present ( joinOpenGroupModal , animated : true , completion : nil )
}
func handleReplyButtonTapped ( for viewItem : ConversationViewItem ) {
reply ( viewItem )
}
func showUserDetails ( for sessionID : String ) {
let userDetailsSheet = UserDetailsSheet ( for : sessionID )
userDetailsSheet . modalPresentationStyle = . overFullScreen
userDetailsSheet . modalTransitionStyle = . crossDissolve
present ( userDetailsSheet , animated : true , completion : nil )
}
// MARK: V o i c e M e s s a g e P l a y b a c k
@objc func handleAudioDidFinishPlayingNotification ( _ notification : Notification ) {
// P l a y t h e n e x t v o i c e m e s s a g e i f t h e r e i s o n e
guard let audioPlayer = audioPlayer , let viewItem = audioPlayer . owner as ? ConversationViewItem ,
let index = viewItems . firstIndex ( where : { $0 = = = viewItem } ) , index < ( viewItems . endIndex - 1 ) else { return }
let nextViewItem = viewItems [ index + 1 ]
guard nextViewItem . messageCellType = = . audio else { return }
playOrPauseAudio ( for : nextViewItem )
}
func playOrPauseAudio ( for viewItem : ConversationViewItem ) {
guard let attachment = viewItem . attachmentStream else { return }
let fileManager = FileManager . default
guard let path = attachment . originalFilePath , fileManager . fileExists ( atPath : path ) ,
let url = attachment . originalMediaURL else { return }
if let audioPlayer = audioPlayer {
if let owner = audioPlayer . owner as ? ConversationViewItem , owner = = = viewItem {
audioPlayer . playbackRate = 1
audioPlayer . togglePlayState ( )
return
} else {
audioPlayer . stop ( )
self . audioPlayer = nil
}
}
let audioPlayer = OWSAudioPlayer ( mediaUrl : url , audioBehavior : . audioMessagePlayback , delegate : viewItem )
self . audioPlayer = audioPlayer
audioPlayer . owner = viewItem
audioPlayer . play ( )
audioPlayer . setCurrentTime ( Double ( viewItem . audioProgressSeconds ) )
}
func speedUpAudio ( for viewItem : ConversationViewItem ) {
guard let audioPlayer = audioPlayer , let owner = audioPlayer . owner as ? ConversationViewItem , owner = = = viewItem , audioPlayer . isPlaying else { return }
audioPlayer . playbackRate = 1.5
viewItem . lastAudioMessageView ? . showSpeedUpLabel ( )
}
// MARK: V o i c e M e s s a g e R e c o r d i n g
func startVoiceMessageRecording ( ) {
// R e q u e s t p e r m i s s i o n i f n e e d e d
requestMicrophonePermissionIfNeeded ( ) { [ weak self ] in
self ? . cancelVoiceMessageRecording ( )
}
// K e e p s c r e e n o n
UIApplication . shared . isIdleTimerDisabled = false
guard AVAudioSession . sharedInstance ( ) . recordPermission = = . granted else { return }
// C a n c e l a n y c u r r e n t a u d i o p l a y b a c k
audioPlayer ? . stop ( )
audioPlayer = nil
// C r e a t e U R L
let directory = OWSTemporaryDirectory ( )
let fileName = " \( NSDate . millisecondTimestamp ( ) ) .m4a "
let path = ( directory as NSString ) . appendingPathComponent ( fileName )
let url = URL ( fileURLWithPath : path )
// S e t u p a u d i o s e s s i o n
let isConfigured = audioSession . startAudioActivity ( recordVoiceMessageActivity )
guard isConfigured else {
return cancelVoiceMessageRecording ( )
}
// S e t u p a u d i o r e c o r d e r
let settings : [ String : NSNumber ] = [
AVFormatIDKey : NSNumber ( value : kAudioFormatMPEG4AAC ) ,
AVSampleRateKey : NSNumber ( value : 44100 ) ,
AVNumberOfChannelsKey : NSNumber ( value : 2 ) ,
AVEncoderBitRateKey : NSNumber ( value : 128 * 1024 )
]
let audioRecorder : AVAudioRecorder
do {
audioRecorder = try AVAudioRecorder ( url : url , settings : settings )
audioRecorder . isMeteringEnabled = true
self . audioRecorder = audioRecorder
} catch {
SNLog ( " Couldn't start audio recording due to error: \( error ) . " )
return cancelVoiceMessageRecording ( )
}
// L i m i t v o i c e m e s s a g e s t o a m i n u t e
audioTimer = Timer . scheduledTimer ( withTimeInterval : 180 , repeats : false , block : { [ weak self ] _ in
self ? . snInputView . hideVoiceMessageUI ( )
self ? . endVoiceMessageRecording ( )
} )
// P r e p a r e a u d i o r e c o r d e r
guard audioRecorder . prepareToRecord ( ) else {
SNLog ( " Couldn't prepare audio recorder. " )
return cancelVoiceMessageRecording ( )
}
// S t a r t r e c o r d i n g
guard audioRecorder . record ( ) else {
SNLog ( " Couldn't record audio. " )
return cancelVoiceMessageRecording ( )
}
}
func endVoiceMessageRecording ( ) {
UIApplication . shared . isIdleTimerDisabled = true
// H i d e t h e U I
snInputView . hideVoiceMessageUI ( )
// C a n c e l t h e t i m e r
audioTimer ? . invalidate ( )
// C h e c k p r e c o n d i t i o n s
guard let audioRecorder = audioRecorder else { return }
// G e t d u r a t i o n
let duration = audioRecorder . currentTime
// S t o p t h e r e c o r d i n g
stopVoiceMessageRecording ( )
// C h e c k f o r u s e r m i s u n d e r s t a n d i n g
guard duration > 1 else {
self . audioRecorder = nil
let title = NSLocalizedString ( " VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE " , comment : " " )
let message = NSLocalizedString ( " VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE " , comment : " " )
return OWSAlerts . showAlert ( title : title , message : message )
}
// G e t d a t a
let dataSourceOrNil = DataSourcePath . dataSource ( with : audioRecorder . url , shouldDeleteOnDeallocation : true )
self . audioRecorder = nil
guard let dataSource = dataSourceOrNil else { return SNLog ( " Couldn't load recorded data. " ) }
// C r e a t e a t t a c h m e n t
let fileName = ( NSLocalizedString ( " VOICE_MESSAGE_FILE_NAME " , comment : " " ) as NSString ) . appendingPathExtension ( " m4a " )
dataSource . sourceFilename = fileName
let attachment = SignalAttachment . voiceMessageAttachment ( dataSource : dataSource , dataUTI : kUTTypeMPEG4Audio as String )
guard ! attachment . hasError else {
return showErrorAlert ( for : attachment , onDismiss : nil )
}
// S e n d a t t a c h m e n t
sendAttachments ( [ attachment ] , with : " " )
}
func cancelVoiceMessageRecording ( ) {
snInputView . hideVoiceMessageUI ( )
audioTimer ? . invalidate ( )
stopVoiceMessageRecording ( )
audioRecorder = nil
}
func stopVoiceMessageRecording ( ) {
audioRecorder ? . stop ( )
audioSession . endAudioActivity ( recordVoiceMessageActivity )
}
// MARK: D a t a E x t r a c t i o n N o t i f i c a t i o n s
@objc func sendScreenshotNotificationIfNeeded ( ) {
/*
guard thread is TSContactThread else { return }
let message = DataExtractionNotification ( )
message . kind = . screenshot
Storage . write { transaction in
MessageSender . send ( message , in : self . thread , using : transaction )
}
*/
}
func sendMediaSavedNotificationIfNeeded ( for viewItem : ConversationViewItem ) {
guard thread is TSContactThread , viewItem . interaction . interactionType ( ) = = . incomingMessage else { return }
let message = DataExtractionNotification ( )
message . kind = . mediaSaved ( timestamp : viewItem . interaction . timestamp )
Storage . write { transaction in
MessageSender . send ( message , in : self . thread , using : transaction )
}
}
// MARK: R e q u e s t i n g P e r m i s s i o n
func requestCameraPermissionIfNeeded ( ) -> Bool {
switch AVCaptureDevice . authorizationStatus ( for : . video ) {
case . authorized : return true
case . denied , . restricted :
let modal = PermissionMissingModal ( permission : " camera " ) { }
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
present ( modal , animated : true , completion : nil )
return false
case . notDetermined :
AVCaptureDevice . requestAccess ( for : . video , completionHandler : { _ in } )
return false
default : return false
}
}
func requestMicrophonePermissionIfNeeded ( onNotGranted : @ escaping ( ) -> Void ) {
switch AVAudioSession . sharedInstance ( ) . recordPermission {
case . granted : break
case . denied :
onNotGranted ( )
let modal = PermissionMissingModal ( permission : " microphone " ) {
onNotGranted ( )
}
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
present ( modal , animated : true , completion : nil )
case . undetermined :
onNotGranted ( )
AVAudioSession . sharedInstance ( ) . requestRecordPermission { _ in }
default : break
}
}
func requestLibraryPermissionIfNeeded ( onAuthorized : @ escaping ( ) -> Void ) {
let authorizationStatus : PHAuthorizationStatus
if #available ( iOS 14 , * ) {
authorizationStatus = PHPhotoLibrary . authorizationStatus ( for : . readWrite )
if authorizationStatus = = . notDetermined {
// W h e n t h e u s e r c h o o s e s t o s e l e c t p h o t o s ( w h i c h i s t h e . l i m i t s t a t u s ) ,
// t h e P H P h o t o U I w i l l p r e s e n t t h e p i c k e r v i e w o n t h e t o p o f t h e f r o n t v i e w .
// S i n c e w e h a v e t h e S c r e e n L o c k U I s h o w i n g w h e n w e r e q u e s t p r e m i s s i o n s ,
// t h e p i c k e r v i e w w i l l b e p r e s e n t e d o n t h e t o p o f t h e S c r e e n L o c k U I .
// H o w e v e r , t h e S c r e e n L o c k U I w i l l d i s m i s s w i t h t h e p e r m i s s i o n r e q u e s t a l e r t v i e w , s o
// t h e p i c k e r v i e w t h e n w i l l d i s m i s s , t o o . T h e s e l e c t i o n p r o c e s s c a n n o t b e f i n i s h e d
// t h i s w a y . S o w e a d d a f l a g ( i s R e q u e s t i n g P e r m i s s i o n ) t o p r e v e n t t h e S c r e e n L o c k U I
// f r o m s h o w i n g w h e n w e r e q u e s t t h e p h o t o l i b r a r y p e r m i s s i o n .
Environment . shared . isRequestingPermission = true
let appMode = AppModeManager . shared . currentAppMode
// FIXME: R a t h e r t h a n s e t t i n g t h e a p p m o d e t o l i g h t a n d t h e n t o d a r k a g a i n o n c e w e ' r e d o n e ,
// i t ' d b e b e t t e r t o j u s t c u s t o m i z e t h e a p p e a r a n c e o f t h e i m a g e p i c k e r . T h e r e d o e s n ' t c u r r e n t l y
// a p p e a r t o b e a g o o d w a y t o d o s o t h o u g h . . .
AppModeManager . shared . setCurrentAppMode ( to : . light )
PHPhotoLibrary . requestAuthorization ( for : . readWrite ) { status in
DispatchQueue . main . async {
AppModeManager . shared . setCurrentAppMode ( to : appMode )
}
Environment . shared . isRequestingPermission = false
if [ PHAuthorizationStatus . authorized , PHAuthorizationStatus . limited ] . contains ( status ) {
onAuthorized ( )
}
}
}
} else {
authorizationStatus = PHPhotoLibrary . authorizationStatus ( )
if authorizationStatus = = . notDetermined {
PHPhotoLibrary . requestAuthorization { status in
if status = = . authorized {
onAuthorized ( )
}
}
}
}
switch authorizationStatus {
case . authorized , . limited :
onAuthorized ( )
case . denied , . restricted :
let modal = PermissionMissingModal ( permission : " library " ) { }
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
present ( modal , animated : true , completion : nil )
default : return
}
}
// MARK: - C o n v e n i e n c e
func showErrorAlert ( for attachment : SignalAttachment , onDismiss : ( ( ) -> ( ) ) ? ) {
let title = NSLocalizedString ( " ATTACHMENT_ERROR_ALERT_TITLE " , comment : " " )
let message = attachment . localizedErrorDescription ? ? SignalAttachment . missingDataErrorMessage
OWSAlerts . showAlert ( title : title , message : message , buttonTitle : nil ) { _ in
onDismiss ? ( )
}
}
}
// MARK: - U I D o c u m e n t I n t e r a c t i o n C o n t r o l l e r D e l e g a t e
extension ConversationVC : UIDocumentInteractionControllerDelegate {
func documentInteractionControllerViewControllerForPreview ( _ controller : UIDocumentInteractionController ) -> UIViewController {
return self
}
}
// MARK: - M e s s a g e R e q u e s t A c t i o n s
extension ConversationVC {
fileprivate func approveMessageRequestIfNeeded ( for thread : TSThread ? , isNewThread : Bool , timestamp : UInt64 ) -> Promise < Void > {
guard let contactThread : TSContactThread = thread as ? TSContactThread else { return Promise . value ( ( ) ) }
// I f t h e c o n t a c t d o e s n ' t e x i s t t h e n w e s h o u l d c r e a t e i t s o w e c a n s t o r e t h e ' i s A p p r o v e d ' s t a t e
// ( i t ' l l b e u p d a t e d w i t h c o r r e c t p r o f i l e i n f o i f t h e y a c c e p t t h e m e s s a g e r e q u e s t s o t h i s
// s h o u l d n ' t c a u s e w e i r d b e h a v i o u r s )
let sessionId : String = contactThread . contactSessionID ( )
let contact : Contact = ( Storage . shared . getContact ( with : sessionId ) ? ? Contact ( sessionID : sessionId ) )
guard ! contact . isApproved else { return Promise . value ( ( ) ) }
return Promise . value ( ( ) )
. then { [ weak self ] _ -> Promise < Void > in
guard ! isNewThread else { return Promise . value ( ( ) ) }
guard let strongSelf = self else { return Promise ( error : MessageSender . Error . noThread ) }
// I f w e a r e n ' t c r e a t i n g a n e w t h r e a d ( i e . s e n d i n g a m e s s a g e r e q u e s t ) t h e n s e n d a
// m e s s a g e R e q u e s t R e s p o n s e b a c k t o t h e s e n d e r ( t h i s a l l o w s t h e s e n d e r t o k n o w t h a t
// t h e y h a v e b e e n a p p r o v e d a n d c a n n o w u s e t h i s c o n t a c t i n c l o s e d g r o u p s )
let ( promise , seal ) = Promise < Void > . pending ( )
let messageRequestResponse : MessageRequestResponse = MessageRequestResponse (
isApproved : true
)
messageRequestResponse . sentTimestamp = timestamp
// S h o w a l o a d i n g i n d i c a t o r
ModalActivityIndicatorViewController . present ( fromViewController : strongSelf , canCancel : false ) { _ in
seal . fulfill ( ( ) )
}
return promise
. then { _ -> Promise < Void > in
let ( promise , seal ) = Promise < Void > . pending ( )
Storage . writeSync { transaction in
MessageSender . sendNonDurably ( messageRequestResponse , in : contactThread , using : transaction )
. done { seal . fulfill ( ( ) ) }
. catch { _ in seal . fulfill ( ( ) ) } // F u l f i l l e v e n i f t h i s f a i l e d ; t h e c o n f i g u r a t i o n i n t h e s w a r m s h o u l d b e a t m o s t 2 d a y s o l d
. retainUntilComplete ( )
}
return promise
}
. map { _ in
if self ? . presentedViewController is ModalActivityIndicatorViewController {
self ? . dismiss ( animated : true , completion : nil ) // D i s m i s s t h e l o a d e r
}
}
}
. map { _ in
// D e f a u l t ' d i d A p p r o v e M e ' t o t r u e f o r t h e p e r s o n a p p r o v i n g t h e m e s s a g e r e q u e s t
Storage . write { transaction in
contact . isApproved = true
contact . didApproveMe = ( contact . didApproveMe || ! isNewThread )
Storage . shared . setContact ( contact , using : transaction )
}
// H i d e t h e ' m e s s a g e R e q u e s t V i e w ' s i n c e t h e r e q u e s t h a s b e e n a p p r o v e d a n d f o r c e a c o n f i g
// s y n c t o p r o p a g a t e t h e c o n t a c t a p p r o v a l s t a t e ( b o t h m u s t r u n o n t h e m a i n t h r e a d )
DispatchQueue . main . async { [ weak self ] in
let messageRequestViewWasVisible : Bool = ( self ? . messageRequestView . isHidden = = false )
UIView . animate ( withDuration : 0.3 ) {
self ? . messageRequestView . isHidden = true
self ? . scrollButtonMessageRequestsBottomConstraint ? . isActive = false
self ? . scrollButtonBottomConstraint ? . isActive = true
// U p d a t e t h e t a b l e c o n t e n t i n s e t a n d o f f s e t t o a c c o u n t f o r t h e d i s s a p e a r a n c e o f
// t h e m e s s a g e R e q u e s t s V i e w
if messageRequestViewWasVisible {
let messageRequestsOffset : CGFloat = ( ( self ? . messageRequestView . bounds . height ? ? 0 ) + 16 )
let oldContentInset : UIEdgeInsets = ( self ? . messagesTableView . contentInset ? ? UIEdgeInsets . zero )
self ? . messagesTableView . contentInset = UIEdgeInsets (
top : 0 ,
leading : 0 ,
bottom : max ( oldContentInset . bottom - messageRequestsOffset , 0 ) ,
trailing : 0
)
}
}
// U p d a t e U I
self ? . updateNavBarButtons ( )
if let viewControllers : [ UIViewController ] = self ? . navigationController ? . viewControllers ,
let messageRequestsIndex = viewControllers . firstIndex ( where : { $0 is MessageRequestsViewController } ) ,
messageRequestsIndex > 0 {
var newViewControllers = viewControllers
newViewControllers . remove ( at : messageRequestsIndex )
self ? . navigationController ? . setViewControllers ( newViewControllers , animated : false )
}
// S e n d a s y n c m e s s a g e w i t h t h e d e t a i l s o f t h e c o n t a c t
if let appDelegate = UIApplication . shared . delegate as ? AppDelegate {
appDelegate . forceSyncConfigurationNowIfNeeded ( ) . retainUntilComplete ( )
}
}
}
}
@objc func acceptMessageRequest ( ) {
let promise : Promise < Void > = self . approveMessageRequestIfNeeded (
for : self . thread ,
isNewThread : false ,
timestamp : NSDate . millisecondTimestamp ( )
)
// S h o w a n e r r o r i n d i c a t i n g t h a t a p p r o v i n g t h e t h r e a d f a i l e d
promise . catch ( on : DispatchQueue . main ) { [ weak self ] _ in
let alert = UIAlertController ( title : " Session " , message : NSLocalizedString ( " MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE " , comment : " " ) , preferredStyle : . alert )
alert . addAction ( UIAlertAction ( title : NSLocalizedString ( " BUTTON_OK " , comment : " " ) , style : . default , handler : nil ) )
self ? . present ( alert , animated : true , completion : nil )
}
promise . retainUntilComplete ( )
}
@objc func deleteMessageRequest ( ) {
guard let uniqueId : String = thread . uniqueId else { return }
let alertVC : UIAlertController = UIAlertController ( title : NSLocalizedString ( " MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON " , comment : " " ) , message : nil , preferredStyle : . actionSheet )
alertVC . addAction ( UIAlertAction ( title : NSLocalizedString ( " TXT_DELETE_TITLE " , comment : " " ) , style : . destructive ) { _ in
// D e l e t e t h e r e q u e s t
Storage . write (
with : { [ weak self ] transaction in
Storage . shared . cancelPendingMessageSendJobs ( for : uniqueId , using : transaction )
// U p d a t e t h e c o n t a c t
if let contactThread : TSContactThread = self ? . thread as ? TSContactThread {
let sessionId : String = contactThread . contactSessionID ( )
if let contact : Contact = Storage . shared . getContact ( with : sessionId ) {
contact . isApproved = false
contact . isBlocked = true
// N o t e : W e s e t t h i s t o t r u e s o t h e c u r r e n t u s e r w i l l b e a b l e t o s e n d a
// m e s s a g e t o t h e p e r s o n w h o o r i g i n a l l y s e n t t h e m t h e m e s s a g e r e q u e s t i n
// t h e f u t u r e i f t h e y u n b l o c k t h e m
contact . didApproveMe = true
Storage . shared . setContact ( contact , using : transaction )
}
}
// D e l e t e a l l t h r e a d c o n t e n t
self ? . thread . removeAllThreadInteractions ( with : transaction )
self ? . thread . remove ( with : transaction )
} ,
completion : { [ weak self ] in
// B l o c k t h e c o n t a c t
if let sessionId : String = ( self ? . thread as ? TSContactThread ) ? . contactSessionID ( ) , ! OWSBlockingManager . shared ( ) . isRecipientIdBlocked ( sessionId ) {
// S t o p o b s e r v i n g t h e ` B l o c k L i s t D i d C h a n g e ` n o t i f i c a t i o n ( w e a r e a b o u t t o p o p t h e s c r e e n
// s o s h o w i n g t h e b a n n e r j u s t l o o k s b u g g y )
if let strongSelf = self {
NotificationCenter . default . removeObserver ( strongSelf , name : NSNotification . Name ( rawValue : kNSNotificationName_BlockListDidChange ) , object : nil )
}
OWSBlockingManager . shared ( ) . addBlockedPhoneNumber ( sessionId )
}
// F o r c e a c o n f i g s y n c a n d p o p t o t h e p r e v i o u s s c r e e n ( b o t h m u s t r u n o n t h e m a i n t h r e a d )
DispatchQueue . main . async {
if let appDelegate = UIApplication . shared . delegate as ? AppDelegate {
appDelegate . forceSyncConfigurationNowIfNeeded ( ) . retainUntilComplete ( )
}
self ? . navigationController ? . popViewController ( animated : true )
}
}
)
} )
alertVC . addAction ( UIAlertAction ( title : NSLocalizedString ( " TXT_CANCEL_TITLE " , comment : " " ) , style : . cancel , handler : nil ) )
self . present ( alertVC , animated : true , completion : nil )
}
}