// 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 Sodium
import PromiseKit
import GRDB
import SessionMessagingKit
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 . threadData . threadRequiresApproval = = false else { return }
openSettings ( )
}
@objc func openSettings ( ) {
let settingsVC : OWSConversationSettingsViewController = OWSConversationSettingsViewController ( )
settingsVC . configure (
withThreadId : viewModel . threadData . threadId ,
threadName : viewModel . threadData . displayName ,
isClosedGroup : ( viewModel . threadData . threadVariant = = . closedGroup ) ,
isOpenGroup : ( viewModel . threadData . threadVariant = = . openGroup ) ,
isNoteToSelf : viewModel . threadData . 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: - C a l l
@objc func startCall ( _ sender : Any ? ) {
guard SessionCall . isEnabled else { return }
guard Storage . shared [ . areCallsEnabled ] else {
let callPermissionRequestModal = CallPermissionRequestModal ( )
self . navigationController ? . present ( callPermissionRequestModal , animated : true , completion : nil )
return
}
requestMicrophonePermissionIfNeeded { }
let threadId : String = self . viewModel . threadData . threadId
guard AVAudioSession . sharedInstance ( ) . recordPermission = = . granted else { return }
guard self . viewModel . threadData . threadVariant = = . contact else { return }
guard AppEnvironment . shared . callManager . currentCall = = nil else { return }
guard let call : SessionCall = Storage . shared . read ( { db in SessionCall ( db , for : threadId , uuid : UUID ( ) . uuidString . lowercased ( ) , mode : . offer , outgoing : true ) } ) else {
return
}
let callVC = CallVC ( for : call )
callVC . conversationVC = self
hideInputAccessoryView ( )
present ( callVC , animated : true , completion : nil )
}
// MARK: - B l o c k i n g
@objc func unblock ( ) {
self . showBlockedModalIfNeeded ( )
}
@ discardableResult func showBlockedModalIfNeeded ( ) -> Bool {
guard self . viewModel . threadData . threadIsBlocked = = true else { return false }
let blockedModal = BlockedModal ( publicKey : viewModel . threadData . threadId )
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 ] , forThreadId threadId : String , messageText : String ? ) {
sendAttachments ( attachments , with : messageText ? ? " " )
self . snInputView . text = " "
resetMentions ( )
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 ] , forThreadId threadId : String , messageText : String ? ) {
sendAttachments ( attachments , with : messageText ? ? " " ) { [ weak self ] in
self ? . dismiss ( animated : true , completion : nil )
}
scrollToBottom ( isAnimated : false )
self . snInputView . text = " "
resetMentions ( )
}
func attachmentApprovalDidCancel ( _ attachmentApproval : AttachmentApprovalViewController ) {
dismiss ( animated : true , completion : nil )
}
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didChangeMessageText newMessageText : String ? ) {
snInputView . text = newMessageText ? ? " "
}
func attachmentApproval ( _ attachmentApproval : AttachmentApprovalViewController , didRemoveAttachment attachment : SignalAttachment ) {
}
func attachmentApprovalDidTapAddMore ( _ attachmentApproval : AttachmentApprovalViewController ) {
}
// 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 ( ) {
let threadId : String = self . viewModel . threadData . threadId
requestLibraryPermissionIfNeeded { [ weak self ] in
DispatchQueue . main . async {
let sendMediaNavController = SendMediaNavigationController . showingMediaLibraryFirst (
threadId : threadId
)
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 ( threadId : self . viewModel . threadData . threadId )
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 (
threadId : self . viewModel . threadData . threadId ,
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 . threadData . 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 )
}
// C l e a r i n g t h i s o u t i m m e d i a t e l y ( e v e n t h o u g h i t a l r e a d y h a p p e n s i n ' m e s s a g e S e n t ' ) t o p r e v e n t
// " d o u b l e s e n d i n g " i f t h e u s e r r a p i d l y t a p s t h e s e n d b u t t o n
DispatchQueue . main . async { [ weak self ] in
self ? . snInputView . text = " "
self ? . snInputView . quoteDraftInfo = nil
self ? . resetMentions ( )
}
// 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 threadId : String = self . viewModel . threadData . threadId
let oldThreadShouldBeVisible : Bool = ( self . viewModel . threadData . threadShouldBeVisible = = true )
let sentTimestampMs : Int64 = Int64 ( floor ( ( Date ( ) . timeIntervalSince1970 * 1000 ) ) )
let linkPreviewDraft : LinkPreviewDraft ? = snInputView . linkPreviewInfo ? . draft
let quoteModel : QuotedReplyModel ? = snInputView . quoteDraftInfo ? . model
// I f t h i s w a s a m e s s a g e r e q u e s t t h e n a p p r o v e i t
approveMessageRequestIfNeeded (
for : threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
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
)
// S e n d t h e m e s s a g e
Storage . shared . writeAsync (
updates : { [ weak self ] db in
guard let thread : SessionThread = try SessionThread . fetchOne ( db , id : threadId ) else {
return
}
// L e t t h e v i e w M o d e l k n o w w e a r e a b o u t t o s e n d a m e s s a g e
self ? . viewModel . sentMessageBeforeUpdate = true
// 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 : threadId )
. 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 interaction : Interaction = try Interaction (
threadId : threadId ,
authorId : getUserHexEncodedPublicKey ( db ) ,
variant : . standardOutgoing ,
body : text ,
timestampMs : sentTimestampMs ,
hasMention : Interaction . isUserMentioned ( db , threadId : threadId , body : text ) ,
expiresInSeconds : try ? DisappearingMessagesConfiguration
. select ( . durationSeconds )
. filter ( id : threadId )
. filter ( DisappearingMessagesConfiguration . Columns . isEnabled = = true )
. asRequest ( of : TimeInterval . self )
. fetchOne ( db ) ,
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 : LinkPreviewDraft = linkPreviewDraft ,
( try ? interaction . linkPreview . isEmpty ( db ) ) = = true
{
try LinkPreview (
url : linkPreviewDraft . urlString ,
title : linkPreviewDraft . title ,
attachmentId : LinkPreview . saveAttachmentIfPossible (
db ,
imageData : linkPreviewDraft . jpegImageData ,
mimeType : OWSMimeTypeImageJpeg
)
) . insert ( db )
}
// 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 interactionId : Int64 = interaction . id , let quoteModel : QuotedReplyModel = quoteModel {
try Quote (
interactionId : interactionId ,
authorId : quoteModel . authorId ,
timestampMs : quoteModel . timestampMs ,
body : quoteModel . body ,
attachmentId : quoteModel . generateAttachmentThumbnailIfNeeded ( db )
) . insert ( db )
}
try MessageSender . send (
db ,
interaction : interaction ,
in : thread
)
} ,
completion : { [ weak self ] _ , _ in
self ? . handleMessageSent ( )
}
)
}
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 threadId : String = self . viewModel . threadData . threadId
let oldThreadShouldBeVisible : Bool = ( self . viewModel . threadData . threadShouldBeVisible = = true )
let sentTimestampMs : Int64 = Int64 ( floor ( ( Date ( ) . timeIntervalSince1970 * 1000 ) ) )
// I f t h i s w a s a m e s s a g e r e q u e s t t h e n a p p r o v e i t
approveMessageRequestIfNeeded (
for : threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
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
)
// S e n d t h e m e s s a g e
Storage . shared . writeAsync (
updates : { [ weak self ] db in
guard let thread : SessionThread = try SessionThread . fetchOne ( db , id : threadId ) else {
return
}
// L e t t h e v i e w M o d e l k n o w w e a r e a b o u t t o s e n d a m e s s a g e
self ? . viewModel . sentMessageBeforeUpdate = true
// 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 : threadId )
. 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 interaction : Interaction = try Interaction (
threadId : threadId ,
authorId : getUserHexEncodedPublicKey ( db ) ,
variant : . standardOutgoing ,
body : text ,
timestampMs : sentTimestampMs ,
hasMention : Interaction . isUserMentioned ( db , threadId : threadId , body : text ) ,
expiresInSeconds : try ? DisappearingMessagesConfiguration
. select ( . durationSeconds )
. filter ( id : threadId )
. filter ( DisappearingMessagesConfiguration . Columns . isEnabled = = true )
. asRequest ( of : TimeInterval . self )
. fetchOne ( db )
) . inserted ( db )
try MessageSender . send (
db ,
interaction : interaction ,
with : attachments ,
in : thread
)
} ,
completion : { [ weak self ] _ , _ in
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 ? ( )
}
}
)
}
func handleMessageSent ( ) {
DispatchQueue . main . async { [ weak self ] in
self ? . snInputView . text = " "
self ? . snInputView . quoteDraftInfo = nil
self ? . resetMentions ( )
}
if Storage . shared [ . playNotificationSoundInForeground ] {
let soundID = Preferences . Sound . systemSoundId ( for : . messageSent , quiet : true )
AudioServicesPlaySystemSound ( soundID )
}
let threadId : String = self . viewModel . threadData . threadId
Storage . shared . writeAsync { db in
TypingIndicators . didStopTyping ( db , threadId : threadId , direction : . outgoing )
_ = try SessionThread
. filter ( id : threadId )
. 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 threadId : String = self . viewModel . threadData . threadId
let threadVariant : SessionThread . Variant = self . viewModel . threadData . threadVariant
let threadIsMessageRequest : Bool = ( self . viewModel . threadData . threadIsMessageRequest = = true )
let needsToStartTypingIndicator : Bool = TypingIndicators . didStartTypingNeedsToStart (
threadId : threadId ,
threadVariant : threadVariant ,
threadIsMessageRequest : threadIsMessageRequest ,
direction : . outgoing ,
timestampMs : Int64 ( floor ( Date ( ) . timeIntervalSince1970 * 1000 ) )
)
if needsToStartTypingIndicator {
Storage . shared . writeAsync { db in
TypingIndicators . start ( db , threadId : threadId , direction : . outgoing )
}
}
}
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 (
threadId : self . viewModel . threadData . threadId ,
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 . threadData . threadVariant ) ) "
)
snInputView . text = newText
self . currentMentionStartIndex = nil
snInputView . hideMentionsUI ( )
mentions = mentions . filter { mentionInfo -> Bool in
newText . contains ( mentionInfo . profile . displayName ( for : self . viewModel . threadData . threadVariant ) )
}
}
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 hideInputAccessoryView ( ) {
self . inputAccessoryView ? . isHidden = true
self . inputAccessoryView ? . alpha = 0
}
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 ( _ cellViewModel : MessageViewModel ) {
// 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
// FIXME: N e e d t o u p d a t e t h i s w h e n a n a p p r o p r i a t e r e p l a c e m e n t i s a d d e d ( s e e h t t p s : / / t e n g . p u b / t e c h n i c a l / 2 0 2 1 / 1 1 / 9 / u i a p p l i c a t i o n - k e y - w i n d o w - r e p l a c e m e n t )
let keyWindow : UIWindow = UIApplication . shared . keyWindow ,
let sectionIndex : Int = self . viewModel . interactionData
. firstIndex ( where : { $0 . model = = . messages } ) ,
let index = self . viewModel . interactionData [ sectionIndex ]
. elements
. firstIndex ( of : cellViewModel ) ,
let cell = tableView . cellForRow ( at : IndexPath ( row : index , section : sectionIndex ) ) as ? VisibleMessageCell ,
let snapshot = cell . bubbleView . snapshotView ( afterScreenUpdates : false ) ,
contextMenuWindow = = nil ,
let actions : [ ContextMenuVC . Action ] = ContextMenuVC . actions (
for : cellViewModel ,
recentEmojis : ( self . viewModel . threadData . recentReactionEmoji ? ? [ ] ) . compactMap { EmojiWithSkinTones ( rawValue : $0 ) } ,
currentUserIsOpenGroupModerator : OpenGroupManager . isUserModeratorOrAdmin (
self . viewModel . threadData . currentUserPublicKey ,
for : self . viewModel . threadData . openGroupRoomToken ,
on : self . viewModel . threadData . openGroupServer
) ,
currentThreadIsMessageRequest : ( self . viewModel . threadData . threadIsMessageRequest = = true ) ,
delegate : self
)
else { return }
UIImpactFeedbackGenerator ( style : . heavy ) . impactOccurred ( )
self . contextMenuWindow = ContextMenuWindow ( )
self . contextMenuVC = ContextMenuVC (
snapshot : snapshot ,
frame : cell . convert ( cell . bubbleView . frame , to : keyWindow ) ,
cellViewModel : cellViewModel ,
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 ? . overrideUserInterfaceStyle = ( isDarkMode ? . dark : . light )
self . contextMenuWindow ? . makeKeyAndVisible ( )
}
func handleItemTapped ( _ cellViewModel : MessageViewModel , gestureRecognizer : UITapGestureRecognizer ) {
guard cellViewModel . variant != . standardOutgoing || cellViewModel . 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 : cellViewModel )
return
}
// F o r c a l l i n f o m e s s a g e s s h o w t h e " c a l l m i s s e d " m o d a l
guard cellViewModel . variant != . infoCall else {
let callMissedTipsModal : CallMissedTipsModal = CallMissedTipsModal ( caller : cellViewModel . authorName )
present ( callMissedTipsModal , animated : true , completion : nil )
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 cellViewModel . cellType != . textOnlyMessage && cellViewModel . variant = = . standardIncoming && ! cellViewModel . threadIsTrusted {
let modal = DownloadAttachmentModal ( profile : cellViewModel . profile )
modal . modalPresentationStyle = . overFullScreen
modal . modalTransitionStyle = . crossDissolve
present ( modal , animated : true , completion : nil )
return
}
switch cellViewModel . cellType {
case . audio : viewModel . playOrPauseAudio ( for : cellViewModel )
case . mediaMessage :
guard
let sectionIndex : Int = self . viewModel . interactionData
. firstIndex ( where : { $0 . model = = . messages } ) ,
let messageIndex : Int = self . viewModel . interactionData [ sectionIndex ]
. elements
. firstIndex ( where : { $0 . id = = cellViewModel . id } ) ,
let cell = tableView . cellForRow ( at : IndexPath ( row : messageIndex , section : sectionIndex ) ) as ? VisibleMessageCell ,
let albumView : MediaAlbumView = cell . albumView
else { return }
let locationInCell : CGPoint = 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 : CGPoint = cell . convert ( locationInCell , to : albumView )
guard let mediaView = albumView . mediaView ( forLocation : locationInAlbumView ) else { return }
switch mediaView . attachment . state {
case . pendingDownload , . downloading , . uploading , . invalid : break
// F a i l e d u p l o a d s s h o u l d b e h a n d l e d v i a t h e " r e s e n d " p r o c e s s i n s t e a d
case . failedUpload : break
case . failedDownload :
let threadId : String = self . viewModel . threadData . threadId
// R e t r y d o w n l o a d i n g t h e f a i l e d a t t a c h m e n t
Storage . shared . writeAsync { db in
JobRunner . add (
db ,
job : Job (
variant : . attachmentDownload ,
threadId : threadId ,
interactionId : cellViewModel . id ,
details : AttachmentDownloadJob . Details (
attachmentId : mediaView . attachment . id
)
)
)
}
break
default :
// I g n o r e i n v a l i d m e d i a
guard mediaView . attachment . isValid else { return }
let viewController : UIViewController ? = MediaGalleryViewModel . createDetailViewController (
for : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
interactionId : cellViewModel . id ,
selectedAttachmentId : mediaView . attachment . id ,
options : [ . sliderEnabled , . showAllMediaButton ]
)
if let viewController : UIViewController = viewController {
// / D e l a y b e c o m i n g t h e f i r s t r e s p o n d e r t o m a k e t h e r e t u r n t r a n s i t i o n a l i t t l e n i c e r ( a l l o w s
// / f o r t h e f o o t e r o n t h e d e t a i l v i e w t o s l i d e o u t r a t h e r t h a n i n s t a n t l y v a n i s h )
self . delayFirstResponder = true
// / D i s m i s s t h e i n p u t b e f o r e s t a r t i n g t h e p r e s e n t a t i o n t o m a k e e v e r y t h i n g l o o k s m o o t h e r
self . resignFirstResponder ( )
// / D e l a y t h e a c t u a l p r e s e n t a t i o n t o g i v e t h e ' r e s i g n F i r s t R e s p o n d e r ' c a l l t h e c h a n c e t o c o m p l e t e
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + . milliseconds ( 250 ) ) { [ weak self ] in
// / L o c k t h e c o n t e n t O f f s e t o f t h e t a b l e V i e w s o t h e t r a n s i t i o n d o e s n ' t l o o k b u g g y
self ? . tableView . lockContentOffset = true
self ? . present ( viewController , animated : true ) { [ weak self ] in
// U n l o c k t h e c o n t e n t O f f s e t s o e v e r y t h i n g w i l l b e i n t h e r i g h t
// p l a c e w h e n w e r e t u r n
self ? . tableView . lockContentOffset = false
}
}
}
}
case . genericAttachment :
guard
let attachment : Attachment = cellViewModel . 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 )
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 quote : Quote = cellViewModel . quote {
// S c r o l l t o t h e o r i g i n a l q u o t e d m e s s a g e
let maybeOriginalInteractionId : Int64 ? = Storage . shared . read { db in
try quote . originalInteraction
. select ( . id )
. asRequest ( of : Int64 . self )
. fetchOne ( db )
}
guard let interactionId : Int64 = maybeOriginalInteractionId else { return }
self . scrollToInteractionIfNeeded ( with : interactionId , highlight : true )
}
else if let linkPreview : LinkPreview = cellViewModel . linkPreview {
switch linkPreview . variant {
case . standard : openUrl ( linkPreview . url )
case . openGroupInvitation : joinOpenGroup ( name : linkPreview . title , url : linkPreview . url )
}
}
default : break
}
}
func handleItemDoubleTapped ( _ cellViewModel : MessageViewModel ) {
switch cellViewModel . 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 : cellViewModel )
default : break
}
}
func handleItemSwiped ( _ cellViewModel : MessageViewModel , state : SwipeState ) {
switch state {
case . began : tableView . isScrollEnabled = false
case . ended , . cancelled : tableView . isScrollEnabled = true
}
}
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 cellViewModel : MessageViewModel ) {
reply ( cellViewModel )
}
func showUserDetails ( for profile : Profile ) {
let userDetailsSheet = UserDetailsSheet ( for : profile )
userDetailsSheet . modalPresentationStyle = . overFullScreen
userDetailsSheet . modalTransitionStyle = . crossDissolve
present ( userDetailsSheet , animated : true , completion : nil )
}
func startThread ( with sessionId : String , openGroupServer : String ? , openGroupPublicKey : String ? ) {
guard SessionId . Prefix ( from : sessionId ) = = . blinded else {
Storage . shared . write { db in
try SessionThread . fetchOrCreate ( db , id : sessionId , variant : . contact )
}
let conversationVC : ConversationVC = ConversationVC ( threadId : sessionId , threadVariant : . contact )
self . navigationController ? . pushViewController ( conversationVC , animated : true )
return
}
// I f t h e s e s s i o n I d i s b l i n d e d t h e n c h e c k i f t h e r e i s a n e x i s t i n g u n - b l i n d e d t h r e a d w i t h t h e c o n t a c t
// a n d u s e t h a t , o t h e r w i s e j u s t u s e t h e b l i n d e d i d
guard let openGroupServer : String = openGroupServer , let openGroupPublicKey : String = openGroupPublicKey else {
return
}
let targetThreadId : String ? = Storage . shared . write { db in
let lookup : BlindedIdLookup = try BlindedIdLookup
. fetchOrCreate (
db ,
blindedId : sessionId ,
openGroupServer : openGroupServer ,
openGroupPublicKey : openGroupPublicKey ,
isCheckingForOutbox : false
)
return try SessionThread
. fetchOrCreate ( db , id : ( lookup . sessionId ? ? lookup . blindedId ) , variant : . contact )
. id
}
guard let threadId : String = targetThreadId else { return }
let conversationVC : ConversationVC = ConversationVC ( threadId : threadId , threadVariant : . contact )
self . navigationController ? . pushViewController ( conversationVC , animated : true )
}
func showReactionList ( _ cellViewModel : MessageViewModel , selectedReaction : EmojiWithSkinTones ? ) {
guard
cellViewModel . reactionInfo ? . isEmpty = = false &&
(
self . viewModel . threadData . threadVariant = = . closedGroup ||
self . viewModel . threadData . threadVariant = = . openGroup
) ,
let allMessages : [ MessageViewModel ] = self . viewModel . interactionData
. first ( where : { $0 . model = = . messages } ) ?
. elements
else { return }
let reactionListSheet : ReactionListSheet = ReactionListSheet ( for : cellViewModel . id ) { [ weak self ] in
self ? . currentReactionListSheet = nil
}
reactionListSheet . delegate = self
reactionListSheet . handleInteractionUpdates (
allMessages ,
selectedReaction : selectedReaction ,
initialLoad : true ,
shouldShowClearAllButton : OpenGroupManager . isUserModeratorOrAdmin (
self . viewModel . threadData . currentUserPublicKey ,
for : self . viewModel . threadData . openGroupRoomToken ,
on : self . viewModel . threadData . openGroupServer
)
)
reactionListSheet . modalPresentationStyle = . overFullScreen
present ( reactionListSheet , animated : true , completion : nil )
// S t o r e s o w e c a n u p d a t e d t h e c o n t e n t b a s e d o n t h e c u r r e n t V C
self . currentReactionListSheet = reactionListSheet
}
func needsLayout ( for cellViewModel : MessageViewModel , expandingReactions : Bool ) {
guard
let messageSectionIndex : Int = self . viewModel . interactionData
. firstIndex ( where : { $0 . model = = . messages } ) ,
let targetMessageIndex = self . viewModel . interactionData [ messageSectionIndex ]
. elements
. firstIndex ( where : { $0 . id = = cellViewModel . id } )
else { return }
if expandingReactions {
self . viewModel . expandReactions ( for : cellViewModel . id )
}
else {
self . viewModel . collapseReactions ( for : cellViewModel . id )
}
UIView . setAnimationsEnabled ( false )
tableView . reloadRows (
at : [ IndexPath ( row : targetMessageIndex , section : messageSectionIndex ) ] ,
with : . none
)
UIView . setAnimationsEnabled ( true )
}
func react ( _ cellViewModel : MessageViewModel , with emoji : EmojiWithSkinTones ) {
react ( cellViewModel , with : emoji . rawValue , remove : false )
}
func removeReact ( _ cellViewModel : MessageViewModel , for emoji : EmojiWithSkinTones ) {
react ( cellViewModel , with : emoji . rawValue , remove : true )
}
func removeAllReactions ( _ cellViewModel : MessageViewModel , for emoji : String ) {
guard cellViewModel . threadVariant = = . openGroup else { return }
Storage . shared
. read { db -> Promise < Void > in
guard
let openGroup : OpenGroup = try ? OpenGroup
. fetchOne ( db , id : cellViewModel . threadId ) ,
let openGroupServerMessageId : Int64 = try ? Interaction
. select ( . openGroupServerMessageId )
. filter ( id : cellViewModel . id )
. asRequest ( of : Int64 . self )
. fetchOne ( db )
else {
return Promise ( error : StorageError . objectNotFound )
}
return OpenGroupAPI
. reactionDeleteAll (
db ,
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server
)
. map { _ in ( ) }
}
. done { _ in
Storage . shared . writeAsync { db in
_ = try Reaction
. filter ( Reaction . Columns . interactionId = = cellViewModel . id )
. filter ( Reaction . Columns . emoji = = emoji )
. deleteAll ( db )
}
}
. retainUntilComplete ( )
}
func react ( _ cellViewModel : MessageViewModel , with emoji : String , remove : Bool ) {
guard cellViewModel . variant = = . standardIncoming || cellViewModel . variant = = . standardOutgoing else {
return
}
let threadIsMessageRequest : Bool = ( self . viewModel . threadData . threadIsMessageRequest = = true )
guard ! threadIsMessageRequest else { return }
// P e r f o r m l o c a l r a t e l i m i t i n g ( d o n ' t a l l o w m o r e t h a n 2 0 r e a c t i o n s w i t h i n 6 0 s e c o n d s )
let sentTimestamp : Int64 = Int64 ( floor ( Date ( ) . timeIntervalSince1970 * 1000 ) )
let recentReactionTimestamps : [ Int64 ] = General . cache . wrappedValue . recentReactionTimestamps
guard
recentReactionTimestamps . count < 20 ||
( sentTimestamp - ( recentReactionTimestamps . first ? ? sentTimestamp ) ) > ( 60 * 1000 )
else { return }
General . cache . mutate {
$0 . recentReactionTimestamps = Array ( $0 . recentReactionTimestamps
. suffix ( 19 ) )
. appending ( sentTimestamp )
}
// P e r f o r m t h e s e n d i n g l o g i c
Storage . shared . writeAsync (
updates : { [ weak self ] db in
guard let thread : SessionThread = try SessionThread . fetchOne ( db , id : cellViewModel . threadId ) else {
return
}
// 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 ) )
// U p d a t e t h e d a t a b a s e
if remove {
_ = try Reaction
. filter ( Reaction . Columns . interactionId = = cellViewModel . id )
. filter ( Reaction . Columns . authorId = = cellViewModel . currentUserPublicKey )
. filter ( Reaction . Columns . emoji = = emoji )
. deleteAll ( db )
}
else {
let sortId = Reaction . getSortId (
db ,
interactionId : cellViewModel . id ,
emoji : emoji
)
try Reaction (
interactionId : cellViewModel . id ,
serverHash : nil ,
timestampMs : sentTimestamp ,
authorId : cellViewModel . currentUserPublicKey ,
emoji : emoji ,
count : 1 ,
sortId : sortId
) . insert ( db )
// A d d i t t o t h e r e c e n t l i s t
Emoji . addRecent ( db , emoji : emoji )
}
if let openGroup : OpenGroup = try ? OpenGroup . fetchOne ( db , id : cellViewModel . threadId ) ,
OpenGroupManager . isOpenGroupSupport ( . reactions , on : openGroup . server )
{
// S e n d r e a c t i o n t o o p e n g r o u p s
guard
let openGroupServerMessageId : Int64 = try ? Interaction
. select ( . openGroupServerMessageId )
. filter ( id : cellViewModel . id )
. asRequest ( of : Int64 . self )
. fetchOne ( db )
else { return }
if remove {
let pendingChange = OpenGroupManager
. addPendingReaction (
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server ,
type : . remove
)
OpenGroupAPI
. reactionDelete (
db ,
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server
)
. map { _ , response in
OpenGroupManager
. updatePendingChange (
pendingChange ,
seqNo : response . seqNo
)
}
. retainUntilComplete ( )
} else {
let pendingChange = OpenGroupManager
. addPendingReaction (
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server ,
type : . react
)
OpenGroupAPI
. reactionAdd (
db ,
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server
)
. map { _ , response in
OpenGroupManager
. updatePendingChange (
pendingChange ,
seqNo : response . seqNo
)
}
. retainUntilComplete ( )
}
} else {
// S e n d t h e a c t u a l m e s s a g e
try MessageSender . send (
db ,
message : VisibleMessage (
sentTimestamp : UInt64 ( sentTimestamp ) ,
text : nil ,
reaction : VisibleMessage . VMReaction (
timestamp : UInt64 ( cellViewModel . timestampMs ) ,
publicKey : {
guard cellViewModel . variant = = . standardIncoming else {
return cellViewModel . currentUserPublicKey
}
return cellViewModel . authorId
} ( ) ,
emoji : emoji ,
kind : ( remove ? . remove : . react )
)
) ,
interactionId : cellViewModel . id ,
in : thread
)
}
}
)
}
func showFullEmojiKeyboard ( _ cellViewModel : MessageViewModel ) {
hideInputAccessoryView ( )
let emojiPicker = EmojiPickerSheet (
completionHandler : { [ weak self ] emoji in
guard let emoji : EmojiWithSkinTones = emoji else { return }
self ? . react ( cellViewModel , with : emoji )
} ,
dismissHandler : { [ weak self ] in
self ? . showInputAccessoryView ( )
}
)
emojiPicker . modalPresentationStyle = . overFullScreen
present ( emojiPicker , animated : true , completion : nil )
}
func contextMenuDismissed ( ) {
recoverInputView ( )
}
// MARK: - - a c t i o n h a n d l i n g
func showFailedMessageSheet ( for cellViewModel : MessageViewModel ) {
let sheet = UIAlertController ( title : cellViewModel . mostRecentFailureText , message : nil , preferredStyle : . actionSheet )
sheet . addAction ( UIAlertAction ( title : " Cancel " , style : . cancel , handler : nil ) )
sheet . addAction ( UIAlertAction ( title : " Delete " , style : . destructive , handler : { _ in
Storage . shared . writeAsync { db in
try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
}
} ) )
sheet . addAction ( UIAlertAction ( title : " Resend " , style : . default , handler : { _ in
Storage . shared . writeAsync { [ weak self ] db in
guard
let threadId : String = self ? . viewModel . threadData . threadId ,
let interaction : Interaction = try ? Interaction . fetchOne ( db , id : cellViewModel . id ) ,
let thread : SessionThread = try SessionThread . fetchOne ( db , id : threadId )
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 = cellViewModel . 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 ( _ cellViewModel : MessageViewModel ) {
let maybeQuoteDraft : QuotedReplyModel ? = QuotedReplyModel . quotedReplyForSending (
threadId : self . viewModel . threadData . threadId ,
authorId : cellViewModel . authorId ,
variant : cellViewModel . variant ,
body : cellViewModel . body ,
timestampMs : cellViewModel . timestampMs ,
attachments : cellViewModel . attachments ,
linkPreviewAttachment : cellViewModel . linkPreviewAttachment
)
guard let quoteDraft : QuotedReplyModel = maybeQuoteDraft else { return }
snInputView . quoteDraftInfo = (
model : quoteDraft ,
isOutgoing : ( cellViewModel . variant = = . standardOutgoing )
)
snInputView . becomeFirstResponder ( )
}
func copy ( _ cellViewModel : MessageViewModel ) {
switch cellViewModel . cellType {
case . typingIndicator : break
case . textOnlyMessage :
UIPasteboard . general . string = cellViewModel . body
case . audio , . genericAttachment , . mediaMessage :
guard
cellViewModel . attachments ? . count = = 1 ,
let attachment : Attachment = cellViewModel . 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 ( _ cellViewModel : MessageViewModel ) {
guard cellViewModel . variant = = . standardIncoming || cellViewModel . variant = = . standardIncomingDeleted else {
return
}
UIPasteboard . general . string = cellViewModel . authorId
}
func delete ( _ cellViewModel : MessageViewModel ) {
// 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 cellViewModel . variant = = . standardIncoming || cellViewModel . variant = = . standardOutgoing else {
return
}
let threadId : String = self . viewModel . threadData . threadId
let threadName : String = self . viewModel . threadData . displayName
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
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. 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 cellViewModel . 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 ? ) ? = Storage . shared . read { db -> ( Int64 ? , OpenGroup ? ) in
(
try Interaction
. select ( . openGroupServerMessageId )
. filter ( id : cellViewModel . id )
. asRequest ( of : Int64 . self )
. fetchOne ( db ) ,
try OpenGroup . fetchOne ( db , id : threadId )
)
}
guard
let openGroup : OpenGroup = result ? . openGroup ,
let openGroupServerMessageId : Int64 = result ? . openGroupServerMessageId , (
cellViewModel . variant != . standardIncoming ||
OpenGroupManager . isUserModeratorOrAdmin (
userPublicKey ,
for : openGroup . roomToken ,
on : openGroup . server
)
)
else {
// I f t h e m e s s a g e h a s n ' t b e e n s e n t y e t t h e n j u s t d e l e t e l o c a l l y
guard cellViewModel . state = = . sending || cellViewModel . state = = . failed else {
return
}
// R e t r i e v e a n y m e s s a g e s e n d j o b s f o r t h i s i n t e r a c t i o n
let jobs : [ Job ] = Storage . shared
. read { db in
try ? Job
. filter ( Job . Columns . variant = = Job . Variant . messageSend )
. filter ( Job . Columns . interactionId = = cellViewModel . id )
. fetchAll ( db )
}
. defaulting ( to : [ ] )
// I f t h e j o b i s c u r r e n t l y r u n n i n g t h e n w a i t u n t i l i t ' s d o n e b e f o r e t r i g g e r i n g
// t h e d e l e t i o n
let targetJob : Job ? = jobs . first ( where : { JobRunner . isCurrentlyRunning ( $0 ) } )
guard targetJob = = nil else {
JobRunner . afterCurrentlyRunningJob ( targetJob ) { [ weak self ] result in
switch result {
// I f i t s u c c e e d e d t h e n w e ' l l n e e d t o d e l e t e f r o m t h e s e r v e r s o r e - r u n
// t h i s f u n c t i o n ( i f w e s t i l l d o n ' t h a v e t h e s e r v e r i d f o r s o m e r e a s o n
// t h e n t h i s w o u l d r e s u l t i n a l o c a l - o n l y d e l e t i o n w h i c h s h o u l d b e f i n e
case . succeeded : self ? . delete ( cellViewModel )
// O t h e r w i s e w e j u s t n e e d t o c a n c e l t h e p e n d i n g j o b ( i n c a s e i t r e t r i e s )
// a n d d e l e t e t h e i n t e r a c t i o n
default :
JobRunner . removePendingJob ( targetJob )
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
}
}
}
return
}
// I f i t ' s n o t c u r r e n t l y r u n n i n g t h e n r e m o v e a n y p e n d i n g j o b s ( j u s t t o b e s a f e ) a n d
// d e l e t e t h e i n t e r a c t i o n l o c a l l y
jobs . forEach { JobRunner . removePendingJob ( $0 ) }
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
}
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 : Storage . shared . read { db in
OpenGroupAPI . messageDelete (
db ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server
)
. map { _ in ( ) }
}
) { [ weak self ] in
self ? . showInputAccessoryView ( )
}
case . contact , . closedGroup :
let serverHash : String ? = Storage . shared . read { db -> String ? in
try Interaction
. select ( . serverHash )
. filter ( id : cellViewModel . id )
. asRequest ( of : String . self )
. fetchOne ( db )
}
let unsendRequest : UnsendRequest = UnsendRequest (
timestamp : UInt64 ( cellViewModel . timestampMs ) ,
author : ( cellViewModel . variant = = . standardOutgoing ?
userPublicKey :
cellViewModel . 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 cellViewModel . variant = = . standardOutgoing , let serverHash : String = serverHash else {
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. 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 : threadId ,
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
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
MessageSender
. send (
db ,
message : unsendRequest ,
threadId : threadId ,
interactionId : nil ,
to : . contact ( publicKey : userPublicKey )
)
}
self ? . showInputAccessoryView ( )
} )
alertVC . addAction ( UIAlertAction (
title : ( cellViewModel . 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 : threadId ,
serverHashes : [ serverHash ]
)
. map { _ in ( ) }
) { [ weak self ] in
Storage . shared . writeAsync { db in
guard let thread : SessionThread = try SessionThread . fetchOne ( db , id : threadId ) else {
return
}
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 ( _ cellViewModel : MessageViewModel ) {
guard cellViewModel . cellType = = . mediaMessage else { return }
let mediaAttachments : [ ( Attachment , String ) ] = ( cellViewModel . 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 . threadData . threadVariant = = . contact , cellViewModel . variant = = . standardIncoming else {
return
}
let threadId : String = self . viewModel . threadData . threadId
Storage . shared . writeAsync { db in
guard let thread : SessionThread = try SessionThread . fetchOne ( db , id : threadId ) else { return }
try MessageSender . send (
db ,
message : DataExtractionNotification (
kind : . mediaSaved ( timestamp : UInt64 ( cellViewModel . timestampMs ) )
) ,
interactionId : nil ,
in : thread
)
}
}
func ban ( _ cellViewModel : MessageViewModel ) {
guard cellViewModel . threadVariant = = . openGroup else { return }
let threadId : String = self . viewModel . threadData . threadId
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 : { [ weak self ] _ in
Storage . shared
. read { db -> Promise < Void > in
guard let openGroup : OpenGroup = try OpenGroup . fetchOne ( db , id : threadId ) else {
return Promise ( error : StorageError . objectNotFound )
}
return OpenGroupAPI
. userBan (
db ,
sessionId : cellViewModel . authorId ,
from : [ openGroup . roomToken ] ,
on : openGroup . server
)
. map { _ in ( ) }
}
. catch ( on : DispatchQueue . main ) { _ in
OWSAlerts . showErrorAlert ( message : " context_menu_ban_user_error_alert_message " . localized ( ) )
}
. retainUntilComplete ( )
self ? . becomeFirstResponder ( )
} ) )
alert . addAction ( UIAlertAction ( title : " Cancel " , style : . default , handler : { [ weak self ] _ in
self ? . becomeFirstResponder ( )
} ) )
present ( alert , animated : true , completion : nil )
}
func banAndDeleteAllMessages ( _ cellViewModel : MessageViewModel ) {
guard cellViewModel . threadVariant = = . openGroup else { return }
let threadId : String = self . viewModel . threadData . threadId
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 : { [ weak self ] _ in
Storage . shared
. read { db -> Promise < Void > in
guard let openGroup : OpenGroup = try OpenGroup . fetchOne ( db , id : threadId ) else {
return Promise ( error : StorageError . objectNotFound )
}
return OpenGroupAPI
. userBanAndDeleteAllMessages (
db ,
sessionId : cellViewModel . authorId ,
in : openGroup . roomToken ,
on : openGroup . server
)
. map { _ in ( ) }
}
. catch ( on : DispatchQueue . main ) { _ in
OWSAlerts . showErrorAlert ( message : " context_menu_ban_user_error_alert_message " . localized ( ) )
}
. retainUntilComplete ( )
self ? . becomeFirstResponder ( )
} ) )
alert . addAction ( UIAlertAction ( title : " Cancel " , style : . default , handler : { [ weak self ] _ in
self ? . becomeFirstResponder ( )
} ) )
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 = ( Environment . shared ? . audioSession . startAudioActivity ( recordVoiceMessageActivity ) = = true )
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 ( )
Environment . shared ? . 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 threadId : String ,
threadVariant : SessionThread . Variant ,
isNewThread : Bool ,
timestampMs : Int64
) {
guard threadVariant = = . contact else { return }
// 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 approvalData : ( contact : Contact , thread : SessionThread ? ) = Storage . shared . read ( { db in
return (
Contact . fetchOrCreate ( db , id : threadId ) ,
try SessionThread . fetchOne ( db , id : threadId )
)
} ) ,
let thread : SessionThread = approvalData . thread ,
! approvalData . contact . isApproved
else {
return
}
Storage . shared . writeAsync (
updates : { db in
// 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 )
if ! isNewThread {
try MessageSender . send (
db ,
message : MessageRequestResponse (
isApproved : true ,
sentTimestampMs : UInt64 ( timestampMs )
) ,
interactionId : nil ,
in : thread
)
}
// 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
try approvalData . contact
. with (
isApproved : true ,
didApproveMe : . update ( approvalData . 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 : { _ , _ in
// 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
DispatchQueue . main . async { [ weak self ] in
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 ? . viewControllers = newViewControllers
}
}
}
)
}
@objc func acceptMessageRequest ( ) {
self . approveMessageRequestIfNeeded (
for : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
isNewThread : false ,
timestampMs : Int64 ( floor ( Date ( ) . timeIntervalSince1970 * 1000 ) )
)
}
@objc func deleteMessageRequest ( ) {
guard self . viewModel . threadData . threadVariant = = . contact else { return }
let threadId : String = self . viewModel . threadData . threadId
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
Storage . shared . writeAsync (
updates : { 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 )
}
}
// MARK: - M e d i a P r e s e n t a t i o n C o n t e x t P r o v i d e r
extension ConversationVC : MediaPresentationContextProvider {
func mediaPresentationContext ( mediaItem : Media , in coordinateSpace : UICoordinateSpace ) -> MediaPresentationContext ? {
guard case let . gallery ( galleryItem ) = mediaItem else { return nil }
// N o t e : A c c o r d i n g t o A p p l e ' s d o c s t h e ' i n d e x P a t h s F o r V i s i b l e R o w s ' m e t h o d r e t u r n s a n
// u n s o r t e d a r r a y w h i c h m e a n s w e c a n ' t u s e i t t o d e t e r m i n e t h e d e s i r e d ' v i s i b l e C e l l '
// w e a r e a f t e r , d u e t o t h i s w e w i l l n e e d t o i t e r a t e a l l o f t h e v i s i b l e c e l l s t o f i n d
// t h e o n e w e w a n t
let maybeMessageCell : VisibleMessageCell ? = tableView . visibleCells
. first { cell -> Bool in
( ( cell as ? VisibleMessageCell ) ?
. albumView ?
. itemViews
. contains ( where : { mediaView in
mediaView . attachment . id = = galleryItem . attachment . id
} ) )
. defaulting ( to : false )
}
. map { $0 as ? VisibleMessageCell }
let maybeTargetView : MediaView ? = maybeMessageCell ?
. albumView ?
. itemViews
. first ( where : { $0 . attachment . id = = galleryItem . attachment . id } )
guard
let messageCell : VisibleMessageCell = maybeMessageCell ,
let targetView : MediaView = maybeTargetView ,
let mediaSuperview : UIView = targetView . superview
else { return nil }
let cornerRadius : CGFloat
let cornerMask : CACornerMask
let presentationFrame : CGRect = coordinateSpace . convert ( targetView . frame , from : mediaSuperview )
let frameInBubble : CGRect = messageCell . bubbleView . convert ( targetView . frame , from : mediaSuperview )
if messageCell . bubbleView . bounds = = targetView . bounds {
cornerRadius = messageCell . bubbleView . layer . cornerRadius
cornerMask = messageCell . bubbleView . layer . maskedCorners
}
else {
// I f t h e f r a m e s d o n ' t m a t c h t h e n a s s u m e i t ' s e i t h e r m u l t i p l e i m a g e s o r t h e r e i s a c a p t i o n
// a n d d e t e r m i n e w h i c h c o r n e r s n e e d t o b e r o u n d e d
cornerRadius = messageCell . bubbleView . layer . cornerRadius
var newCornerMask = CACornerMask ( )
let cellMaskedCorners : CACornerMask = messageCell . bubbleView . layer . maskedCorners
if
cellMaskedCorners . contains ( . layerMinXMinYCorner ) &&
frameInBubble . minX < CGFloat . leastNonzeroMagnitude &&
frameInBubble . minY < CGFloat . leastNonzeroMagnitude
{
newCornerMask . insert ( . layerMinXMinYCorner )
}
if
cellMaskedCorners . contains ( . layerMaxXMinYCorner ) &&
abs ( frameInBubble . maxX - messageCell . bubbleView . bounds . width ) < CGFloat . leastNonzeroMagnitude &&
frameInBubble . minY < CGFloat . leastNonzeroMagnitude
{
newCornerMask . insert ( . layerMaxXMinYCorner )
}
if
cellMaskedCorners . contains ( . layerMinXMaxYCorner ) &&
frameInBubble . minX < CGFloat . leastNonzeroMagnitude &&
abs ( frameInBubble . maxY - messageCell . bubbleView . bounds . height ) < CGFloat . leastNonzeroMagnitude
{
newCornerMask . insert ( . layerMinXMaxYCorner )
}
if
cellMaskedCorners . contains ( . layerMaxXMaxYCorner ) &&
abs ( frameInBubble . maxX - messageCell . bubbleView . bounds . width ) < CGFloat . leastNonzeroMagnitude &&
abs ( frameInBubble . maxY - messageCell . bubbleView . bounds . height ) < CGFloat . leastNonzeroMagnitude
{
newCornerMask . insert ( . layerMaxXMaxYCorner )
}
cornerMask = newCornerMask
}
return MediaPresentationContext (
mediaView : targetView ,
presentationFrame : presentationFrame ,
cornerRadius : cornerRadius ,
cornerMask : cornerMask
)
}
func snapshotOverlayView ( in coordinateSpace : UICoordinateSpace ) -> ( UIView , CGRect ) ? {
return self . navigationController ? . navigationBar . generateSnapshot ( in : coordinateSpace )
}
}