// 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 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 : " callsPermissionsRequired " . localized ( ) ,
body : . text ( " callsPermissionsRequiredDescription " . localized ( ) ) ,
confirmTitle : " sessionSettings " . 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 confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : String (
format : " blockUnblock " . localized ( ) ,
self . viewModel . threadData . displayName
) ,
body : . attributedText (
" blockUnblockDescription "
. put ( key : " name " , value : self . viewModel . threadData . displayName )
. localizedFormatted ( baseFont : . systemFont ( ofSize : Values . smallFontSize ) )
) ,
confirmTitle : " blockUnblock " . 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 ?
) {
sendMessage ( text : ( messageText ? ? " " ) , attachments : attachments , using : viewModel . 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 ?
) {
sendMessage ( text : ( messageText ? ? " " ) , attachments : attachments , using : viewModel . 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 : " giphyWarning " . localized ( ) ,
body : . text (
" giphyWarningDescription "
. put ( key : " app_name " , value : Constants . app_name )
. localized ( )
) ,
confirmTitle : " theContinue " . 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 , dependencies = viewModel . dependencies ] in
DispatchQueue . main . async {
let sendMediaNavController = SendMediaNavigationController . showingMediaLibraryFirst (
threadId : threadId ,
threadVariant : threadVariant ,
using : dependencies
)
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 ,
using : self . viewModel . dependencies
)
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
self ? . viewModel . showToast ( text : " attachmentsErrorLoad " . localized ( ) )
}
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 : " attachmentsErrorNotSupported " . localized ( ) ,
body : . text ( " attachmentsErrorSize " . localized ( ) ) ,
cancelTitle : " okay " . localized ( ) ,
cancelStyle : . alert_text
)
)
self ? . present ( modal , animated : true )
}
return
}
let fileName = urlResourceValues . name ? ? " attachment " . localized ( )
guard let dataSource = DataSourcePath ( fileUrl : url , shouldDeleteOnDeinit : false ) else {
DispatchQueue . main . async { [ weak self ] in
self ? . viewModel . showToast ( text : " attachmentsErrorLoad " . localized ( ) )
}
return
}
dataSource . sourceFilename = fileName
// A l t h o u g h w e w a n t t o b e a b l e t o s e n d h i g h e r q u a l i t y a t t a c h m e n t s t h r o u g h t h e d o c u m e n t p i c k e r
// i t ' s m o r e i m p o r a n t t h a t w e e n s u r e t h e s e n t f o r m a t i s o n e a l l c l i e n t s c a n a c c e p t ( e . g . * n o t * q u i c k t i m e . m o v )
guard ! SignalAttachment . isInvalidVideo ( dataSource : dataSource , dataUTI : type ) else {
return showAttachmentApprovalDialogAfterProcessingVideo ( at : url , with : fileName )
}
// " D o c u m e n t p i c k e r " a t t a c h m e n t s _ S H O U L D N O T _ b e r e s i z e d
let attachment = SignalAttachment . attachment ( dataSource : dataSource , dataUTI : type , imageQuality : . original )
showAttachmentApprovalDialog ( for : [ attachment ] )
}
func showAttachmentApprovalDialog ( for attachments : [ SignalAttachment ] ) {
guard let navController = AttachmentApprovalViewController . wrappedInNavController (
threadId : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
attachments : attachments ,
approvalDelegate : self ,
using : self . viewModel . dependencies
) else { return }
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 , dependencies = viewModel . dependencies ] modalActivityIndicator in
let dataSource = DataSourcePath ( fileUrl : url , shouldDeleteOnDeinit : false ) !
dataSource . sourceFilename = fileName
SignalAttachment
. compressVideoAsMp4 (
dataSource : dataSource ,
dataUTI : kUTTypeMPEG4 as String ,
using : dependencies
)
. 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 : " warning " . localized ( ) ,
body : . text ( " recoveryPasswordWarningSendDescription " . localized ( ) ) ,
confirmTitle : " send " . 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 ) ,
SessionThread . Columns . pinnedPriority . set ( to : LibSession . visiblePriority )
)
}
// 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 : " linkPreviewsEnable " . localized ( ) ,
body : . text (
" linkPreviewsFirstDescription "
. put ( key : " app_name " , value : Constants . app_name )
. localized ( )
) ,
confirmTitle : " enable " . 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 ( data : imageData , utiType : kUTTypeJPEG as String )
let attachment = SignalAttachment . attachment ( dataSource : dataSource , dataUTI : kUTTypeJPEG as String , imageQuality : . medium )
guard let approvalVC = AttachmentApprovalViewController . wrappedInNavController (
threadId : self . viewModel . threadData . threadId ,
threadVariant : self . viewModel . threadData . threadVariant ,
attachments : [ attachment ] ,
approvalDelegate : self ,
using : self . viewModel . dependencies
) else { return }
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 ) ) " // s t r i n g l i n t : d i s a b l e
)
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 } // s t r i n g l i n t : d i s a b l e
result = result . replacingCharacters ( in : range , with : " @ \( mention . profile . id ) " ) // s t r i n g l i n t : d i s a b l e
}
return result
}
func hideInputAccessoryView ( ) {
DispatchQueue . main . async {
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 ( )
) {
// 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 ? . localizedName ? ? " " )
let modalBodyString : String = {
if messageDisappearingConfig . isEnabled {
return " disappearingMessagesFollowSettingOn "
. put ( key : " time " , value : expirationTimerString )
. put ( key : " disappearing_messages_type " , value : expirationTypeString )
. localized ( )
} else {
return " disappearingMessagesFollowSettingOff "
. localized ( )
}
} ( )
let modalConfirmTitle : String = messageDisappearingConfig . isEnabled ? " set " . localized ( ) : " confirm " . localized ( )
let confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " disappearingMessagesFollowSetting " . localized ( ) ,
body : . attributedText ( modalBodyString . formatted ( baseFont : . systemFont ( ofSize : Values . smallFontSize ) ) ) ,
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 : NSAttributedString = " attachmentsAutoDownloadModalDescription "
. put ( key : " conversation_name " , value : cellViewModel . authorName )
. localizedFormatted ( baseFont : . systemFont ( ofSize : Values . smallFontSize ) )
let confirmationModal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " attachmentsAutoDownloadModalTitle " . localized ( ) ,
body : . attributedText ( message ) ,
confirmTitle : " download " . 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 = = MimeTypeUtil . MimeType . applicationPdf
{
// FIXME: I f g i v e n a n i n v a l i d t e x t f i l e ( e g w i t h b i n a r y d a t a ) t h i s h a n g s f o r e v e r
// N o t e : I t r i e d d i s p a t c h i n g a f t e r a s h o r t d e l a y , d e t e c t i n g t h a t t h e n e w U I i s i n v a l i d a n d d i s m i s s i n g i t
// i f s o b u t t h e d i s m i s s a l d i d n ' t w o r k ( w e m a y h a v e t o w a i t o n A p p l e t o h a n d l e t h i s o n e )
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 : " urlOpen " . localized ( ) ,
message : " urlOpenDescription "
. put ( key : " url " , value : url . absoluteString )
. localized ( ) ,
preferredStyle : . actionSheet
)
actionSheet . addAction ( UIAlertAction ( title : " open " . localized ( ) , style : . default ) { [ weak self ] _ in
UIApplication . shared . open ( url , options : [ : ] , completionHandler : nil )
self ? . showInputAccessoryView ( )
} )
actionSheet . addAction ( UIAlertAction ( title : " urlCopy " . 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 ( try ? SessionId . Prefix ( from : sessionId ) ) != . blinded25 else { return }
guard ( try ? 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 ,
using : viewModel . dependencies
)
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 ,
using : viewModel . dependencies
)
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 : " emojiReactsCoolDown " . 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
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 ? ? " communityUnknown " . localized ( ) )
let message : String = " communityJoinDescription "
. put ( key : " community_name " , value : finalName )
. localized ( )
let modal : ConfirmationModal = ConfirmationModal (
info : ConfirmationModal . Info (
title : " join " . localized ( ) + " \( finalName ) ? " ,
body : . attributedText (
NSMutableAttributedString ( string : message )
. adding (
attributes : [ . font : UIFont . boldSystemFont ( ofSize : Values . smallFontSize ) ] ,
range : ( message as NSString ) . range ( of : finalName )
)
) ,
confirmTitle : " join " . 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 : " communityJoinError "
. put ( key : " community_name " , value : finalName )
. localized ( ) ,
cancelTitle : " okay " . 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 : " communityJoinError "
. put ( key : " community_name " , value : finalName )
. localized ( ) ,
body : . text ( " \( error ) " ) ,
cancelTitle : " okay " . 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 : " theError " . localized ( ) ,
body : . text ( " shareExtensionDatabaseError " . localized ( ) ) ,
cancelTitle : " okay " . 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 ( for : 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 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 : " deleteMessageDeviceOnly " . 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 , cellViewModel . threadId ) {
case ( . legacyGroup , _ ) , ( . group , _ ) : return " clearMessagesForEveryone " . localized ( )
case ( _ , userPublicKey ) : return " deleteMessageDevicesAll " . localized ( )
default : return " deleteMessageEveryone " . localized ( )
}
} ( ) ,
accessibilityIdentifier : " Delete for everyone " ,
style : . destructive
) { [ weak self ] _ in
let completeServerDeletion = {
Storage . shared . writeAsync { db in
try MessageSender
. send (
db ,
message : unsendRequest ,
interactionId : nil ,
threadId : cellViewModel . threadId ,
threadVariant : cellViewModel . threadVariant ,
using : dependencies
)
}
}
// 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 : " cancel " . 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 : Constants . app_name ,
body : . text ( " This will ban the selected user from this room. It won't ban them from other rooms. " ) ,
confirmTitle : " okay " . 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 :
DispatchQueue . main . async { [ weak self ] in
self ? . viewModel . showToast (
text : " banUserBanned " . localized ( ) ,
backgroundColor : . backgroundSecondary
)
}
case . failure :
DispatchQueue . main . async { [ weak self ] in
self ? . viewModel . showToast (
text : " banErrorFailed " . localized ( ) ,
backgroundColor : . backgroundSecondary
)
}
}
}
)
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 : Constants . app_name ,
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 : " okay " . 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 :
DispatchQueue . main . async { [ weak self ] in
self ? . viewModel . showToast (
text : " banUserBanned " . localized ( ) ,
backgroundColor : . backgroundSecondary
)
}
case . failure :
DispatchQueue . main . async { [ weak self ] in
self ? . viewModel . showToast (
text : " banErrorFailed " . localized ( ) ,
backgroundColor : . backgroundSecondary
)
}
}
}
)
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 " // s t r i n g l i n t : d i s a b l e
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 : " theError " . localized ( ) ,
body : . text ( " audioUnableToRecord " . localized ( ) ) ,
cancelTitle : " okay " . 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 : " messageVoice " . localized ( ) ,
body : . text ( " messageVoiceErrorShort " . localized ( ) ) ,
cancelTitle : " okay " . localized ( ) ,
cancelStyle : . alert_text
)
)
self . present ( modal , animated : true )
return
}
// G e t d a t a
let dataSourceOrNil = DataSourcePath ( fileUrl : audioRecorder . url , shouldDeleteOnDeinit : 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 = ( " messageVoice " . 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 : " attachmentsErrorSending " . localized ( ) ,
body : . text ( attachment . localizedErrorDescription ? ? SignalAttachment . missingDataErrorMessage ) ,
cancelTitle : " okay " . 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 ,
navigatableStateHolder : nil
)
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 ,
navigatableStateHolder : nil
)
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 )
}
}