// 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 AVKit
import AVFoundation
import Combine
import CoreServices
import Photos
import PhotosUI
import Sodium
import GRDB
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
import SwiftUI
import SessionSnodeKit
extension ConversationVC :
InputViewDelegate ,
MessageCellDelegate ,
ContextMenuActionDelegate ,
SendMediaNavDelegate ,
UIDocumentPickerDelegate ,
AttachmentApprovalViewControllerDelegate ,
GifPickerViewControllerDelegate
{
// MARK: - O p e n S e t t i n g s
@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 }
openSettingsFromTitleView ( )
}
@objc func openSettingsFromTitleView ( ) {
switch self . titleView . currentLabelType {
case . userCount :
if self . viewModel . threadData . threadVariant = = . group || self . viewModel . threadData . threadVariant = = . legacyGroup {
let viewController = EditClosedGroupVC (
threadId : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant
)
navigationController ? . pushViewController ( viewController , animated : true )
} else {
openSettings ( )
}
break
case . none , . notificationSettings :
openSettings ( )
break
case . disappearingMessageSetting :
let viewController = SessionTableViewController (
viewModel : ThreadDisappearingMessagesSettingsViewModel (
threadId : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
currentUserIsClosedGroupMember : self . viewModel . threadData . currentUserIsClosedGroupMember ,
currentUserIsClosedGroupAdmin : self . viewModel . threadData . currentUserIsClosedGroupAdmin ,
config : self . viewModel . threadData . disappearingMessagesConfiguration !
)
)
navigationController ? . pushViewController ( viewController , animated : true )
break
}
}
@objc func openSettings ( ) {
let viewController = SessionTableViewController ( viewModel : ThreadSettingsViewModel (
threadId : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
didTriggerSearch : { [ weak self ] in
DispatchQueue . main . async {
self ? . showSearchUI ( )
self ? . popAllConversationSettingsViews {
// N o t e : W i t h o u t t h i s d e l a y t h e s e a r c h b a r d o e s n ' t s h o w
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.5 ) {
self ? . searchController . uiSearchController . searchBar . becomeFirstResponder ( )
}
}
}
}
)
)
navigationController ? . pushViewController ( viewController , animated : true )
}
// MARK: - C a l l
@objc func startCall ( _ sender : Any ? ) {
guard SessionCall . isEnabled else { return }
guard viewModel . threadData . threadIsBlocked = = false else { return }
guard Storage . shared [ . areCallsEnabled ] else {
let confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " modal_call_permission_request_title " . localized ( ) ,
body : . text ( " modal_call_permission_request_explanation " . localized ( ) ) ,
confirmTitle : " vc_settings_title " . localized ( ) ,
confirmAccessibility : Accessibility ( identifier : " Settings " ) ,
dismissOnConfirm : false // C u s t o m d i s m i s s a l l o g i c
) { [ weak self ] _ in
self ? . dismiss ( animated : true ) {
let navController : UINavigationController = StyledNavigationController (
rootViewController : SessionTableViewController (
viewModel : PrivacySettingsViewModel (
shouldShowCloseButton : true
)
)
)
navController . modalPresentationStyle = . fullScreen
self ? . present ( navController , animated : true , completion : nil )
}
}
)
self . navigationController ? . present ( confirmationModal , animated : true , completion : nil )
return
}
Permissions . 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 ( )
resignFirstResponder ( )
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 . threadVariant = = . contact &&
self . viewModel . threadData . threadIsBlocked = = true
else { return false }
let message = String (
format : " modal_blocked_explanation " . localized ( ) ,
self . viewModel . threadData . displayName
)
let confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : String (
format : " modal_blocked_title " . localized ( ) ,
self . viewModel . threadData . displayName
) ,
body : . attributedText (
NSAttributedString ( string : message )
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( message as NSString ) . range ( of : self . viewModel . threadData . displayName )
)
) ,
confirmTitle : " modal_blocked_button_title " . localized ( ) ,
confirmAccessibility : Accessibility ( identifier : " Confirm block " ) ,
cancelAccessibility : Accessibility ( identifier : " Cancel block " ) ,
dismissOnConfirm : false // C u s t o m d i s m i s s a l l o g i c
) { [ weak self ] _ in
self ? . viewModel . unblockContact ( )
self ? . dismiss ( animated : true , completion : nil )
}
)
present ( confirmationModal , 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 ,
threadVariant : SessionThread . Variant ,
messageText : String ? ,
using dependencies : Dependencies
) {
sendMessage ( text : ( messageText ? ? " " ) , attachments : attachments , using : dependencies )
resetMentions ( )
dismiss ( animated : true ) { [ weak self ] in
if self ? . isFirstResponder = = false {
self ? . becomeFirstResponder ( )
}
else {
self ? . reloadInputViews ( )
}
}
}
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 ,
threadVariant : SessionThread . Variant ,
messageText : String ? ,
using dependencies : Dependencies
) {
sendMessage ( text : ( messageText ? ? " " ) , attachments : attachments , using : dependencies )
resetMentions ( )
dismiss ( animated : true ) { [ weak self ] in
if self ? . isFirstResponder = = false {
self ? . becomeFirstResponder ( )
}
else {
self ? . reloadInputViews ( )
}
}
}
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 ( ) {
guard Storage . shared [ . isGiphyEnabled ] else {
let modal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " GIPHY_PERMISSION_TITLE " . localized ( ) ,
body : . text ( " GIPHY_PERMISSION_MESSAGE " . localized ( ) ) ,
confirmTitle : " continue_2 " . localized ( )
) { [ weak self ] _ in
Storage . shared . writeAsync (
updates : { db in
db [ . isGiphyEnabled ] = true
} ,
completion : { _ , _ in
DispatchQueue . main . async {
self ? . handleGIFButtonTapped ( )
}
}
)
}
)
present ( modal , animated : true , completion : nil )
return
}
let gifVC = GifPickerViewController ( )
gifVC . delegate = self
let navController = StyledNavigationController ( 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
present ( documentPickerVC , animated : true , completion : nil )
}
func handleLibraryButtonTapped ( ) {
let threadId : String = self . viewModel . threadData . threadId
let threadVariant : SessionThread . Variant = self . viewModel . threadData . threadVariant
Permissions . requestLibraryPermissionIfNeeded { [ weak self ] in
DispatchQueue . main . async {
let sendMediaNavController = SendMediaNavigationController . showingMediaLibraryFirst (
threadId : threadId ,
threadVariant : threadVariant
)
sendMediaNavController . sendMediaNavDelegate = self
sendMediaNavController . modalPresentationStyle = . fullScreen
self ? . present ( sendMediaNavController , animated : true , completion : nil )
}
}
}
func handleCameraButtonTapped ( ) {
guard Permissions . requestCameraPermissionIfNeeded ( presentingViewController : self ) else { return }
Permissions . 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 ,
threadVariant : self . viewModel . threadData . threadVariant
)
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 documentPicker ( _ controller : UIDocumentPickerViewController , didPickDocumentsAt urls : [ URL ] ) {
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 modal : ConfirmationModal = ConfirmationModal (
targetView : self ? . view ,
info : ConfirmationModal . Info (
title : " Session " ,
body : . text ( " An error occurred. " ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self ? . present ( modal , animated : true )
}
return
}
let type = urlResourceValues . typeIdentifier ? ? ( kUTTypeData as String )
guard urlResourceValues . isDirectory != true else {
DispatchQueue . main . async { [ weak self ] in
let modal : ConfirmationModal = ConfirmationModal (
targetView : self ? . view ,
info : ConfirmationModal . Info (
title : " ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE " . localized ( ) ,
body : . text ( " ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self ? . present ( modal , animated : true )
}
return
}
let fileName = urlResourceValues . name ? ? NSLocalizedString ( " ATTACHMENT_DEFAULT_FILENAME " , comment : " " )
guard let dataSource = DataSourcePath . dataSource ( with : url , shouldDeleteOnDeallocation : false ) else {
DispatchQueue . main . async { [ weak self ] in
let modal : ConfirmationModal = ConfirmationModal (
targetView : self ? . view ,
info : ConfirmationModal . Info (
title : " ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE " . localized ( ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self ? . present ( modal , animated : true )
}
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 ,
threadVariant : self . viewModel . threadData . threadVariant ,
attachments : attachments ,
approvalDelegate : self
)
navController . modalPresentationStyle = . fullScreen
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
)
. attachmentPublisher
. sinkUntilComplete (
receiveValue : { [ weak self ] attachment in
guard ! modalActivityIndicator . wasCancelled else { return }
modalActivityIndicator . dismiss {
guard ! attachment . hasError else {
self ? . showErrorAlert ( for : attachment )
return
}
self ? . showAttachmentApprovalDialog ( for : [ attachment ] )
}
}
)
}
}
// 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 (
text : snInputView . text . trimmingCharacters ( in : . whitespacesAndNewlines ) ,
linkPreviewDraft : snInputView . linkPreviewInfo ? . draft ,
quoteModel : snInputView . quoteDraftInfo ? . model
)
}
func sendMessage (
text : String ,
attachments : [ SignalAttachment ] = [ ] ,
linkPreviewDraft : LinkPreviewDraft ? = nil ,
quoteModel : QuotedReplyModel ? = nil ,
hasPermissionToSendSeed : Bool = false ,
using dependencies : Dependencies = Dependencies ( )
) {
guard ! showBlockedModalIfNeeded ( ) else { return }
// H a n d l e a t t a c h m e n t e r r o r s i f a p p l i c a b l e
if let failedAttachment : SignalAttachment = attachments . first ( where : { $0 . hasError } ) {
return showErrorAlert ( for : failedAttachment )
}
let processedText : String = replaceMentions ( in : text . trimmingCharacters ( in : . whitespacesAndNewlines ) )
// I f w e h a v e n o c o n t e n t t h e n d o n o t h i n g
guard ! processedText . isEmpty || ! attachments . isEmpty else { return }
if processedText . 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 : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " modal_send_seed_title " . localized ( ) ,
body : . text ( " modal_send_seed_explanation " . localized ( ) ) ,
confirmTitle : " modal_send_seed_send_button_title " . localized ( ) ,
confirmStyle : . danger ,
cancelStyle : . alert_text ,
onConfirm : { [ weak self ] _ in
self ? . sendMessage (
text : text ,
attachments : attachments ,
linkPreviewDraft : linkPreviewDraft ,
quoteModel : quoteModel ,
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 t o m a k e t h i s a p p e a r m o r e s n a p p y
DispatchQueue . main . async { [ weak self ] in
self ? . snInputView . text = " "
self ? . snInputView . quoteDraftInfo = nil
self ? . resetMentions ( )
self ? . scrollToBottom ( isAnimated : false )
}
// N o t e : ' s h o u l d B e V i s i b l e ' i s s e t t o t r u e t h e f i r s t t i m e a t h r e a d i s s a v e d s o w e c a n
// u s e i t t o d e t e r m i n e i f t h e u s e r i s c r e a t i n g a n e w t h r e a d a n d u p d a t e t h e ' i s A p p r o v e d '
// f l a g s a p p r o p r i a t e l y
let oldThreadShouldBeVisible : Bool = ( self . viewModel . threadData . threadShouldBeVisible = = true )
let sentTimestampMs : Int64 = SnodeAPI . currentOffsetTimestampMs ( )
// 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 : self . viewModel . threadData . 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
)
// O p t i m i s t i c a l l y i n s e r t t h e o u t g o i n g m e s s a g e ( t h i s w i l l t r i g g e r a U I u p d a t e )
self . viewModel . sentMessageBeforeUpdate = true
let optimisticData : ConversationViewModel . OptimisticMessageData = self . viewModel . optimisticallyAppendOutgoingMessage (
text : processedText ,
sentTimestampMs : sentTimestampMs ,
attachments : attachments ,
linkPreviewDraft : linkPreviewDraft ,
quoteModel : quoteModel
)
sendMessage ( optimisticData : optimisticData , using : dependencies )
}
private func sendMessage (
optimisticData : ConversationViewModel . OptimisticMessageData ,
using dependencies : Dependencies
) {
let threadId : String = self . viewModel . threadData . threadId
let threadVariant : SessionThread . Variant = self . viewModel . threadData . threadVariant
DispatchQueue . global ( qos : . userInitiated ) . async ( using : dependencies ) {
// G e n e r a t e t h e q u o t e t h u m b n a i l i f n e e d e d ( w a n t t h i s t o h a p p e n o u t s i d e o f t h e D B W r i t e t h r e a d a s
// t h i s c a n t a k e u p t o 0 . 5 s
let quoteThumbnailAttachment : Attachment ? = optimisticData . quoteModel ? . attachment ? . cloneAsQuoteThumbnail ( )
// A c t u a l l y s e n d t h e m e s s a g e
dependencies . storage
. writePublisher { [ weak self ] 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 ( i f i t i s n ' t a l r e a d y )
if self ? . viewModel . threadData . threadShouldBeVisible = = false {
_ = try SessionThread
. filter ( id : threadId )
. updateAllAndConfig ( db , SessionThread . Columns . shouldBeVisible . set ( to : true ) )
}
// I n s e r t 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 i t w i t h t h e o p t i m i s t i c a l l y i n s e r t e d m e s s a g e s o
// w e c a n r e m o v e i t o n c e t h e d a t a b a s e t r i g g e r s a U I u p d a t e
let insertedInteraction : Interaction = try optimisticData . interaction . inserted ( db )
self ? . viewModel . associate ( optimisticMessageId : optimisticData . id , to : insertedInteraction . id )
// I f t h e r e i s a L i n k P r e v i e w d r a f t t h e n c h e c k t h e s t a t e o f a n y e x i s t i n g l i n k p r e v i e w s a n d
// i n s e r t a n e w o n e i f n e e d e d
if let linkPreviewDraft : LinkPreviewDraft = optimisticData . linkPreviewDraft {
let invalidLinkPreviewAttachmentStates : [ Attachment . State ] = [
. failedDownload , . pendingDownload , . downloading , . failedUpload , . invalid
]
let linkPreviewAttachmentId : String ? = try ? insertedInteraction . linkPreview
. select ( . attachmentId )
. asRequest ( of : String . self )
. fetchOne ( db )
let linkPreviewAttachmentState : Attachment . State = linkPreviewAttachmentId
. map {
try ? Attachment
. filter ( id : $0 )
. select ( . state )
. asRequest ( of : Attachment . State . self )
. fetchOne ( db )
}
. defaulting ( to : . invalid )
// I f w e d o n ' t h a v e a " v a l i d " e x i s t i n g l i n k p r e v i e w t h e n u p s e r t a n e w o n e
if invalidLinkPreviewAttachmentStates . contains ( linkPreviewAttachmentState ) {
try LinkPreview (
url : linkPreviewDraft . urlString ,
title : linkPreviewDraft . title ,
attachmentId : try optimisticData . linkPreviewAttachment ? . inserted ( db ) . id
) . save ( 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 = insertedInteraction . id , let quoteModel : QuotedReplyModel = optimisticData . quoteModel {
try Quote (
interactionId : interactionId ,
authorId : quoteModel . authorId ,
timestampMs : quoteModel . timestampMs ,
body : quoteModel . body ,
attachmentId : try quoteThumbnailAttachment ? . inserted ( db ) . id
) . insert ( db )
}
// P r o c e s s a n y a t t a c h m e n t s
try Attachment . process (
db ,
data : optimisticData . attachmentData ,
for : insertedInteraction . id
)
try MessageSender . send (
db ,
interaction : insertedInteraction ,
threadId : threadId ,
threadVariant : threadVariant ,
using : dependencies
)
}
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. sinkUntilComplete (
receiveCompletion : { [ weak self ] result in
switch result {
case . finished : break
case . failure ( let error ) :
self ? . viewModel . failedToStoreOptimisticOutgoingMessage ( id : optimisticData . id , error : error )
}
self ? . handleMessageSent ( )
}
)
}
}
func handleMessageSent ( ) {
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 linkPreviewModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " modal_link_previews_title " . localized ( ) ,
body : . text ( " modal_link_previews_explanation " . localized ( ) ) ,
confirmTitle : " modal_link_previews_button_title " . localized ( )
) { [ weak self ] _ in
Storage . shared . writeAsync { db in
db [ . areLinkPreviewsEnabled ] = true
}
self ? . snInputView . autoGenerateLinkPreview ( )
}
)
present ( linkPreviewModal , animated : true , completion : nil )
}
func inputTextViewDidChangeContent ( _ inputTextView : InputTextView ) {
// N o t e : I f t h e r e i s a ' d r a f t ' m e s s a g e t h e n w e d o n ' t w a n t i t t o t r i g g e r t h e t y p i n g i n d i c a t o r t o
// a p p e a r ( a s t h a t i s n o t e x p e c t e d / c o r r e c t b e h a v i o u r )
guard ! viewIsAppearing else { return }
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 threadIsBlocked : Bool = ( self . viewModel . threadData . threadIsBlocked = = true )
let needsToStartTypingIndicator : Bool = TypingIndicators . didStartTypingNeedsToStart (
threadId : threadId ,
threadVariant : threadVariant ,
threadIsBlocked : threadIsBlocked ,
threadIsMessageRequest : threadIsMessageRequest ,
direction : . outgoing ,
timestampMs : SnodeAPI . currentOffsetTimestampMs ( )
)
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 ,
threadVariant : self . viewModel . threadData . threadVariant ,
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 : 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 ? MessageCell ,
let contextSnapshotView : UIView = cell . contextSnapshotView ,
let snapshot = contextSnapshotView . snapshotView ( afterScreenUpdates : false ) ,
contextMenuWindow = = nil ,
let actions : [ ContextMenuVC . Action ] = ContextMenuVC . actions (
for : cellViewModel ,
recentEmojis : ( self . viewModel . threadData . recentReactionEmoji ? ? [ ] ) . compactMap { EmojiWithSkinTones ( rawValue : $0 ) } ,
currentUserPublicKey : self . viewModel . threadData . currentUserPublicKey ,
currentUserBlinded15PublicKey : self . viewModel . threadData . currentUserBlinded15PublicKey ,
currentUserBlinded25PublicKey : self . viewModel . threadData . currentUserBlinded25PublicKey ,
currentUserIsOpenGroupModerator : OpenGroupManager . isUserModeratorOrAdmin (
self . viewModel . threadData . currentUserPublicKey ,
for : self . viewModel . threadData . openGroupRoomToken ,
on : self . viewModel . threadData . openGroupServer
) ,
currentThreadIsMessageRequest : ( self . viewModel . threadData . threadIsMessageRequest = = true ) ,
forMessageInfoScreen : false ,
delegate : self
)
else { return }
// / 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
UIImpactFeedbackGenerator ( style : . heavy ) . impactOccurred ( )
self . contextMenuWindow = ContextMenuWindow ( )
self . contextMenuVC = ContextMenuVC (
snapshot : snapshot ,
frame : contextSnapshotView . convert ( contextSnapshotView . bounds , 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 ,
animations : { self ? . updateScrollToBottom ( ) } ,
completion : { _ in
guard let contentOffset : CGPoint = self ? . tableView . contentOffset else { return }
// 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
self ? . tableView . setContentOffset ( contentOffset , animated : false )
}
)
}
self . contextMenuWindow ? . themeBackgroundColor = . clear
self . contextMenuWindow ? . rootViewController = self . contextMenuVC
self . contextMenuWindow ? . overrideUserInterfaceStyle = ThemeManager . currentTheme . interfaceStyle
self . contextMenuWindow ? . makeKeyAndVisible ( )
}
func handleItemTapped (
_ cellViewModel : MessageViewModel ,
cell : UITableViewCell ,
cellLocation : CGPoint ,
using dependencies : Dependencies = Dependencies ( )
) {
guard cellViewModel . variant != . standardOutgoing || ( cellViewModel . state != . failed && cellViewModel . state != . failedToSync ) 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 , using : dependencies )
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
}
// F o r d i s a p p e a r i n g m e s s a g e s c o n f i g u p d a t e , s h o w t h e f o l l o w i n g s e t t i n g s m o d a l
guard cellViewModel . variant != . infoDisappearingMessagesUpdate else {
let messageDisappearingConfig = cellViewModel . messageDisappearingConfiguration ( )
let expirationTimerString : String = floor ( messageDisappearingConfig . durationSeconds ) . formatted ( format : . long )
let expirationTypeString : String = ( messageDisappearingConfig . type = = . disappearAfterRead ? " DISAPPEARING_MESSAGE_STATE_READ " . localized ( ) : " DISAPPEARING_MESSAGE_STATE_SENT " . localized ( ) )
let modalBodyString : String = (
messageDisappearingConfig . isEnabled ?
String (
format : " FOLLOW_SETTING_EXPLAINATION_TURNING_ON " . localized ( ) ,
expirationTimerString ,
expirationTypeString
) :
" FOLLOW_SETTING_EXPLAINATION_TURNING_OFF " . localized ( )
)
let modalConfirmTitle : String = messageDisappearingConfig . isEnabled ? " DISAPPERING_MESSAGES_SAVE_TITLE " . localized ( ) : " CONFIRM_BUTTON_TITLE " . localized ( )
let confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " FOLLOW_SETTING_TITLE " . localized ( ) ,
body : . attributedText (
NSAttributedString ( string : modalBodyString )
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( modalBodyString as NSString ) . range ( of : expirationTypeString )
)
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( modalBodyString as NSString ) . range ( of : expirationTimerString )
)
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( modalBodyString as NSString ) . range ( of : " DISAPPEARING_MESSAGES_OFF " . localized ( ) . lowercased ( ) )
)
) ,
accessibility : Accessibility ( identifier : " Follow setting dialog " ) ,
confirmTitle : modalConfirmTitle ,
confirmAccessibility : Accessibility ( identifier : " Set button " ) ,
confirmStyle : . danger ,
cancelStyle : . textPrimary ,
dismissOnConfirm : false // C u s t o m d i s m i s s a l l o g i c
) { [ weak self ] _ in
dependencies . storage . writeAsync { db in
try messageDisappearingConfig . save ( db )
try LibSession
. update (
db ,
sessionId : cellViewModel . threadId ,
disappearingMessagesConfig : messageDisappearingConfig
)
}
self ? . dismiss ( animated : true , completion : nil )
}
)
present ( confirmationModal , 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 message : String = String (
format : " modal_download_attachment_explanation " . localized ( ) ,
cellViewModel . authorName
)
let confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : String (
format : " modal_download_attachment_title " . localized ( ) ,
cellViewModel . authorName
) ,
body : . attributedText (
NSAttributedString ( string : message )
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( message as NSString ) . range ( of : cellViewModel . authorName )
)
) ,
confirmTitle : " modal_download_button_title " . localized ( ) ,
confirmAccessibility : Accessibility ( identifier : " Download media " ) ,
cancelAccessibility : Accessibility ( identifier : " Don't download media " ) ,
dismissOnConfirm : false // C u s t o m d i s m i s s a l l o g i c
) { [ weak self ] _ in
self ? . viewModel . trustContact ( )
self ? . dismiss ( animated : true , completion : nil )
}
)
present ( confirmationModal , animated : true , completion : nil )
return
}
// / T a k e s t h e ` c e l l ` a n d a ` t a r g e t V i e w ` a n d r e t u r n s ` t r u e ` i f t h e u s e r t a p p e d a l i n k i n t h e c e l l b o d y t e x t i n s t e a d
// / o f t h e ` t a r g e t V i e w `
func handleLinkTapIfNeeded ( cell : UITableViewCell , targetView : UIView ? ) -> Bool {
let locationInTargetView : CGPoint = cell . convert ( cellLocation , to : targetView )
guard
let visibleCell : VisibleMessageCell = cell as ? VisibleMessageCell ,
targetView ? . bounds . contains ( locationInTargetView ) != true ,
visibleCell . bodyTappableLabel ? . containsLinks = = true
else { return false }
let tappableLabelPoint : CGPoint = cell . convert ( cellLocation , to : visibleCell . bodyTappableLabel )
visibleCell . bodyTappableLabel ? . handleTouch ( at : tappableLabelPoint )
return true
}
switch cellViewModel . cellType {
case . voiceMessage : viewModel . playOrPauseAudio ( for : cellViewModel )
case . mediaMessage :
guard
let albumView : MediaAlbumView = ( cell as ? VisibleMessageCell ) ? . albumView ,
! handleLinkTapIfNeeded ( cell : cell , targetView : albumView )
else { return }
// 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 ( cellLocation , 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
dependencies . storage . writeAsync { db in
dependencies . jobRunner . add (
db ,
job : Job (
variant : . attachmentDownload ,
threadId : threadId ,
interactionId : cellViewModel . id ,
details : AttachmentDownloadJob . Details (
attachmentId : mediaView . attachment . id
)
) ,
canStartJob : true ,
using : dependencies
)
}
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 }
guard albumView . numItems > 1 || ! mediaView . attachment . isVideo else {
guard
let originalFilePath : String = mediaView . attachment . originalFilePath ,
FileManager . default . fileExists ( atPath : originalFilePath )
else { return SNLog ( " Missing video file " ) }
// / W h e n p l a y i n g m e d i a w e n e e d t o c h a n g e t h e A V A u d i o S e s s i o n t o ' p l a y b a c k ' m o d e s o t h e d e v i c e " s i l e n t m o d e "
// / d o e s n ' t p r e v e n t v i d e o a u d i o f r o m p l a y i n g
try ? AVAudioSession . sharedInstance ( ) . setCategory ( . playback )
let viewController : AVPlayerViewController = AVPlayerViewController ( )
viewController . player = AVPlayer ( url : URL ( fileURLWithPath : originalFilePath ) )
self . navigationController ? . present ( viewController , animated : true )
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 . audio :
guard
! handleLinkTapIfNeeded ( cell : cell , targetView : ( cell as ? VisibleMessageCell ) ? . documentView ) ,
let attachment : Attachment = cellViewModel . attachments ? . first ,
let originalFilePath : String = attachment . originalFilePath
else { return }
// / W h e n p l a y i n g m e d i a w e n e e d t o c h a n g e t h e A V A u d i o S e s s i o n t o ' p l a y b a c k ' m o d e s o t h e d e v i c e " s i l e n t m o d e "
// / d o e s n ' t p r e v e n t v i d e o a u d i o f r o m p l a y i n g
try ? AVAudioSession . sharedInstance ( ) . setCategory ( . playback )
let viewController : AVPlayerViewController = AVPlayerViewController ( )
viewController . player = AVPlayer ( url : URL ( fileURLWithPath : originalFilePath ) )
self . navigationController ? . present ( viewController , animated : true )
case . genericAttachment :
guard
! handleLinkTapIfNeeded ( cell : cell , targetView : ( cell as ? VisibleMessageCell ) ? . documentView ) ,
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 :
guard let visibleCell : VisibleMessageCell = cell as ? VisibleMessageCell else { return }
let quotePoint : CGPoint = visibleCell . convert ( cellLocation , to : visibleCell . quoteView )
let linkPreviewPoint : CGPoint = visibleCell . convert ( cellLocation , to : visibleCell . linkPreviewView ? . previewView )
let tappableLabelPoint : CGPoint = visibleCell . convert ( cellLocation , to : visibleCell . bodyTappableLabel )
let containsLinks : Bool = (
// I f t h e r e i s o n l y a s i n g l e l i n k a n d i t m a t c h e s t h e L i n k P r e v i e w t h e n c o n s i d e r t h i s _ j u s t _ a
// L i n k P r e v i e w
visibleCell . bodyTappableLabel ? . containsLinks = = true && (
( visibleCell . bodyTappableLabel ? . links . count ? ? 0 ) > 1 ||
visibleCell . bodyTappableLabel ? . links [ cellViewModel . linkPreview ? . url ? ? " " ] = = nil
)
)
let quoteViewContainsTouch : Bool = ( visibleCell . quoteView ? . bounds . contains ( quotePoint ) = = true )
let linkPreviewViewContainsTouch : Bool = ( visibleCell . linkPreviewView ? . previewView . bounds . contains ( linkPreviewPoint ) = = true )
switch ( containsLinks , quoteViewContainsTouch , linkPreviewViewContainsTouch , cellViewModel . quote , cellViewModel . linkPreview ) {
// I f t h e m e s s a g e c o n t a i n s b o t h l i n k s a n d a q u o t e , a n d t h e u s e r t a p p e d o n t h e q u o t e ; O R t h e
// m e s s a g e o n l y c o n t a i n e d a q u o t e , t h e n s c r o l l t o t h e q u o t e
case ( true , true , _ , . some ( let quote ) , _ ) , ( false , _ , _ , . some ( let quote ) , _ ) :
let maybeOriginalInteractionInfo : Interaction . TimestampInfo ? = Storage . shared . read { db in
try quote . originalInteraction
. select ( . id , . timestampMs )
. asRequest ( of : Interaction . TimestampInfo . self )
. fetchOne ( db )
}
guard let interactionInfo : Interaction . TimestampInfo = maybeOriginalInteractionInfo else {
return
}
self . scrollToInteractionIfNeeded (
with : interactionInfo ,
focusBehaviour : . highlight ,
originalIndexPath : self . tableView . indexPath ( for : cell )
)
// I f t h e m e s s a g e c o n t a i n s b o t h l i n k s a n d a L i n k P r e v i e w , a n d t h e u s e r t a p p e d o n
// t h e L i n k P r e v i e w ; O R t h e m e s s a g e o n l y c o n t a i n e d a L i n k P r e v i e w , t h e n o p e n t h e l i n k
case ( true , _ , true , _ , . some ( let linkPreview ) ) , ( false , _ , _ , _ , . some ( let linkPreview ) ) :
switch linkPreview . variant {
case . standard : openUrl ( linkPreview . url )
case . openGroupInvitation : joinOpenGroup ( name : linkPreview . title , url : linkPreview . url )
}
// I f t h e m e s s a g e c o n t a i n e d l i n k s t h e n i n t e r a c t w i t h t h e m d i r e c t l y
case ( true , _ , _ , _ , _ ) : visibleCell . bodyTappableLabel ? . handleTouch ( at : tappableLabelPoint )
default : break
}
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 . voiceMessage : 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 actionSheet : UIAlertController = UIAlertController (
title : " modal_open_url_title " . localized ( ) ,
message : String ( format : " modal_open_url_explanation " . localized ( ) , url . absoluteString ) ,
preferredStyle : . actionSheet
)
actionSheet . addAction ( UIAlertAction ( title : " modal_open_url_button_title " . localized ( ) , style : . default ) { [ weak self ] _ in
UIApplication . shared . open ( url , options : [ : ] , completionHandler : nil )
self ? . showInputAccessoryView ( )
} )
actionSheet . addAction ( UIAlertAction ( title : " modal_copy_url_button_title " . localized ( ) , style : . default ) { [ weak self ] _ in
UIPasteboard . general . string = url . absoluteString
self ? . showInputAccessoryView ( )
} )
actionSheet . addAction ( UIAlertAction ( title : " cancel " . localized ( ) , style : . cancel ) { [ weak self ] _ in
self ? . showInputAccessoryView ( )
} )
Modal . setupForIPadIfNeeded ( actionSheet , targetView : self . view )
self . present ( actionSheet , animated : true )
}
func handleReplyButtonTapped ( for cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
reply ( cellViewModel , using : dependencies )
}
func startThread ( with sessionId : String , openGroupServer : String ? , openGroupPublicKey : String ? ) {
guard viewModel . threadData . canWrite else { return }
// FIXME: A d d i n s u p p o r t f o r s t a r t i n g a t h r e a d w i t h a ' b l i n d e d 2 5 ' i d
guard SessionId . Prefix ( from : sessionId ) != . blinded25 else { return }
guard SessionId . Prefix ( from : sessionId ) = = . blinded15 else {
Storage . shared . write { db in
try SessionThread
. fetchOrCreate ( db , id : sessionId , variant : . contact , shouldBeVisible : nil )
}
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 ,
shouldBeVisible : nil
)
. 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 = = . legacyGroup ||
self . viewModel . threadData . threadVariant = = . group ||
self . viewModel . threadData . threadVariant = = . community
) ,
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 , using dependencies : Dependencies ) {
react ( cellViewModel , with : emoji . rawValue , remove : false , using : dependencies )
}
func removeReact ( _ cellViewModel : MessageViewModel , for emoji : EmojiWithSkinTones , using dependencies : Dependencies ) {
react ( cellViewModel , with : emoji . rawValue , remove : true , using : dependencies )
}
func removeAllReactions ( _ cellViewModel : MessageViewModel , for emoji : String , using dependencies : Dependencies ) {
guard cellViewModel . threadVariant = = . community else { return }
Storage . shared
. readPublisher { db -> ( Network . PreparedRequest < OpenGroupAPI . ReactionRemoveAllResponse > , OpenGroupAPI . PendingChange ) 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 { throw StorageError . objectNotFound }
let preparedRequest : Network . PreparedRequest < OpenGroupAPI . ReactionRemoveAllResponse > = try OpenGroupAPI
. preparedReactionDeleteAll (
db ,
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server ,
using : dependencies
)
let pendingChange : OpenGroupAPI . PendingChange = OpenGroupManager
. addPendingReaction (
emoji : emoji ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server ,
type : . removeAll
)
return ( preparedRequest , pendingChange )
}
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. flatMap { preparedRequest , pendingChange in
preparedRequest . send ( using : dependencies )
. handleEvents (
receiveOutput : { _ , response in
OpenGroupManager
. updatePendingChange (
pendingChange ,
seqNo : response . seqNo
)
}
)
. eraseToAnyPublisher ( )
}
. sinkUntilComplete (
receiveCompletion : { _ in
Storage . shared . writeAsync { db in
_ = try Reaction
. filter ( Reaction . Columns . interactionId = = cellViewModel . id )
. filter ( Reaction . Columns . emoji = = emoji )
. deleteAll ( db )
}
}
)
}
func react (
_ cellViewModel : MessageViewModel ,
with emoji : String ,
remove : Bool ,
using dependencies : Dependencies = Dependencies ( )
) {
guard
self . viewModel . threadData . threadIsMessageRequest != true && (
cellViewModel . variant = = . standardIncoming ||
cellViewModel . variant = = . standardOutgoing
)
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 threadVariant : SessionThread . Variant = self . viewModel . threadData . threadVariant
let openGroupRoom : String ? = self . viewModel . threadData . openGroupRoomToken
let sentTimestamp : Int64 = SnodeAPI . currentOffsetTimestampMs ( )
let recentReactionTimestamps : [ Int64 ] = dependencies . caches [ . general ] . recentReactionTimestamps
guard
recentReactionTimestamps . count < 20 ||
( sentTimestamp - ( recentReactionTimestamps . first ? ? sentTimestamp ) ) > ( 60 * 1000 )
else {
let toastController : ToastController = ToastController (
text : " EMOJI_REACTS_RATE_LIMIT_TOAST " . localized ( ) ,
background : . backgroundSecondary
)
toastController . presentToastView (
fromBottomOfView : self . view ,
inset : ( snInputView . bounds . height + Values . largeSpacing ) ,
duration : . milliseconds ( 2500 )
)
return
}
dependencies . caches . mutate ( cache : . general ) {
$0 . recentReactionTimestamps = Array ( $0 . recentReactionTimestamps
. suffix ( 19 ) )
. appending ( sentTimestamp )
}
typealias OpenGroupInfo = (
pendingReaction : Reaction ? ,
pendingChange : OpenGroupAPI . PendingChange ,
preparedRequest : Network . PreparedRequest < Int64 ? >
)
// / P e r f o r m t h e s e n d i n g l o g i c , w e g e n e r a t e t h e p e n d i n g r e a c t i o n f i r s t i n a d e f e r r e d f u t u r e c l o s u r e t o p r e v e n t t h e O p e n G r o u p
// / c a c h e f r o m b l o c k i n g e i t h e r t h e m a i n t h r e a d o r t h e d a t a b a s e w r i t e t h r e a d
Deferred {
Future < OpenGroupAPI . PendingChange ? , Error > { resolver in
guard
threadVariant = = . community ,
let serverMessageId : Int64 = cellViewModel . openGroupServerMessageId ,
let openGroupServer : String = cellViewModel . threadOpenGroupServer ,
let openGroupPublicKey : String = cellViewModel . threadOpenGroupPublicKey
else { return resolver ( Result . success ( nil ) ) }
// C r e a t e t h e p e n d i n g c h a n g e i f w e h a v e o p e n g r o u p i n f o
return resolver ( Result . success (
OpenGroupManager . addPendingReaction (
emoji : emoji ,
id : serverMessageId ,
in : openGroupServer ,
on : openGroupPublicKey ,
type : ( remove ? . remove : . add )
)
) )
}
}
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) , using : dependencies )
. flatMap { pendingChange -> AnyPublisher < ( MessageSender . PreparedSendData ? , OpenGroupInfo ? ) , Error > in
dependencies . storage . writePublisher { [ weak self ] db -> ( MessageSender . PreparedSendData ? , OpenGroupInfo ? ) 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 ( i f i t i s n ' t a l r e a d y )
if self ? . viewModel . threadData . threadShouldBeVisible = = false {
_ = try SessionThread
. filter ( id : cellViewModel . threadId )
. updateAllAndConfig ( db , SessionThread . Columns . shouldBeVisible . set ( to : true ) )
}
let pendingReaction : Reaction ? = {
guard ! remove else {
return try ? Reaction
. filter ( Reaction . Columns . interactionId = = cellViewModel . id )
. filter ( Reaction . Columns . authorId = = cellViewModel . currentUserPublicKey )
. filter ( Reaction . Columns . emoji = = emoji )
. fetchOne ( db )
}
let sortId : Int64 = Reaction . getSortId (
db ,
interactionId : cellViewModel . id ,
emoji : emoji
)
return Reaction (
interactionId : cellViewModel . id ,
serverHash : nil ,
timestampMs : sentTimestamp ,
authorId : cellViewModel . currentUserPublicKey ,
emoji : emoji ,
count : 1 ,
sortId : sortId
)
} ( )
// 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 {
try pendingReaction ? . 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 )
}
switch threadVariant {
case . community :
guard
let serverMessageId : Int64 = cellViewModel . openGroupServerMessageId ,
let openGroupServer : String = cellViewModel . threadOpenGroupServer ,
let openGroupRoom : String = openGroupRoom ,
let pendingChange : OpenGroupAPI . PendingChange = pendingChange ,
OpenGroupManager . doesOpenGroupSupport ( db , capability : . reactions , on : openGroupServer )
else { throw MessageSenderError . invalidMessage }
let preparedRequest : Network . PreparedRequest < Int64 ? > = try {
guard ! remove else {
return try OpenGroupAPI
. preparedReactionDelete (
db ,
emoji : emoji ,
id : serverMessageId ,
in : openGroupRoom ,
on : openGroupServer ,
using : dependencies
)
. map { _ , response in response . seqNo }
}
return try OpenGroupAPI
. preparedReactionAdd (
db ,
emoji : emoji ,
id : serverMessageId ,
in : openGroupRoom ,
on : openGroupServer ,
using : dependencies
)
. map { _ , response in response . seqNo }
} ( )
return ( nil , ( pendingReaction , pendingChange , preparedRequest ) )
default :
let sendData : MessageSender . PreparedSendData = try MessageSender . preparedSendData (
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 )
)
) ,
to : try Message . Destination
. from ( db , threadId : cellViewModel . threadId , threadVariant : cellViewModel . threadVariant ) ,
namespace : try Message . Destination
. from ( db , threadId : cellViewModel . threadId , threadVariant : cellViewModel . threadVariant )
. defaultNamespace ,
interactionId : cellViewModel . id ,
using : dependencies
)
return ( sendData , nil )
}
}
}
. tryFlatMap { messageSendData , openGroupInfo -> AnyPublisher < Void , Error > in
switch ( messageSendData , openGroupInfo ) {
case ( . some ( let sendData ) , _ ) :
return MessageSender . sendImmediate ( data : sendData , using : dependencies )
case ( _ , . some ( let info ) ) :
return info . preparedRequest . send ( using : dependencies )
. handleEvents (
receiveOutput : { _ , seqNo in
OpenGroupManager
. updatePendingChange (
info . pendingChange ,
seqNo : seqNo
)
} ,
receiveCompletion : { [ weak self ] result in
switch result {
case . finished : break
case . failure :
OpenGroupManager . removePendingChange ( info . pendingChange )
self ? . handleReactionSentFailure (
info . pendingReaction ,
remove : remove
)
}
}
)
. map { _ in ( ) }
. eraseToAnyPublisher ( )
default : throw MessageSenderError . invalidMessage
}
}
. sinkUntilComplete ( )
}
func handleReactionSentFailure ( _ pendingReaction : Reaction ? , remove : Bool ) {
guard let pendingReaction = pendingReaction else { return }
Storage . shared . writeAsync { db in
// R e v e r s e t h e d a t a b a s e
if remove {
try pendingReaction . insert ( db )
}
else {
try Reaction
. filter ( Reaction . Columns . interactionId = = pendingReaction . interactionId )
. filter ( Reaction . Columns . authorId = = pendingReaction . authorId )
. filter ( Reaction . Columns . emoji = = pendingReaction . emoji )
. deleteAll ( db )
}
}
}
func showFullEmojiKeyboard ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
hideInputAccessoryView ( )
let emojiPicker = EmojiPickerSheet (
completionHandler : { [ weak self ] emoji in
guard let emoji : EmojiWithSkinTones = emoji else { return }
self ? . react ( cellViewModel , with : emoji , using : dependencies )
} ,
dismissHandler : { [ weak self ] in
self ? . showInputAccessoryView ( )
}
)
present ( emojiPicker , animated : true , completion : nil )
}
func contextMenuDismissed ( ) {
recoverInputView ( )
}
// MARK: - - a c t i o n h a n d l i n g
private func showFailedMessageSheet ( for cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
let sheet = UIAlertController (
title : ( cellViewModel . state = = . failedToSync ?
" MESSAGE_DELIVERY_FAILED_SYNC_TITLE " . localized ( ) :
" MESSAGE_DELIVERY_FAILED_TITLE " . localized ( )
) ,
message : cellViewModel . mostRecentFailureText ,
preferredStyle : . actionSheet
)
sheet . addAction ( UIAlertAction ( title : " TXT_CANCEL_TITLE " . localized ( ) , style : . cancel , handler : nil ) )
if cellViewModel . state != . failedToSync {
sheet . addAction ( UIAlertAction ( title : " TXT_DELETE_TITLE " . localized ( ) , style : . destructive , handler : { _ in
Storage . shared . writeAsync { db in
try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
}
} ) )
}
sheet . addAction ( UIAlertAction (
title : ( cellViewModel . state = = . failedToSync ?
" context_menu_resync " . localized ( ) :
" context_menu_resend " . localized ( )
) ,
style : . default ,
handler : { [ weak self ] _ in self ? . retry ( cellViewModel , using : dependencies ) }
) )
// 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
} )
}
}
Modal . setupForIPadIfNeeded ( sheet , targetView : self . view )
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 finalName : String = ( name ? ? " Open Group " )
let message : String = " Are you sure you want to join the \( finalName ) open group? " ;
let modal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " Join \( finalName ) ? " ,
body : . attributedText (
NSMutableAttributedString ( string : message )
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( message as NSString ) . range ( of : finalName )
)
) ,
confirmTitle : " JOIN_COMMUNITY_BUTTON_TITLE " . localized ( ) ,
onConfirm : { modal in
guard let presentingViewController : UIViewController = modal . presentingViewController else {
return
}
guard let ( room , server , publicKey ) = LibSession . parseCommunity ( url : url ) else {
let errorModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " COMMUNITY_ERROR_GENERIC " . localized ( ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
return presentingViewController . present ( errorModal , animated : true , completion : nil )
}
Storage . shared
. writePublisher { db in
OpenGroupManager . shared . add (
db ,
roomToken : room ,
server : server ,
publicKey : publicKey ,
calledFromConfigHandling : false
)
}
. flatMap { successfullyAddedGroup in
OpenGroupManager . shared . performInitialRequestsAfterAdd (
successfullyAddedGroup : successfullyAddedGroup ,
roomToken : room ,
server : server ,
publicKey : publicKey ,
calledFromConfigHandling : false
)
}
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. receive ( on : DispatchQueue . main )
. sinkUntilComplete (
receiveCompletion : { result in
switch result {
case . finished : break
case . failure ( let error ) :
// I f t h e r e w a s a f a i l u r e t h e n t h e g r o u p w i l l b e i n i n v a l i d s t a t e u n t i l
// t h e n e x t l a u n c h s o r e m o v e i t ( t h e u s e r w i l l b e l e f t o n t h e p r e v i o u s
// s c r e e n s o c a n r e - t r i g g e r t h e j o i n )
Storage . shared . writeAsync { db in
OpenGroupManager . shared . delete (
db ,
openGroupId : OpenGroup . idFor ( roomToken : room , server : server ) ,
calledFromConfigHandling : false
)
}
// S h o w t h e u s e r a n e r r o r i n d i c a t i n g t h e y f a i l e d t o p r o p e r l y j o i n t h e g r o u p
let errorModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " COMMUNITY_ERROR_GENERIC " . localized ( ) ,
body : . text ( error . localizedDescription ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
presentingViewController . present ( errorModal , animated : true , completion : nil )
}
}
)
}
)
)
present ( modal , 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 info ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
let actions : [ ContextMenuVC . Action ] = ContextMenuVC . actions (
for : cellViewModel ,
recentEmojis : [ ] ,
currentUserPublicKey : self . viewModel . threadData . currentUserPublicKey ,
currentUserBlinded15PublicKey : self . viewModel . threadData . currentUserBlinded15PublicKey ,
currentUserBlinded25PublicKey : self . viewModel . threadData . currentUserBlinded25PublicKey ,
currentUserIsOpenGroupModerator : OpenGroupManager . isUserModeratorOrAdmin (
self . viewModel . threadData . currentUserPublicKey ,
for : self . viewModel . threadData . openGroupRoomToken ,
on : self . viewModel . threadData . openGroupServer
) ,
currentThreadIsMessageRequest : ( self . viewModel . threadData . threadIsMessageRequest = = true ) ,
forMessageInfoScreen : true ,
delegate : self ,
using : dependencies
) ? ? [ ]
let messageInfoViewController = MessageInfoViewController (
actions : actions ,
messageViewModel : cellViewModel
)
DispatchQueue . main . asyncAfter ( deadline : . now ( ) + 0.2 ) { [ weak self ] in
self ? . navigationController ? . pushViewController ( messageInfoViewController , animated : true )
}
}
func retry ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
guard cellViewModel . id != MessageViewModel . optimisticUpdateId else {
guard
let optimisticMessageId : UUID = cellViewModel . optimisticMessageId ,
let optimisticMessageData : ConversationViewModel . OptimisticMessageData = self . viewModel . optimisticMessageData ( for : optimisticMessageId )
else {
// S h o w a n e r r o r f o r t h e r e t r y
let modal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " ALERT_ERROR_TITLE " . localized ( ) ,
body : . text ( " FAILED_TO_STORE_OUTGOING_MESSAGE " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self . present ( modal , animated : true , completion : nil )
return
}
// T r y t o s e n d t h e o p t i m i s t i c m e s s a g e a g a i n
sendMessage ( optimisticData : optimisticMessageData , using : dependencies )
return
}
dependencies . storage . writeAsync { [ weak self ] db in
guard
let threadId : String = self ? . viewModel . threadData . threadId ,
let threadVariant : SessionThread . Variant = self ? . viewModel . threadData . threadVariant ,
let interaction : Interaction = try ? Interaction . fetchOne ( db , id : cellViewModel . id )
else { return }
if
let quote = try ? interaction . quote . fetchOne ( db ) ,
let quotedAttachment = try ? quote . attachment . fetchOne ( db ) ,
quotedAttachment . isVisualMedia ,
quotedAttachment . downloadUrl = = Attachment . nonMediaQuoteFileId ,
let quotedInteraction = try ? quote . originalInteraction . fetchOne ( db )
{
let attachment : Attachment ? = {
if let attachment = try ? quotedInteraction . attachments . fetchOne ( db ) {
return attachment
}
if
let linkPreview = try ? quotedInteraction . linkPreview . fetchOne ( db ) ,
let linkPreviewAttachment = try ? linkPreview . attachment . fetchOne ( db )
{
return linkPreviewAttachment
}
return nil
} ( )
try quote . with (
attachmentId : attachment ? . cloneAsQuoteThumbnail ( ) ? . inserted ( db ) . id
) . update ( db )
}
// R e m o v e m e s s a g e s e n d i n g j o b s f o r t h e s a m e i n t e r a c t i o n i n d a t a b a s e
// P r e v e n t t h e s a m e m e s s a g e b e i n g s e n t t w i c e
try Job . filter ( Job . Columns . interactionId = = interaction . id ) . deleteAll ( db )
try MessageSender . send (
db ,
interaction : interaction ,
threadId : threadId ,
threadVariant : threadVariant ,
isSyncMessage : ( cellViewModel . state = = . failedToSync ) ,
using : dependencies
)
}
}
func reply ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
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 ,
currentUserPublicKey : cellViewModel . currentUserPublicKey ,
currentUserBlinded15PublicKey : cellViewModel . currentUserBlinded15PublicKey ,
currentUserBlinded25PublicKey : cellViewModel . currentUserBlinded25PublicKey
)
guard let quoteDraft : QuotedReplyModel = maybeQuoteDraft else { return }
snInputView . quoteDraftInfo = (
model : quoteDraft ,
isOutgoing : ( cellViewModel . variant = = . standardOutgoing )
)
snInputView . becomeFirstResponder ( )
}
func copy ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
switch cellViewModel . cellType {
case . typingIndicator , . dateHeader , . unreadMarker : break
case . textOnlyMessage :
if cellViewModel . body = = nil , let linkPreview : LinkPreview = cellViewModel . linkPreview {
UIPasteboard . general . string = linkPreview . url
return
}
UIPasteboard . general . string = cellViewModel . body
case . audio , . voiceMessage , . 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 , using dependencies : Dependencies ) {
switch cellViewModel . variant {
case . standardIncomingDeleted , . infoCall ,
. infoScreenshotNotification , . infoMediaSavedNotification ,
. infoClosedGroupCreated , . infoClosedGroupUpdated ,
. infoClosedGroupCurrentUserLeft , . infoClosedGroupCurrentUserLeaving , . infoClosedGroupCurrentUserErrorLeaving ,
. infoMessageRequestAccepted , . infoDisappearingMessagesUpdate :
// I n f o m e s s a g e s a n d u n s e n t m e s s a g e s s h o u l d j u s t t r i g g e r a l o c a l
// d e l e t i o n ( t h e y a r e c r e a t e d a s s i d e e f f e c t s s o w e w o u l d n ' t b e
// a b l e t o d e l e t e t h e m f o r a l l p a r t i c i p a n t s a n y w a y )
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
}
return
case . standardOutgoing , . standardIncoming : break
}
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 : AnyPublisher < Void , Error > , onComplete : ( ( ) -> ( ) ) ? ) {
// S h o w a l o a d i n g i n d i c a t o r
Deferred {
Future < Void , Error > { resolver in
DispatchQueue . main . async {
ModalActivityIndicatorViewController . present ( fromViewController : viewController , canCancel : false ) { _ in
resolver ( Result . success ( ( ) ) )
}
}
}
}
. flatMap { _ in request }
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. receive ( on : DispatchQueue . main )
. sinkUntilComplete (
receiveCompletion : { [ weak self ] result in
switch result {
case . failure : break
case . finished :
// 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 )
}
// S t o p i t ' s a u d i o i f n e e d e d
self ? . viewModel . stopAudioIfNeeded ( for : cellViewModel )
}
// R e g a r d l e s s o f s u c c e s s w e s h o u l d d i s m i s s a n d c a l l b a c k
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 ? ( )
}
)
}
// 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 . community :
// 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 : cellViewModel . 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 . afterJob ( targetJob , state : . running ) { [ 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 )
}
// S t o p i t ' s a u d i o i f n e e d e d
self ? . viewModel . stopAudioIfNeeded ( for : cellViewModel )
}
}
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 )
}
// S t o p i t ' s a u d i o i f n e e d e d
viewModel . stopAudioIfNeeded ( for : cellViewModel )
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
. readPublisher { db in
try OpenGroupAPI . preparedMessageDelete (
db ,
id : openGroupServerMessageId ,
in : openGroup . roomToken ,
on : openGroup . server ,
using : dependencies
)
}
. flatMap { $0 . send ( using : dependencies ) }
. map { _ in ( ) }
. eraseToAnyPublisher ( )
) { [ weak self ] in
self ? . showInputAccessoryView ( )
}
case . contact , . legacyGroup , . group :
let targetPublicKey : String = ( cellViewModel . threadVariant = = . contact ?
userPublicKey :
cellViewModel . threadId
)
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
)
)
. with (
expiresInSeconds : cellViewModel . expiresInSeconds ,
expiresStartedAtMs : cellViewModel . expiresStartedAtMs
)
// 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 : cellViewModel . threadId ,
interactionId : nil ,
to : . contact ( publicKey : userPublicKey ) ,
using : dependencies
)
}
// S t o p i t ' s a u d i o i f n e e d e d
viewModel . stopAudioIfNeeded ( for : cellViewModel )
return
}
let actionSheet : UIAlertController = UIAlertController ( title : nil , message : nil , preferredStyle : . actionSheet )
actionSheet . addAction ( UIAlertAction (
title : " delete_message_for_me " . localized ( ) ,
accessibilityIdentifier : " Delete for me " ,
style : . destructive
) { [ weak self ] _ in
Storage . shared . writeAsync { db in
_ = try Interaction
. filter ( id : cellViewModel . id )
. deleteAll ( db )
MessageSender
. send (
db ,
message : unsendRequest ,
threadId : cellViewModel . threadId ,
interactionId : nil ,
to : . contact ( publicKey : userPublicKey ) ,
using : dependencies
)
}
self ? . showInputAccessoryView ( )
// S t o p i t ' s a u d i o i f n e e d e d
self ? . viewModel . stopAudioIfNeeded ( for : cellViewModel )
} )
actionSheet . addAction ( UIAlertAction (
title : {
switch cellViewModel . threadVariant {
case . legacyGroup , . group : return " delete_message_for_everyone " . localized ( )
default :
return ( cellViewModel . threadId = = userPublicKey ?
" delete_message_for_me_and_my_devices " . localized ( ) :
String ( format : " delete_message_for_me_and_recipient " . localized ( ) , threadName )
)
}
} ( ) ,
accessibilityIdentifier : " Delete for everyone " ,
style : . destructive
) { [ weak self ] _ in
let completeServerDeletion = { [ weak self ] in
Storage . shared . writeAsync { db in
try MessageSender
. send (
db ,
message : unsendRequest ,
interactionId : nil ,
threadId : cellViewModel . threadId ,
threadVariant : cellViewModel . threadVariant ,
using : dependencies
)
}
self ? . showInputAccessoryView ( )
}
// W e c a n o n l y d e l e t e m e s s a g e s o n t h e s e r v e r f o r ` c o n t a c t ` a n d ` g r o u p ` c o n v e r s a t i o n s
guard cellViewModel . threadVariant = = . contact || cellViewModel . threadVariant = = . group else {
return completeServerDeletion ( )
}
deleteRemotely (
from : self ,
request : SnodeAPI
. deleteMessages (
swarmPublicKey : targetPublicKey ,
serverHashes : [ serverHash ]
)
. map { _ in ( ) }
. eraseToAnyPublisher ( )
) { completeServerDeletion ( ) }
} )
actionSheet . addAction ( UIAlertAction . init ( title : " TXT_CANCEL_TITLE " . localized ( ) , style : . cancel ) { [ weak self ] _ in
self ? . showInputAccessoryView ( )
} )
self . hideInputAccessoryView ( )
Modal . setupForIPadIfNeeded ( actionSheet , targetView : self . view )
self . present ( actionSheet , animated : true )
}
}
func save ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
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
}
sendDataExtraction ( kind : . mediaSaved ( timestamp : UInt64 ( cellViewModel . timestampMs ) ) )
}
func ban ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
guard cellViewModel . threadVariant = = . community else { return }
let threadId : String = self . viewModel . threadData . threadId
let modal : ConfirmationModal = ConfirmationModal (
targetView : self . view ,
info : ConfirmationModal . Info (
title : " Session " ,
body : . text ( " This will ban the selected user from this room. It won't ban them from other rooms. " ) ,
confirmTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text ,
onConfirm : { [ weak self ] _ in
Storage . shared
. readPublisher { db -> Network . PreparedRequest < NoResponse > in
guard let openGroup : OpenGroup = try OpenGroup . fetchOne ( db , id : threadId ) else {
throw StorageError . objectNotFound
}
return try OpenGroupAPI
. preparedUserBan (
db ,
sessionId : cellViewModel . authorId ,
from : [ openGroup . roomToken ] ,
on : openGroup . server ,
using : dependencies
)
}
. flatMap { $0 . send ( using : dependencies ) }
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. receive ( on : DispatchQueue . main )
. sinkUntilComplete (
receiveCompletion : { result in
switch result {
case . finished : break
case . failure :
let modal : ConfirmationModal = ConfirmationModal (
targetView : self ? . view ,
info : ConfirmationModal . Info (
title : CommonStrings . errorAlertTitle ,
body : . text ( " context_menu_ban_user_error_alert_message " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self ? . present ( modal , animated : true )
}
}
)
self ? . becomeFirstResponder ( )
} ,
afterClosed : { [ weak self ] in self ? . becomeFirstResponder ( ) }
)
)
self . present ( modal , animated : true )
}
func banAndDeleteAllMessages ( _ cellViewModel : MessageViewModel , using dependencies : Dependencies ) {
guard cellViewModel . threadVariant = = . community else { return }
let threadId : String = self . viewModel . threadData . threadId
let modal : ConfirmationModal = ConfirmationModal (
targetView : self . view ,
info : ConfirmationModal . Info (
title : " Session " ,
body : . text ( " 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. " ) ,
confirmTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text ,
onConfirm : { [ weak self ] _ in
Storage . shared
. readPublisher { db in
guard let openGroup : OpenGroup = try OpenGroup . fetchOne ( db , id : threadId ) else {
throw StorageError . objectNotFound
}
return try OpenGroupAPI
. preparedUserBanAndDeleteAllMessages (
db ,
sessionId : cellViewModel . authorId ,
in : openGroup . roomToken ,
on : openGroup . server ,
using : dependencies
)
}
. flatMap { $0 . send ( using : dependencies ) }
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. receive ( on : DispatchQueue . main )
. sinkUntilComplete (
receiveCompletion : { result in
switch result {
case . finished : break
case . failure :
let modal : ConfirmationModal = ConfirmationModal (
targetView : self ? . view ,
info : ConfirmationModal . Info (
title : CommonStrings . errorAlertTitle ,
body : . text ( " context_menu_ban_user_error_alert_message " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self ? . present ( modal , animated : true )
}
}
)
self ? . becomeFirstResponder ( )
} ,
afterClosed : { [ weak self ] in self ? . becomeFirstResponder ( ) }
)
)
self . present ( modal , animated : true )
}
// 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 ( using dependencies : Dependencies ) {
// 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
Permissions . requestMicrophonePermissionIfNeeded ( ) { [ weak self ] in
DispatchQueue . main . async {
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 = Singleton . appContext . temporaryDirectory
let fileName : String = " \( SnodeAPI . currentOffsetTimestampMs ( ) ) .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 = ( SessionEnvironment . 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 ( using : dependencies )
} )
// P r e p a r e a u d i o r e c o r d e r a n d s t a r t r e c o r d i n g
let successfullyPrepared : Bool = audioRecorder . prepareToRecord ( )
let startedRecording : Bool = ( successfullyPrepared && audioRecorder . record ( ) )
guard successfullyPrepared && startedRecording else {
SNLog ( successfullyPrepared ? " Couldn't record audio. " : " Couldn't prepare audio recorder. " )
// D i s p a t c h t o t h e n e x t r u n l o o p t o a v o i d
DispatchQueue . main . async {
let modal : ConfirmationModal = ConfirmationModal (
targetView : self . view ,
info : ConfirmationModal . Info (
title : " ALERT_ERROR_TITLE " . localized ( ) ,
body : . text ( " VOICE_MESSAGE_FAILED_TO_START_MESSAGE " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self . present ( modal , animated : true )
}
return cancelVoiceMessageRecording ( )
}
}
func endVoiceMessageRecording ( using dependencies : Dependencies ) {
UIApplication . shared . isIdleTimerDisabled = true
// H i d e t h e U I
snInputView . hideVoiceMessageUI ( )
// C a n c e l t h e t i m e r
audioTimer ? . invalidate ( )
// C h e c k p r e c o n d i t i o n s
guard let audioRecorder = audioRecorder else { return }
// G e t d u r a t i o n
let duration = audioRecorder . currentTime
// S t o p t h e r e c o r d i n g
stopVoiceMessageRecording ( )
// C h e c k f o r u s e r m i s u n d e r s t a n d i n g
guard duration > 1 else {
self . audioRecorder = nil
let modal : ConfirmationModal = ConfirmationModal (
targetView : self . view ,
info : ConfirmationModal . Info (
title : " VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE " . localized ( ) ,
body : . text ( " VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE " . localized ( ) ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self . present ( modal , animated : true )
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 )
}
// S e n d a t t a c h m e n t
sendMessage ( text : " " , attachments : [ attachment ] , using : dependencies )
}
func cancelVoiceMessageRecording ( ) {
snInputView . hideVoiceMessageUI ( )
audioTimer ? . invalidate ( )
stopVoiceMessageRecording ( )
audioRecorder = nil
}
func stopVoiceMessageRecording ( ) {
audioRecorder ? . stop ( )
SessionEnvironment . shared ? . audioSession . endAudioActivity ( recordVoiceMessageActivity )
}
// MARK: - D a t a E x t r a c t i o n N o t i f i c a t i o n s
@objc func sendScreenshotNotification ( ) { sendDataExtraction ( kind : . screenshot ) }
func sendDataExtraction (
kind : DataExtractionNotification . Kind ,
using dependencies : Dependencies = Dependencies ( )
) {
// O n l y s e n d s c r e e n s h o t n o t i f i c a t i o n s t o o n e - t o - o n e c o n v e r s a t i o n s
guard self . viewModel . threadData . threadVariant = = . contact else { return }
let threadId : String = self . viewModel . threadData . threadId
let threadVariant : SessionThread . Variant = self . viewModel . threadData . threadVariant
dependencies . storage . writeAsync { db in
try MessageSender . send (
db ,
message : DataExtractionNotification (
kind : kind ,
sentTimestamp : UInt64 ( SnodeAPI . currentOffsetTimestampMs ( ) )
)
. with ( DisappearingMessagesConfiguration
. fetchOne ( db , id : threadId ) ?
. forcedWithDisappearAfterReadIfNeeded ( )
) ,
interactionId : nil ,
threadId : threadId ,
threadVariant : threadVariant ,
using : dependencies
)
}
}
// MARK: - C o n v e n i e n c e
func showErrorAlert ( for attachment : SignalAttachment ) {
let modal : ConfirmationModal = ConfirmationModal (
targetView : self . view ,
info : ConfirmationModal . Info (
title : " ATTACHMENT_ERROR_ALERT_TITLE " . localized ( ) ,
body : . text ( attachment . localizedErrorDescription ? ? SignalAttachment . missingDataErrorMessage ) ,
cancelTitle : " BUTTON_OK " . localized ( ) ,
cancelStyle : . alert_text
)
)
self . present ( modal , animated : true )
}
}
// 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 ,
using dependencies : Dependencies = Dependencies ( )
) {
guard threadVariant = = . contact else { return }
let updateNavigationBackStack : ( ) -> Void = {
// R e m o v e t h e ' S e s s i o n T a b l e V i e w C o n t r o l l e r < M e s s a g e R e q u e s t s V i e w M o d e l > ' 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 : { viewCon -> Bool in
( viewCon as ? SessionViewModelAccessible ) ? . viewModelType = = MessageRequestsViewModel . self
} ) ,
messageRequestsIndex > 0
{
var newViewControllers = viewControllers
newViewControllers . remove ( at : messageRequestsIndex )
self ? . navigationController ? . viewControllers = newViewControllers
}
}
}
// 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 = Storage . shared . read ( { db in Contact . fetchOrCreate ( db , id : threadId ) } ) ,
! contact . isApproved
else { return }
Storage . shared
. writePublisher { 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 ,
threadId : threadId ,
threadVariant : threadVariant ,
using : dependencies
)
}
// 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 contact . save ( db )
try Contact
. filter ( id : contact . id )
. updateAllAndConfig (
db ,
Contact . Columns . isApproved . set ( to : true ) ,
Contact . Columns . didApproveMe
. set ( to : contact . didApproveMe || ! isNewThread )
)
}
. subscribe ( on : DispatchQueue . global ( qos : . userInitiated ) )
. receive ( on : DispatchQueue . main )
. sinkUntilComplete (
receiveCompletion : { _ in
// U p d a t e t h e U I
updateNavigationBackStack ( )
}
)
}
func acceptMessageRequest ( ) {
self . approveMessageRequestIfNeeded (
for : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
isNewThread : false ,
timestampMs : SnodeAPI . currentOffsetTimestampMs ( )
)
}
func declineMessageRequest ( ) {
let actions : [ UIContextualAction ] ? = UIContextualAction . generateSwipeActions (
[ . delete ] ,
for : . trailing ,
indexPath : IndexPath ( row : 0 , section : 0 ) ,
tableView : self . tableView ,
threadViewModel : self . viewModel . threadData ,
viewController : self
)
guard let action : UIContextualAction = actions ? . first else { return }
action . handler ( action , self . view , { [ weak self ] didConfirm in
guard didConfirm else { return }
self ? . stopObservingChanges ( )
DispatchQueue . main . async {
self ? . navigationController ? . popViewController ( animated : true )
}
} )
}
func blockMessageRequest ( ) {
let actions : [ UIContextualAction ] ? = UIContextualAction . generateSwipeActions (
[ . block ] ,
for : . trailing ,
indexPath : IndexPath ( row : 0 , section : 0 ) ,
tableView : self . tableView ,
threadViewModel : self . viewModel . threadData ,
viewController : self
)
guard let action : UIContextualAction = actions ? . first else { return }
action . handler ( action , self . view , { [ weak self ] didConfirm in
guard didConfirm else { return }
self ? . stopObservingChanges ( )
DispatchQueue . main . async {
self ? . navigationController ? . popViewController ( animated : true )
}
} )
}
}
// 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 )
}
}