// C o p y r i g h t © 2 0 2 2 R a n g e p r o o f P t y L t d . A l l r i g h t s r e s e r v e d .
import UIKit
import CoreServices
import Photos
import PhotosUI
import PromiseKit
import GRDB
import SessionUtilitiesKit
import SignalUtilitiesKit
extension ConversationVC :
InputViewDelegate ,
MessageCellDelegate ,
ContextMenuActionDelegate ,
ScrollToBottomButtonDelegate ,
SendMediaNavDelegate ,
UIDocumentPickerDelegate ,
AttachmentApprovalViewControllerDelegate ,
GifPickerViewControllerDelegate
{
@objc 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 u n a p p r o v e d t h r e a d s
guard ! viewModel . viewData . requiresApproval else { return }
openSettings ( )
}
@objc func openSettings ( ) {
let settingsVC : OWSConversationSettingsViewController = OWSConversationSettingsViewController ( )
settingsVC . configure (
withThreadId : viewModel . viewData . thread . id ,
threadName : viewModel . viewData . threadName ,
isClosedGroup : ( viewModel . viewData . thread . variant = = . closedGroup ) ,
isOpenGroup : ( viewModel . viewData . thread . variant = = . openGroup ) ,
isNoteToSelf : viewModel . viewData . threadIsNoteToSelf
)
settingsVC . conversationSettingsViewDelegate = self
navigationController ? . pushViewController ( settingsVC , animated : true , completion : nil )
}
// MARK: - S c r o l l T o B o t t o m B u t t o n D e l e g a t e
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 .
scrollToBottom ( isAnimated : true )
}
// MARK: - B l o c k i n g
@objc func unblock ( ) {
guard self . viewModel . viewData . thread . variant = = . contact else { return }
let publicKey : String = self . viewModel . viewData . thread . id
UIView . animate (
withDuration : 0.25 ,
animations : {
self . blockedBanner . alpha = 0
} ,
completion : { _ in
GRDBStorage . shared . write { db in
try Contact
. filter ( id : publicKey )
. updateAll ( db , Contact . Columns . isBlocked . set ( to : true ) )
try MessageSender . syncConfiguration ( db , forceSyncNow : true ) . retainUntilComplete ( )
}
}
)
}
func showBlockedModalIfNeeded ( ) -> Bool {
guard viewModel . viewData . threadIsBlocked else { return false }
let blockedModal = BlockedModal ( publicKey : viewModel . viewData . thread . id )
blockedModal . modalPresentationStyle = . overFullScreen
blockedModal . modalTransitionStyle = . crossDissolve
present ( blockedModal , animated : true , completion : nil )
return true
}
// MARK: - S e n d M e d i a N a v D e l e g a t e
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 ? ? " " )
}
// MARK: - A t t a c h m e n t A p p r o v a l V i e w C o n t r o l l e r D e l e g a t e
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 ? ? " "
}
// MARK: - E x p a n d i n g A t t a c h m e n t s B u t t o n D e l e g a t e
func handleGIFButtonTapped ( ) {
let gifVC = GifPickerViewController ( )
gifVC . delegate = self
let navController = OWSNavigationController ( rootViewController : gifVC )
navController . modalPresentationStyle = . fullScreen
present ( navController , animated : true ) { }
}
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 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 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 )
}
// MARK: - G i f 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
func gifPickerDidSelect ( attachment : SignalAttachment ) {
showAttachmentApprovalDialog ( for : [ attachment ] )
}
// 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
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 {
DispatchQueue . main . async { [ weak self ] in
let alert = UIAlertController ( title : " Session " , message : " An error occurred. " , preferredStyle : . alert )
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : nil ) )
self ? . present ( alert , animated : true , completion : nil )
}
return
}
let type = urlResourceValues . typeIdentifier ? ? ( kUTTypeData as String )
guard urlResourceValues . isDirectory != true else {
DispatchQueue . main . async {
OWSAlerts . showAlert (
title : " ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE " . localized ( ) ,
message : " ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY " . localized ( )
)
}
return
}
let fileName = urlResourceValues . name ? ? NSLocalizedString ( " ATTACHMENT_DEFAULT_FILENAME " , comment : " " )
guard let dataSource = DataSourcePath . dataSource ( with : url , shouldDeleteOnDeallocation : false ) else {
DispatchQueue . main . async {
OWSAlerts . showAlert ( title : " ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE " . localized ( ) )
}
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
SignalAttachment
. compressVideoAsMp4 (
dataSource : dataSource ,
dataUTI : kUTTypeMPEG4 as String
)
. attachmentPromise
. done { attachment in
guard
! modalActivityIndicator . wasCancelled ,
let attachment = attachment as ? SignalAttachment
else { return }
modalActivityIndicator . dismiss {
guard ! attachment . hasError else {
self ? . showErrorAlert ( for : attachment , onDismiss : nil )
return
}
self ? . showAttachmentApprovalDialog ( for : [ attachment ] )
}
}
. retainUntilComplete ( )
}
}
// MARK: - I n p u t V i e w D e l e g a t e
// 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 ) )
guard ! text . isEmpty else { return }
if text . contains ( mnemonic ) && ! viewModel . viewData . threadIsNoteToSelf && ! 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 )
}
// 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 thread : SessionThread = viewModel . viewData . thread
let oldThreadShouldBeVisible : Bool = thread . shouldBeVisible
let sentTimestampMs : Int64 = Int64 ( floor ( ( Date ( ) . timeIntervalSince1970 * 1000 ) ) )
let linkPreviewDraft : OWSLinkPreviewDraft ? = snInputView . linkPreviewInfo ? . draft
let quoteModel : QuotedReplyModel ? = snInputView . quoteDraftInfo ? . model
for : self . thread ,
approveMessageRequestIfNeeded (
for : thread ,
isNewThread : ! oldThreadShouldBeVisible ,
timestampMs : ( sentTimestampMs - 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
)
. done { [ weak self ] _ in
GRDBStorage . shared . writeAsync (
updates : { db in
// U p d a t e t h e t h r e a d t o b e v i s i b l e
_ = try SessionThread
. filter ( id : thread . id )
. updateAll ( db , SessionThread . Columns . shouldBeVisible . set ( to : true ) )
// C r e a t e t h e i n t e r a c t i o n
let userPublicKey : String = getUserHexEncodedPublicKey ( db )
let interaction : Interaction = try Interaction (
threadId : thread . id ,
authorId : getUserHexEncodedPublicKey ( db ) ,
variant : . standardOutgoing ,
body : text ,
timestampMs : sentTimestampMs ,
hasMention : text . contains ( " @ \( userPublicKey ) " ) ,
linkPreviewUrl : linkPreviewDraft ? . urlString
) . inserted ( db )
// I f t h e r e i s a L i n k P r e v i e w a n d i t d o e s n ' t m a t c h a n e x i s t i n g o n e t h e n a d d i t n o w
if
let linkPreviewDraft : OWSLinkPreviewDraft = linkPreviewDraft ,
( try ? interaction . linkPreview . isEmpty ( db ) ) = = true
{
var attachmentId : String ?
// I f t h e L i n k P r e v i e w h a s i m a g e d a t a t h e n c r e a t e a n a t t a c h m e n t f i r s t
if let imageData : Data = linkPreviewDraft . jpegImageData {
attachmentId = try LinkPreview . saveAttachmentIfPossible (
db ,
imageData : imageData ,
mimeType : OWSMimeTypeImageJpeg
)
}
try LinkPreview (
url : linkPreviewDraft . urlString ,
title : linkPreviewDraft . title ,
attachmentId : attachmentId
) . insert ( db )
}
guard let interactionId : Int64 = interaction . id else { return }
// I f t h e r e i s a Q u o t e t h e i n s e r t i t n o w
if let quoteModel : QuotedReplyModel = quoteModel {
try Quote (
interactionId : interactionId ,
authorId : quoteModel . authorId ,
timestampMs : quoteModel . timestampMs ,
body : quoteModel . body ,
attachmentId : quoteModel . attachment ? . id
) . insert ( db )
}
try MessageSender . send (
db ,
interaction : interaction ,
with : [ ] ,
in : thread
)
} ,
completion : { [ weak self ] _ , _ in
self ? . viewModel . sentMessageBeforeUpdate = true
self ? . handleMessageSent ( )
}
)
}
. catch ( on : DispatchQueue . main ) { [ weak self ] _ in
// 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
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 )
}
. 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 text = replaceMentions ( in : snInputView . text . trimmingCharacters ( in : . whitespacesAndNewlines ) )
// 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 thread : SessionThread = viewModel . viewData . thread
let oldThreadShouldBeVisible : Bool = thread . shouldBeVisible
let sentTimestampMs : Int64 = Int64 ( floor ( ( Date ( ) . timeIntervalSince1970 * 1000 ) ) )
approveMessageRequestIfNeeded (
for : thread ,
isNewThread : ! oldThreadShouldBeVisible ,
timestampMs : ( sentTimestampMs - 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
)
. done { [ weak self ] _ in
GRDBStorage . shared . writeAsync (
updates : { db in
// U p d a t e t h e t h r e a d t o b e v i s i b l e
_ = try SessionThread
. filter ( id : thread . id )
. updateAll ( db , SessionThread . Columns . shouldBeVisible . set ( to : true ) )
// C r e a t e t h e i n t e r a c t i o n
let currentUserPublicKey : String = getUserHexEncodedPublicKey ( db )
let interaction : Interaction = try Interaction (
threadId : thread . id ,
authorId : getUserHexEncodedPublicKey ( db ) ,
variant : . standardOutgoing ,
body : text ,
timestampMs : sentTimestampMs ,
hasMention : text . contains ( " @ \( currentUserPublicKey ) " )
) . inserted ( db )
try MessageSender . send (
db ,
interaction : interaction ,
with : attachments ,
in : thread
)
} ,
completion : { [ weak self ] _ , _ in
self ? . viewModel . sentMessageBeforeUpdate = true
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
DispatchQueue . main . async {
onComplete ? ( )
}
}
)
}
. catch ( on : DispatchQueue . main ) { [ weak self ] _ in
// 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
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 )
}
. retainUntilComplete ( )
}
func handleMessageSent ( ) {
DispatchQueue . main . async { [ weak self ] in
self ? . snInputView . text = " "
self ? . snInputView . quoteDraftInfo = nil
}
resetMentions ( )
if Environment . shared . preferences . soundInForeground ( ) {
let soundID = Preferences . Sound . systemSoundId ( for : . messageSent , quiet : true )
AudioServicesPlaySystemSound ( soundID )
}
let thread : SessionThread = self . viewModel . viewData . thread
GRDBStorage . shared . writeAsync { db in
TypingIndicators . didStopTyping ( db , in : thread , direction : . outgoing )
_ = try SessionThread
. filter ( id : thread . id )
. updateAll ( db , SessionThread . Columns . messageDraft . set ( to : " " ) )
}
}
func showLinkPreviewSuggestionModal ( ) {
let linkPreviewModel = LinkPreviewModal ( ) { [ weak self ] in
self ? . snInputView . autoGenerateLinkPreview ( )
}
linkPreviewModel . modalPresentationStyle = . overFullScreen
linkPreviewModel . modalTransitionStyle = . crossDissolve
present ( linkPreviewModel , animated : true , completion : nil )
}
func inputTextViewDidChangeContent ( _ inputTextView : InputTextView ) {
let newText : String = ( inputTextView . text ? ? " " )
if ! newText . isEmpty {
let thread : SessionThread = self . viewModel . viewData . thread
GRDBStorage . shared . writeAsync { db in
TypingIndicators . didStartTyping (
db ,
in : thread ,
direction : . outgoing ,
timestampMs : Int64 ( floor ( Date ( ) . timeIntervalSince1970 * 1000 ) )
)
}
}
updateMentions ( for : newText )
}
// 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 )
}
// MARK: - - M e n t i o n s
func handleMentionSelected ( _ mentionInfo : ConversationViewModel . MentionInfo , from view : MentionSelectionView ) {
guard let currentMentionStartIndex = currentMentionStartIndex else { return }
mentions . append ( mentionInfo )
let newText : String = snInputView . text . replacingCharacters (
in : currentMentionStartIndex . . . ,
with : " @ \( mentionInfo . profile . displayName ( for : self . viewModel . viewData . thread . variant ) ) "
)
snInputView . text = newText
self . currentMentionStartIndex = nil
snInputView . hideMentionsUI ( )
mentions = mentions . filter { mentionInfo -> Bool in
newText . contains ( mentionInfo . profile . displayName ( for : self . viewModel . viewData . thread . variant ) )
}
}
func updateMentions ( for newText : String ) {
guard ! newText . isEmpty else {
if currentMentionStartIndex != nil {
snInputView . hideMentionsUI ( )
}
resetMentions ( )
return
}
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 {
currentMentionStartIndex = lastCharacterIndex
snInputView . showMentionsUI ( for : self . viewModel . mentions ( ) )
}
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 @
snInputView . showMentionsUI ( for : self . viewModel . mentions ( for : query ) )
}
}
}
func resetMentions ( ) {
currentMentionStartIndex = nil
mentions = [ ]
}
func replaceMentions ( in text : String ) -> String {
var result = text
for mention in mentions {
guard let range = result . range ( of : " @ \( mention . profile . displayName ( for : mention . threadVariant ) ) " ) else { continue }
result = result . replacingCharacters ( in : range , with : " @ \( mention . profile . id ) " )
}
return result
}
func showInputAccessoryView ( ) {
UIView . animate ( withDuration : 0.25 , animations : {
self . inputAccessoryView ? . isHidden = false
self . inputAccessoryView ? . alpha = 1
} )
}
// MARK: M e s s a g e C e l l D e l e g a t e
func handleItemLongPressed ( _ item : ConversationViewModel . Item ) {
// 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 keyWindow : UIWindow = UIApplication . shared . keyWindow ,
let index = viewModel . viewData . items . firstIndex ( of : item ) ,
let cell = tableView . cellForRow ( at : IndexPath ( row : index , section : 0 ) ) as ? VisibleMessageCell ,
let snapshot = cell . bubbleView . snapshotView ( afterScreenUpdates : false ) ,
contextMenuWindow = = nil ,
let actions : [ ContextMenuVC . Action ] = ContextMenuVC . actions (
for : item ,
currentUserIsOpenGroupModerator : OpenGroupAPIV2 . isUserModerator (
self . viewModel . viewData . userPublicKey ,
for : self . viewModel . viewData . openGroupRoom ,
on : self . viewModel . viewData . openGroupServer
) ,
delegate : self
)
else { return }
UIImpactFeedbackGenerator ( style : . heavy ) . impactOccurred ( )
self . contextMenuWindow = ContextMenuWindow ( )
self . contextMenuVC = ContextMenuVC (
snapshot : snapshot ,
frame : cell . convert ( cell . bubbleView . frame , to : keyWindow ) ,
item : item ,
actions : actions
) { [ weak self ] in
self ? . contextMenuWindow ? . isHidden = true
self ? . contextMenuVC = nil
self ? . contextMenuWindow = nil
self ? . scrollButton . alpha = 0
UIView . animate ( withDuration : 0.25 ) {
self ? . scrollButton . alpha = ( self ? . getScrollButtonOpacity ( ) ? ? 0 )
self ? . unreadCountView . alpha = ( self ? . scrollButton . alpha ? ? 0 )
}
}
self . contextMenuWindow ? . backgroundColor = . clear
self . contextMenuWindow ? . rootViewController = self . contextMenuVC
self . contextMenuWindow ? . makeKeyAndVisible ( )
}
func handleItemTapped ( _ item : ConversationViewModel . Item , gestureRecognizer : UITapGestureRecognizer ) {
guard item . interactionVariant != . standardOutgoing || item . state != . failed else {
// 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 : item )
return
}
// I f i t ' s a n i n c o m i n g m e d i a m e s s a g e a n d t h e t h r e a d i s n ' t t r u s t e d t h e n s h o w t h e p l a c e h o l d e r v i e w
if item . cellType != . textOnlyMessage && item . interactionVariant = = . standardIncoming && ! item . isThreadTrusted {
let modal = DownloadAttachmentModal ( profile : item . profile )
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
present ( modal , animated : true , completion : nil )
return
}
switch item . cellType {
case . audio : viewModel . playOrPauseAudio ( for : item )
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 ,
let contact : Contact ? = GRDBStorage . shared . read ( { db in try Contact . fetchOne ( db , id : thread . contactSessionID ( ) ) } ) ,
contact ? . 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 :
guard
let attachment : Attachment = item . attachments ? . first ,
let originalFilePath : String = attachment . originalFilePath
else { return }
let fileUrl : URL = URL ( fileURLWithPath : originalFilePath )
// O p e n a p r e v i e w o f t h e d o c u m e n t f o r t e x t , p d f o r m i c r o s o f t f i l e s
if
attachment . isText ||
attachment . isMicrosoftDoc ||
attachment . contentType = = OWSMimeTypeApplicationPdf
{
let interactionController : UIDocumentInteractionController = UIDocumentInteractionController ( url : fileUrl )
interactionController . delegate = self
interactionController . presentPreview ( animated : true )
return
}
// O t h e r w i s e s h a r e t h e f i l e
let shareVC = UIActivityViewController ( activityItems : [ fileUrl ] , applicationActivities : nil )
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 handleItemDoubleTapped ( _ item : ConversationViewModel . Item ) {
switch item . cellType {
// 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
case . audio : self . viewModel . speedUpAudio ( for : item )
default : break
}
}
func handleItemSwiped ( _ item : ConversationViewModel . Item , state : SwipeState ) {
switch state {
case . began : tableView . isScrollEnabled = false
case . ended , . cancelled : tableView . isScrollEnabled = true
}
}
func showFullText ( _ item : ConversationViewModel . Item ) {
}
func openUrl ( _ urlString : String ) {
guard let url : URL = URL ( string : urlString ) else { return }
// 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 alertVC = UIAlertController . init (
title : " modal_open_url_title " . localized ( ) ,
message : String ( format : " modal_open_url_explanation " . localized ( ) , url . absoluteString ) ,
preferredStyle : . actionSheet
)
alertVC . addAction ( UIAlertAction . init ( title : " modal_open_url_button_title " . localized ( ) , style : . default ) { [ weak self ] _ in
UIApplication . shared . open ( url , options : [ : ] , completionHandler : nil )
self ? . showInputAccessoryView ( )
} )
alertVC . addAction ( UIAlertAction . init ( title : " modal_copy_url_button_title " . localized ( ) , style : . default ) { [ weak self ] _ in
UIPasteboard . general . string = url . absoluteString
self ? . showInputAccessoryView ( )
} )
alertVC . addAction ( UIAlertAction . init ( title : " cancel " . localized ( ) , style : . cancel ) { [ weak self ] _ in
self ? . showInputAccessoryView ( )
} )
self . presentAlert ( alertVC )
}
func handleReplyButtonTapped ( for item : ConversationViewModel . Item ) {
reply ( item )
}
func showUserDetails ( for profile : Profile ) {
let userDetailsSheet = UserDetailsSheet ( for : profile )
userDetailsSheet . modalPresentationStyle = . overFullScreen
userDetailsSheet . modalTransitionStyle = . crossDissolve
present ( userDetailsSheet , animated : true , completion : nil )
}
// MARK: - - a c t i o n h a n d l i n g
func showFailedMessageSheet ( for item : ConversationViewModel . Item ) {
let sheet = UIAlertController ( title : item . mostRecentFailureText , message : nil , preferredStyle : . actionSheet )
sheet . addAction ( UIAlertAction ( title : " Cancel " , style : . cancel , handler : nil ) )
sheet . addAction ( UIAlertAction ( title : " Delete " , style : . destructive , handler : { _ in
GRDBStorage . shared . writeAsync { db in
try Interaction
. filter ( id : item . interactionId )
. deleteAll ( db )
}
} ) )
sheet . addAction ( UIAlertAction ( title : " Resend " , style : . default , handler : { _ in
GRDBStorage . shared . writeAsync { [ weak self ] db in
guard
let interaction : Interaction = try ? Interaction . fetchOne ( db , id : item . interactionId ) ,
let thread : SessionThread = self ? . viewModel . viewData . thread
else { return }
try MessageSender . send (
db ,
interaction : interaction ,
in : thread
)
}
} ) )
// 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 : String = " HTTP request failed at destination (Service node "
if let mostRecentFailureText : String = item . mostRecentFailureText , mostRecentFailureText . hasPrefix ( prefix ) {
let rest = mostRecentFailureText . 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 ) { _ in
UIPasteboard . general . string = snodeAddress
} )
}
}
present ( sheet , animated : true , completion : nil )
}
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 = JoinOpenGroupModal ( name : name , url : url )
joinOpenGroupModal . modalPresentationStyle = . overFullScreen
joinOpenGroupModal . modalTransitionStyle = . crossDissolve
present ( joinOpenGroupModal , animated : true , completion : nil )
}
// MARK: - C o n t e x t M e n u A c t i o n D e l e g a t e
func reply ( _ item : ConversationViewModel . Item ) {
let maybeQuoteDraft : QuotedReplyModel ? = QuotedReplyModel . quotedReplyForSending (
threadId : self . viewModel . viewData . thread . id ,
authorId : item . authorId ,
variant : item . interactionVariant ,
body : item . body ,
timestampMs : item . timestampMs ,
attachments : item . attachments ,
linkPreview : item . linkPreview
)
guard let quoteDraft : QuotedReplyModel = maybeQuoteDraft else { return }
snInputView . quoteDraftInfo = (
model : quoteDraft ,
isOutgoing : ( item . interactionVariant = = . standardOutgoing )
)
snInputView . becomeFirstResponder ( )
}
func copy ( _ item : ConversationViewModel . Item ) {
switch item . cellType {
case . typingIndicator : break
case . textOnlyMessage :
UIPasteboard . general . string = item . body
case . audio , . genericAttachment , . mediaMessage :
guard
item . attachments ? . count = = 1 ,
let attachment : Attachment = item . attachments ? . first ,
attachment . isValid ,
(
attachment . state = = . downloaded ||
attachment . state = = . uploaded
) ,
let utiType : String = MIMETypeUtil . utiType ( forMIMEType : attachment . contentType ) ,
let originalFilePath : String = attachment . originalFilePath ,
let data : Data = try ? Data ( contentsOf : URL ( fileURLWithPath : originalFilePath ) )
else { return }
UIPasteboard . general . setData ( data , forPasteboardType : utiType )
}
}
func copySessionID ( _ item : ConversationViewModel . Item ) {
guard item . interactionVariant = = . standardIncoming || item . interactionVariant = = . standardIncomingDeleted else {
return
}
UIPasteboard . general . string = item . authorId
}
func delete ( _ item : ConversationViewModel . Item ) {
// O n l y a l l o w d e l e t i o n o n i n c o m i n g a n d o u t g o i n g m e s s a g e s
guard item . interactionVariant = = . standardIncoming || item . interactionVariant = = . standardOutgoing else {
return
}
let thread : SessionThread = self . viewModel . viewData . thread
let threadName : String = self . viewModel . viewData . threadName
let userPublicKey : String = getUserHexEncodedPublicKey ( )
// R e m o t e d e l e t i o n l o g i c
func deleteRemotely ( from viewController : UIViewController ? , request : Promise < Void > , onComplete : ( ( ) -> ( ) ) ? ) {
// S h o w a l o a d i n g i n d i c a t o r
let ( promise , seal ) = Promise < Void > . pending ( )
ModalActivityIndicatorViewController . present ( fromViewController : viewController , canCancel : false ) { _ in
seal . fulfill ( ( ) )
}
promise
. then { _ -> Promise < Void > in request }
. done { _ in
// D e l e t e t h e i n t e r a c t i o n ( a n d a s s o c i a t e d d a t a ) f r o m t h e d a t a b a s e
GRDBStorage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : item . interactionId )
. deleteAll ( db )
}
}
. ensure {
DispatchQueue . main . async { [ weak self ] 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
}
onComplete ? ( )
}
}
. retainUntilComplete ( )
}
// H o w w e d e l e t e t h e m e s s a g e d i f f e r s d e p e n d i n g o n t h e t y p e o f t h r e a d
switch item . threadVariant {
// 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
case . openGroup :
// I f i t ' s a n i n c o m i n g m e s s a g e t h e u s e r m u s t h a v e m o d e r a t o r s t a t u s
let result : ( openGroupServerMessageId : Int64 ? , openGroup : OpenGroup ? ) ? = GRDBStorage . shared . read { db -> ( Int64 ? , OpenGroup ? ) in
(
try Interaction
. select ( . openGroupServerMessageId )
. filter ( id : item . interactionId )
. asRequest ( of : Int64 . self )
. fetchOne ( db ) ,
try OpenGroup . fetchOne ( db , id : thread . id )
)
}
guard
let openGroup : OpenGroup = result ? . openGroup ,
let openGroupServerMessageId : Int64 = result ? . openGroupServerMessageId , (
item . interactionVariant != . standardIncoming ||
OpenGroupAPIV2 . isUserModerator ( userPublicKey , for : openGroup . room , on : openGroup . server )
)
else { return }
// D e l e t e t h e m e s s a g e f r o m t h e o p e n g r o u p
deleteRemotely (
from : self ,
request : OpenGroupAPIV2 . deleteMessage (
with : openGroupServerMessageId ,
from : openGroup . room ,
on : openGroup . server
)
) { [ weak self ] in
self ? . showInputAccessoryView ( )
}
case . contact , . closedGroup :
let serverHash : String ? = GRDBStorage . shared . read { db -> String ? in
try Interaction
. select ( . serverHash )
. filter ( id : item . interactionId )
. asRequest ( of : String . self )
. fetchOne ( db )
}
let unsendRequest : UnsendRequest = UnsendRequest (
timestamp : UInt64 ( item . timestampMs ) ,
author : ( item . interactionVariant = = . standardOutgoing ?
userPublicKey :
item . authorId
)
)
// F o r i n c o m i n g i n t e r a c t i o n s o r i n t e r a c t i o n s w i t h n o s e r v e r H a s h j u s t d e l e t e t h e m l o c a l l y
guard item . interactionVariant = = . standardOutgoing , let serverHash : String = serverHash else {
GRDBStorage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : item . interactionId )
. deleteAll ( db )
// N o n e e d t o s e n d t h e u n s e n d R e q u e s t i f t h e r e i s n o s e r v e r H a s h ( i e . t h e m e s s a g e
// w a s o u t g o i n g b u t n e v e r g o t t o t h e s e r v e r )
guard serverHash != nil else { return }
MessageSender
. send (
db ,
message : unsendRequest ,
threadId : thread . id ,
interactionId : nil ,
to : . contact ( publicKey : userPublicKey )
)
}
return
}
let alertVC = UIAlertController . init ( title : nil , message : nil , preferredStyle : . actionSheet )
alertVC . addAction ( UIAlertAction ( title : " delete_message_for_me " . localized ( ) , style : . destructive ) { [ weak self ] _ in
GRDBStorage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : item . interactionId )
. deleteAll ( db )
MessageSender
. send (
db ,
message : unsendRequest ,
threadId : thread . id ,
interactionId : nil ,
to : . contact ( publicKey : userPublicKey )
)
}
self ? . showInputAccessoryView ( )
} )
alertVC . addAction ( UIAlertAction (
title : ( item . threadVariant = = . closedGroup ?
" delete_message_for_everyone " . localized ( ) :
String ( format : " delete_message_for_me_and_recipient " . localized ( ) , threadName )
) ,
style : . destructive
) { [ weak self ] _ in
deleteRemotely (
from : self ,
request : SnodeAPI
. deleteMessage (
publicKey : thread . id ,
serverHashes : [ serverHash ]
)
. map { _ in ( ) }
) { [ weak self ] in
GRDBStorage . shared . writeAsync { db in
try MessageSender
. send (
db ,
message : unsendRequest ,
interactionId : nil ,
in : thread
)
}
self ? . showInputAccessoryView ( )
}
} )
alertVC . addAction ( UIAlertAction . init ( title : " TXT_CANCEL_TITLE " . localized ( ) , style : . cancel ) { [ weak self ] _ in
self ? . showInputAccessoryView ( )
} )
self . inputAccessoryView ? . isHidden = true
self . inputAccessoryView ? . alpha = 0
self . presentAlert ( alertVC )
}
}
func save ( _ item : ConversationViewModel . Item ) {
guard item . cellType = = . mediaMessage else { return }
let mediaAttachments : [ ( Attachment , String ) ] = ( item . attachments ? ? [ ] )
. filter { attachment in
attachment . isValid &&
attachment . isVisualMedia && (
attachment . state = = . downloaded ||
attachment . state = = . uploaded
)
}
. compactMap { attachment in
guard let originalFilePath : String = attachment . originalFilePath else { return nil }
return ( attachment , originalFilePath )
}
guard ! mediaAttachments . isEmpty else { return }
mediaAttachments . forEach { attachment , originalFilePath in
PHPhotoLibrary . shared ( ) . performChanges (
{
if attachment . isImage || attachment . isAnimated {
PHAssetChangeRequest . creationRequestForAssetFromImage (
atFileURL : URL ( fileURLWithPath : originalFilePath )
)
}
else if attachment . isVideo {
PHAssetChangeRequest . creationRequestForAssetFromVideo (
atFileURL : URL ( fileURLWithPath : originalFilePath )
)
}
} ,
completionHandler : { _ , _ in }
)
}
// S e n d a ' m e d i a s a v e d ' n o t i f i c a t i o n i f n e e d e d
guard self . viewModel . viewData . thread . variant = = . contact , item . interactionVariant = = . standardIncoming else {
return
}
let thread : SessionThread = self . viewModel . viewData . thread
GRDBStorage . shared . writeAsync { db in
try MessageSender . send (
db ,
message : DataExtractionNotification (
kind : . mediaSaved ( timestamp : UInt64 ( item . timestampMs ) )
) ,
interactionId : nil ,
in : thread
)
}
}
func ban ( _ item : ConversationViewModel . Item ) {
guard item . threadVariant = = . openGroup else { return }
let threadId : String = self . viewModel . viewData . thread . id
let alert : UIAlertController = UIAlertController (
title : " Session " ,
message : " This will ban the selected user from this room. It won't ban them from other rooms. " ,
preferredStyle : . alert
)
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : { _ in
guard let openGroup : OpenGroup = GRDBStorage . shared . read ( { db in try OpenGroup . fetchOne ( db , id : threadId ) } ) else {
return
}
OpenGroupAPIV2
. ban ( item . authorId , from : openGroup . room , on : openGroup . server )
. retainUntilComplete ( )
} ) )
alert . addAction ( UIAlertAction ( title : " Cancel " , style : . default , handler : nil ) )
present ( alert , animated : true , completion : nil )
}
func banAndDeleteAllMessages ( _ item : ConversationViewModel . Item ) {
guard item . threadVariant = = . openGroup else { return }
let threadId : String = self . viewModel . viewData . thread . id
let alert : UIAlertController = UIAlertController (
title : " Session " ,
message : " 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. " ,
preferredStyle : . alert
)
alert . addAction ( UIAlertAction ( title : " OK " , style : . default , handler : { _ in
guard let openGroup : OpenGroup = GRDBStorage . shared . read ( { db in try OpenGroup . fetchOne ( db , id : threadId ) } ) else {
return
}
OpenGroupAPIV2
. banAndDeleteAllMessages ( item . authorId , from : openGroup . room , on : openGroup . server )
. retainUntilComplete ( )
} ) )
alert . addAction ( UIAlertAction ( title : " Cancel " , style : . default , handler : nil ) )
present ( alert , animated : true , completion : nil )
}
// MARK: - V o i c e M e s s a g e R e c o r d i n g V i e w D e l e g a t e
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
self . viewModel . stopAudio ( )
// C r e a t e U R L
let directory : String = OWSTemporaryDirectory ( )
let fileName : String = " \( Int64 ( floor ( Date ( ) . timeIntervalSince1970 * 1000 ) ) ) .m4a "
let url : URL = URL ( fileURLWithPath : directory ) . appendingPathComponent ( fileName )
// 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 audioRecorder : AVAudioRecorder
do {
audioRecorder = try AVAudioRecorder (
url : url ,
settings : [
AVFormatIDKey : NSNumber ( value : kAudioFormatMPEG4AAC ) ,
AVSampleRateKey : NSNumber ( value : 44100 ) ,
AVNumberOfChannelsKey : NSNumber ( value : 2 ) ,
AVEncoderBitRateKey : NSNumber ( value : 128 * 1024 )
]
)
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
OWSAlerts . showAlert (
title : " VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE " . localized ( ) ,
message : " VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE " . localized ( )
)
return
}
// 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 = ( " VOICE_MESSAGE_FILE_NAME " . localized ( ) 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: - P e r m i s s i o n s
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 : ( ( ) -> ( ) ) ? ) {
OWSAlerts . showAlert (
title : " ATTACHMENT_ERROR_ALERT_TITLE " . localized ( ) ,
message : ( attachment . localizedErrorDescription ? ? SignalAttachment . missingDataErrorMessage ) ,
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 : SessionThread ? ,
isNewThread : Bool ,
timestampMs : Int64
) -> Promise < Void > {
guard let thread : SessionThread = thread , thread . variant = = . contact 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 )
guard
let contact : Contact = GRDBStorage . shared . read ( { db in Contact . fetchOrCreate ( db , id : thread . id ) } ) ,
! 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 : MessageSenderError . 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 ,
sentTimestampMs : UInt64 ( timestampMs )
)
// 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
GRDBStorage . shared . write { db in
try MessageSender . sendNonDurably (
db ,
message : messageRequestResponse ,
interactionId : nil ,
in : thread
)
}
}
. 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
GRDBStorage . shared . writeAsync (
updates : { db in
try contact
. with (
isApproved : true ,
didApproveMe : . update ( contact . didApproveMe || ! isNewThread )
)
. save ( db )
// 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
try MessageSender . syncConfiguration ( db , forceSyncNow : true ) . retainUntilComplete ( )
} ,
completion : { db , _ in
// 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
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 ? . tableView . contentInset ? ? UIEdgeInsets . zero )
self ? . tableView . contentInset = UIEdgeInsets (
top : 0 ,
leading : 0 ,
bottom : max ( oldContentInset . bottom - messageRequestsOffset , 0 ) ,
trailing : 0
)
}
}
// R e m o v e t h e ' M e s s a g e R e q u e s t s V i e w C o n t r o l l e r ' f r o m t h e n a v h i e r a r c h y i f p r e s e n t
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 )
}
}
}
)
}
}
@objc func acceptMessageRequest ( ) {
self . approveMessageRequestIfNeeded (
for : self . viewModel . viewData . thread ,
isNewThread : false ,
timestampMs : Int64 ( floor ( Date ( ) . timeIntervalSince1970 * 1000 ) )
)
. catch ( on : DispatchQueue . main ) { [ weak self ] _ in
// 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
let alert = UIAlertController (
title : " Session " ,
message : " MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE " . localized ( ) ,
preferredStyle : . alert
)
alert . addAction ( UIAlertAction ( title : " BUTTON_OK " . localized ( ) , style : . default , handler : nil ) )
self ? . present ( alert , animated : true , completion : nil )
}
. retainUntilComplete ( )
}
@objc func deleteMessageRequest ( ) {
guard self . viewModel . viewData . thread . variant = = . contact else { return }
let threadId : String = self . viewModel . viewData . thread . id
let alertVC : UIAlertController = UIAlertController (
title : " MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON " . localized ( ) ,
message : nil ,
preferredStyle : . actionSheet
)
alertVC . addAction ( UIAlertAction ( title : " TXT_DELETE_TITLE " . localized ( ) , style : . destructive ) { _ in
// D e l e t e t h e r e q u e s t
GRDBStorage . shared . writeAsync (
updates : { [ weak self ] db in
// U p d a t e t h e c o n t a c t
try ? Contact
. fetchOrCreate ( db , id : threadId )
. with (
isApproved : false ,
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
didApproveMe : true
)
. saved ( db )
_ = try SessionThread
. filter ( id : threadId )
. deleteAll ( db )
try MessageSender . syncConfiguration ( db , forceSyncNow : true ) . retainUntilComplete ( )
} ,
completion : { db , _ in
DispatchQueue . main . async { [ weak self ] in
self ? . navigationController ? . popViewController ( animated : true )
}
}
)
} )
alertVC . addAction ( UIAlertAction ( title : " TXT_CANCEL_TITLE " . localized ( ) , style : . cancel , handler : nil ) )
self . present ( alertVC , animated : true , completion : nil )
}
}