mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			2628 lines
		
	
	
		
			118 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			2628 lines
		
	
	
		
			118 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import Combine
 | |
| import CoreServices
 | |
| import Photos
 | |
| import PhotosUI
 | |
| import Sodium
 | |
| import GRDB
 | |
| import SessionUIKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| import SignalUtilitiesKit
 | |
| import SessionSnodeKit
 | |
| 
 | |
| extension ConversationVC:
 | |
|     InputViewDelegate,
 | |
|     MessageCellDelegate,
 | |
|     ContextMenuActionDelegate,
 | |
|     SendMediaNavDelegate,
 | |
|     UIDocumentPickerDelegate,
 | |
|     AttachmentApprovalViewControllerDelegate,
 | |
|     GifPickerViewControllerDelegate
 | |
| {
 | |
|     @objc func handleTitleViewTapped() {
 | |
|         // Don't take the user to settings for unapproved threads
 | |
|         guard viewModel.threadData.threadRequiresApproval == false else { return }
 | |
| 
 | |
|         openSettings()
 | |
|     }
 | |
| 
 | |
|     @objc func openSettings() {
 | |
|         let viewController: SessionTableViewController = SessionTableViewController(
 | |
|             viewModel: ThreadSettingsViewModel(
 | |
|                 threadId: self.viewModel.threadData.threadId,
 | |
|                 threadVariant: self.viewModel.threadData.threadVariant,
 | |
|                 didTriggerSearch: { [weak self] in
 | |
|                     DispatchQueue.main.async {
 | |
|                         self?.showSearchUI()
 | |
|                         self?.popAllConversationSettingsViews {
 | |
|                             // Note: Without this delay the search bar doesn't show
 | |
|                             DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
 | |
|                                 self?.searchController.uiSearchController.searchBar.becomeFirstResponder()
 | |
|                             }
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             )
 | |
|         )
 | |
|         
 | |
|         navigationController?.pushViewController(viewController, animated: true)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Call
 | |
|     
 | |
|     @objc func startCall(_ sender: Any?) {
 | |
|         guard SessionCall.isEnabled else { return }
 | |
|         guard viewModel.threadData.threadIsBlocked == false else { return }
 | |
|         guard Storage.shared[.areCallsEnabled] else {
 | |
|             let confirmationModal: ConfirmationModal = ConfirmationModal(
 | |
|                 info: ConfirmationModal.Info(
 | |
|                     title: "modal_call_permission_request_title".localized(),
 | |
|                     body: .text("modal_call_permission_request_explanation".localized()),
 | |
|                     confirmTitle: "vc_settings_title".localized(),
 | |
|                     confirmAccessibility: Accessibility(identifier: "Settings"),
 | |
|                     dismissOnConfirm: false // Custom dismissal logic
 | |
|                 ) { [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()
 | |
|         
 | |
|         present(callVC, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     // MARK: - Blocking
 | |
|     
 | |
|     @objc func unblock() {
 | |
|         self.showBlockedModalIfNeeded()
 | |
|     }
 | |
| 
 | |
|     @discardableResult func showBlockedModalIfNeeded() -> Bool {
 | |
|         guard
 | |
|             self.viewModel.threadData.threadVariant == .contact &&
 | |
|             self.viewModel.threadData.threadIsBlocked == true
 | |
|         else { return false }
 | |
|         
 | |
|         let message = String(
 | |
|             format: "modal_blocked_explanation".localized(),
 | |
|             self.viewModel.threadData.displayName
 | |
|         )
 | |
|         let confirmationModal: ConfirmationModal = ConfirmationModal(
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: String(
 | |
|                     format: "modal_blocked_title".localized(),
 | |
|                     self.viewModel.threadData.displayName
 | |
|                 ),
 | |
|                 body: .attributedText(
 | |
|                     NSAttributedString(string: message)
 | |
|                         .adding(
 | |
|                             attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
 | |
|                             range: (message as NSString).range(of: self.viewModel.threadData.displayName)
 | |
|                         )
 | |
|                 ),
 | |
|                 confirmTitle: "modal_blocked_button_title".localized(),
 | |
|                 confirmAccessibility: Accessibility(identifier: "Confirm block"),
 | |
|                 cancelAccessibility: Accessibility(identifier: "Cancel block"),
 | |
|                 dismissOnConfirm: false // Custom dismissal logic
 | |
|             ) { [weak self] _ in
 | |
|                 self?.viewModel.unblockContact()
 | |
|                 self?.dismiss(animated: true, completion: nil)
 | |
|             }
 | |
|         )
 | |
|         present(confirmationModal, animated: true, completion: nil)
 | |
|         
 | |
|         return true
 | |
|     }
 | |
| 
 | |
|     // MARK: - SendMediaNavDelegate
 | |
| 
 | |
|     func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) {
 | |
|         dismiss(animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func sendMediaNav(
 | |
|         _ sendMediaNavigationController: SendMediaNavigationController,
 | |
|         didApproveAttachments attachments: [SignalAttachment],
 | |
|         forThreadId threadId: String,
 | |
|         messageText: String?,
 | |
|         using dependencies: Dependencies
 | |
|     ) {
 | |
|         sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies)
 | |
|         resetMentions()
 | |
|         
 | |
|         dismiss(animated: true) { [weak self] in
 | |
|             if self?.isFirstResponder == false {
 | |
|                 self?.becomeFirstResponder()
 | |
|             }
 | |
|             else {
 | |
|                 self?.reloadInputViews()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
 | |
|         return snInputView.text
 | |
|     }
 | |
| 
 | |
|     func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) {
 | |
|         snInputView.text = (newMessageText ?? "")
 | |
|     }
 | |
| 
 | |
|     // MARK: - AttachmentApprovalViewControllerDelegate
 | |
|     
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], forThreadId threadId: String, messageText: String?, using dependencies: Dependencies) {
 | |
|         sendMessage(text: (messageText ?? ""), attachments: attachments, using: dependencies)
 | |
|         resetMentions()
 | |
|         
 | |
|         dismiss(animated: true) { [weak self] in
 | |
|             if self?.isFirstResponder == false {
 | |
|                 self?.becomeFirstResponder()
 | |
|             }
 | |
|             else {
 | |
|                 self?.reloadInputViews()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
 | |
|         dismiss(animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
 | |
|         snInputView.text = (newMessageText ?? "")
 | |
|     }
 | |
|     
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) {
 | |
|     }
 | |
| 
 | |
|     func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) {
 | |
|     }
 | |
| 
 | |
|     // MARK: - ExpandingAttachmentsButtonDelegate
 | |
| 
 | |
|     func handleGIFButtonTapped() {
 | |
|         guard Storage.shared[.isGiphyEnabled] else {
 | |
|             let modal: ConfirmationModal = ConfirmationModal(
 | |
|                 info: ConfirmationModal.Info(
 | |
|                     title: "GIPHY_PERMISSION_TITLE".localized(),
 | |
|                     body: .text("GIPHY_PERMISSION_MESSAGE".localized()),
 | |
|                     confirmTitle: "continue_2".localized()
 | |
|                 ) { [weak self] _ in
 | |
|                     Storage.shared.writeAsync(
 | |
|                         updates: { db in
 | |
|                             db[.isGiphyEnabled] = true
 | |
|                         },
 | |
|                         completion: { _, _ in
 | |
|                             DispatchQueue.main.async {
 | |
|                                 self?.handleGIFButtonTapped()
 | |
|                             }
 | |
|                         }
 | |
|                     )
 | |
|                 }
 | |
|             )
 | |
|             
 | |
|             present(modal, animated: true, completion: nil)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let gifVC = GifPickerViewController()
 | |
|         gifVC.delegate = self
 | |
|         
 | |
|         let navController = StyledNavigationController(rootViewController: gifVC)
 | |
|         navController.modalPresentationStyle = .fullScreen
 | |
|         present(navController, animated: true) { }
 | |
|     }
 | |
| 
 | |
|     func handleDocumentButtonTapped() {
 | |
|         // UIDocumentPickerModeImport copies to a temp file within our container.
 | |
|         // It uses more memory than "open" but lets us avoid working with security scoped URLs.
 | |
|         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
 | |
|         
 | |
|         Permissions.requestLibraryPermissionIfNeeded { [weak self] in
 | |
|             DispatchQueue.main.async {
 | |
|                 let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst(
 | |
|                     threadId: threadId
 | |
|                 )
 | |
|                 sendMediaNavController.sendMediaNavDelegate = self
 | |
|                 sendMediaNavController.modalPresentationStyle = .fullScreen
 | |
|                 self?.present(sendMediaNavController, animated: true, completion: nil)
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func handleCameraButtonTapped() {
 | |
|         guard 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)
 | |
|         sendMediaNavController.sendMediaNavDelegate = self
 | |
|         sendMediaNavController.modalPresentationStyle = .fullScreen
 | |
|         
 | |
|         present(sendMediaNavController, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     // MARK: - GifPickerViewControllerDelegate
 | |
|     
 | |
|     func gifPickerDidSelect(attachment: SignalAttachment) {
 | |
|         showAttachmentApprovalDialog(for: [ attachment ])
 | |
|     }
 | |
|     
 | |
|     // MARK: - UIDocumentPickerDelegate
 | |
|     
 | |
|     func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
 | |
|         guard let url = urls.first else { return } // TODO: Handle multiple?
 | |
|         
 | |
|         let urlResourceValues: URLResourceValues
 | |
|         do {
 | |
|             urlResourceValues = try url.resourceValues(forKeys: [ .typeIdentifierKey, .isDirectoryKey, .nameKey ])
 | |
|         }
 | |
|         catch {
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 let modal: ConfirmationModal = ConfirmationModal(
 | |
|                     targetView: self?.view,
 | |
|                     info: ConfirmationModal.Info(
 | |
|                         title: "Session",
 | |
|                         body: .text("An error occurred."),
 | |
|                         cancelTitle: "BUTTON_OK".localized(),
 | |
|                         cancelStyle: .alert_text
 | |
|                     )
 | |
|                 )
 | |
|                 self?.present(modal, animated: true)
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String)
 | |
|         guard urlResourceValues.isDirectory != true else {
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 let modal: ConfirmationModal = ConfirmationModal(
 | |
|                     targetView: self?.view,
 | |
|                     info: ConfirmationModal.Info(
 | |
|                         title: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE".localized(),
 | |
|                         body: .text("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY".localized()),
 | |
|                         cancelTitle: "BUTTON_OK".localized(),
 | |
|                         cancelStyle: .alert_text
 | |
|                     )
 | |
|                 )
 | |
|                 self?.present(modal, animated: true)
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let fileName = urlResourceValues.name ?? NSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "")
 | |
|         guard let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else {
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 let modal: ConfirmationModal = ConfirmationModal(
 | |
|                     targetView: self?.view,
 | |
|                     info: ConfirmationModal.Info(
 | |
|                         title: "ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE".localized(),
 | |
|                         cancelTitle: "BUTTON_OK".localized(),
 | |
|                         cancelStyle: .alert_text
 | |
|                     )
 | |
|                 )
 | |
|                 self?.present(modal, animated: true)
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         dataSource.sourceFilename = fileName
 | |
|         
 | |
|         // Although we want to be able to send higher quality attachments through the document picker
 | |
|         // it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov)
 | |
|         guard !SignalAttachment.isInvalidVideo(dataSource: dataSource, dataUTI: type) else {
 | |
|             return showAttachmentApprovalDialogAfterProcessingVideo(at: url, with: fileName)
 | |
|         }
 | |
|         
 | |
|         // "Document picker" attachments _SHOULD NOT_ be resized
 | |
|         let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: type, imageQuality: .original)
 | |
|         showAttachmentApprovalDialog(for: [ attachment ])
 | |
|     }
 | |
| 
 | |
|     func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) {
 | |
|         let navController = AttachmentApprovalViewController.wrappedInNavController(
 | |
|             threadId: self.viewModel.threadData.threadId,
 | |
|             attachments: attachments,
 | |
|             approvalDelegate: self
 | |
|         )
 | |
|         navController.modalPresentationStyle = .fullScreen
 | |
|         
 | |
|         present(navController, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func showAttachmentApprovalDialogAfterProcessingVideo(at url: URL, with fileName: String) {
 | |
|         ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self] modalActivityIndicator in
 | |
|             let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false)!
 | |
|             dataSource.sourceFilename = fileName
 | |
|             
 | |
|             SignalAttachment
 | |
|                 .compressVideoAsMp4(
 | |
|                     dataSource: dataSource,
 | |
|                     dataUTI: kUTTypeMPEG4 as String
 | |
|                 )
 | |
|                 .attachmentPublisher
 | |
|                 .sinkUntilComplete(
 | |
|                     receiveValue: { [weak self] attachment in
 | |
|                         guard !modalActivityIndicator.wasCancelled else { return }
 | |
|                         
 | |
|                         modalActivityIndicator.dismiss {
 | |
|                             guard !attachment.hasError else {
 | |
|                                 self?.showErrorAlert(for: attachment)
 | |
|                                 return
 | |
|                             }
 | |
|                             
 | |
|                             self?.showAttachmentApprovalDialog(for: [ attachment ])
 | |
|                         }
 | |
|                     }
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - InputViewDelegate
 | |
| 
 | |
|     // MARK: --Message Sending
 | |
|     
 | |
|     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 }
 | |
|         
 | |
|         // Handle attachment errors if applicable
 | |
|         if let failedAttachment: SignalAttachment = attachments.first(where: { $0.hasError }) {
 | |
|             return showErrorAlert(for: failedAttachment)
 | |
|         }
 | |
|         
 | |
|         let processedText: String = replaceMentions(in: text.trimmingCharacters(in: .whitespacesAndNewlines))
 | |
|         
 | |
|         // If we have no content then do nothing
 | |
|         guard !processedText.isEmpty || !attachments.isEmpty else { return }
 | |
| 
 | |
|         if processedText.contains(mnemonic) && !viewModel.threadData.threadIsNoteToSelf && !hasPermissionToSendSeed {
 | |
|             // Warn the user if they're about to send their seed to someone
 | |
|             let modal: ConfirmationModal = ConfirmationModal(
 | |
|                 info: ConfirmationModal.Info(
 | |
|                     title: "modal_send_seed_title".localized(),
 | |
|                     body: .text("modal_send_seed_explanation".localized()),
 | |
|                     confirmTitle: "modal_send_seed_send_button_title".localized(),
 | |
|                     confirmStyle: .danger,
 | |
|                     cancelStyle: .alert_text,
 | |
|                     onConfirm: { [weak self] _ in
 | |
|                         self?.sendMessage(
 | |
|                             text: text,
 | |
|                             attachments: attachments,
 | |
|                             linkPreviewDraft: linkPreviewDraft,
 | |
|                             quoteModel: quoteModel,
 | |
|                             hasPermissionToSendSeed: true
 | |
|                         )
 | |
|                     }
 | |
|                 )
 | |
|             )
 | |
|             
 | |
|             return present(modal, animated: true, completion: nil)
 | |
|         }
 | |
|         
 | |
|         // Clearing this out immediately to make this appear more snappy
 | |
|         DispatchQueue.main.async { [weak self] in
 | |
|             self?.snInputView.text = ""
 | |
|             self?.snInputView.quoteDraftInfo = nil
 | |
| 
 | |
|             self?.resetMentions()
 | |
|             self?.scrollToBottom(isAnimated: false)
 | |
|         }
 | |
| 
 | |
|         // Note: 'shouldBeVisible' is set to true the first time a thread is saved so we can
 | |
|         // use it to determine if the user is creating a new thread and update the 'isApproved'
 | |
|         // flags appropriately
 | |
|         let oldThreadShouldBeVisible: Bool = (self.viewModel.threadData.threadShouldBeVisible == true)
 | |
|         let sentTimestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
 | |
| 
 | |
|         // If this was a message request then approve it
 | |
|         approveMessageRequestIfNeeded(
 | |
|             for: self.viewModel.threadData.threadId,
 | |
|             threadVariant: self.viewModel.threadData.threadVariant,
 | |
|             isNewThread: !oldThreadShouldBeVisible,
 | |
|             timestampMs: (sentTimestampMs - 1)  // Set 1ms earlier as this is used for sorting
 | |
|         )
 | |
|         
 | |
|         // Optimistically insert the outgoing message (this will trigger a UI update)
 | |
|         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) {
 | |
|             // Generate the quote thumbnail if needed (want this to happen outside of the DBWrite thread as
 | |
|             // this can take up to 0.5s
 | |
|             let quoteThumbnailAttachment: Attachment? = optimisticData.quoteModel?.attachment?.cloneAsQuoteThumbnail()
 | |
|             
 | |
|             // Actually send the message
 | |
|             dependencies.storage
 | |
|                 .writePublisher { [weak self] db in
 | |
|                     // Update the thread to be visible (if it isn't already)
 | |
|                     if self?.viewModel.threadData.threadShouldBeVisible == false {
 | |
|                         _ = try SessionThread
 | |
|                             .filter(id: threadId)
 | |
|                             .updateAllAndConfig(db, SessionThread.Columns.shouldBeVisible.set(to: true))
 | |
|                     }
 | |
|                     
 | |
|                     // Insert the interaction and associated it with the optimistically inserted message so
 | |
|                     // we can remove it once the database triggers a UI update
 | |
|                     let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db)
 | |
|                     self?.viewModel.associate(optimisticMessageId: optimisticData.id, to: insertedInteraction.id)
 | |
|                     
 | |
|                     // If there is a LinkPreview and it doesn't match an existing one then add it now
 | |
|                     if
 | |
|                         let linkPreviewDraft: LinkPreviewDraft = optimisticData.linkPreviewDraft,
 | |
|                         (try? insertedInteraction.linkPreview.isEmpty(db)) == true
 | |
|                     {
 | |
|                         try LinkPreview(
 | |
|                             url: linkPreviewDraft.urlString,
 | |
|                             title: linkPreviewDraft.title,
 | |
|                             attachmentId: try optimisticData.linkPreviewAttachment?.inserted(db).id
 | |
|                         ).insert(db)
 | |
|                     }
 | |
|                     
 | |
|                     // If there is a Quote the insert it now
 | |
|                     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)
 | |
|                     }
 | |
|                     
 | |
|                     // Process any attachments
 | |
|                     try Attachment.process(
 | |
|                         db,
 | |
|                         data: optimisticData.attachmentData,
 | |
|                         for: insertedInteraction.id
 | |
|                     )
 | |
|                     
 | |
|                     try MessageSender.send(
 | |
|                         db,
 | |
|                         interaction: insertedInteraction,
 | |
|                         threadId: threadId,
 | |
|                         threadVariant: threadVariant,
 | |
|                         using: dependencies
 | |
|                     )
 | |
|                 }
 | |
|                 .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|                 .sinkUntilComplete(
 | |
|                     receiveCompletion: { [weak self] result in
 | |
|                         switch result {
 | |
|                             case .finished: break
 | |
|                             case .failure(let error):
 | |
|                                 self?.viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.id, error: error)
 | |
|                         }
 | |
|                         
 | |
|                         self?.handleMessageSent()
 | |
|                     }
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func handleMessageSent() {
 | |
|         if Storage.shared[.playNotificationSoundInForeground] {
 | |
|             let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true)
 | |
|             AudioServicesPlaySystemSound(soundID)
 | |
|         }
 | |
|         
 | |
|         let threadId: String = self.viewModel.threadData.threadId
 | |
|         
 | |
|         Storage.shared.writeAsync { db in
 | |
|             TypingIndicators.didStopTyping(db, threadId: threadId, direction: .outgoing)
 | |
|             
 | |
|             _ = try SessionThread
 | |
|                 .filter(id: threadId)
 | |
|                 .updateAll(db, SessionThread.Columns.messageDraft.set(to: ""))
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func showLinkPreviewSuggestionModal() {
 | |
|         let linkPreviewModal: ConfirmationModal = ConfirmationModal(
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: "modal_link_previews_title".localized(),
 | |
|                 body: .text("modal_link_previews_explanation".localized()),
 | |
|                 confirmTitle: "modal_link_previews_button_title".localized()
 | |
|             ) { [weak self] _ in
 | |
|                 Storage.shared.writeAsync { db in
 | |
|                     db[.areLinkPreviewsEnabled] = true
 | |
|                 }
 | |
|                 
 | |
|                 self?.snInputView.autoGenerateLinkPreview()
 | |
|             }
 | |
|         )
 | |
|         
 | |
|         present(linkPreviewModal, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
 | |
|         // Note: If there is a 'draft' message then we don't want it to trigger the typing indicator to
 | |
|         // appear (as that is not expected/correct behaviour)
 | |
|         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: --Attachments
 | |
|     
 | |
|     func didPasteImageFromPasteboard(_ image: UIImage) {
 | |
|         guard let imageData = image.jpegData(compressionQuality: 1.0) else { return }
 | |
|         
 | |
|         let dataSource = DataSourceValue.dataSource(with: imageData, utiType: kUTTypeJPEG as String)
 | |
|         let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: kUTTypeJPEG as String, imageQuality: .medium)
 | |
| 
 | |
|         let approvalVC = AttachmentApprovalViewController.wrappedInNavController(
 | |
|             threadId: self.viewModel.threadData.threadId,
 | |
|             attachments: [ attachment ],
 | |
|             approvalDelegate: self
 | |
|         )
 | |
|         approvalVC.modalPresentationStyle = .fullScreen
 | |
|         
 | |
|         self.present(approvalVC, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     // MARK: --Mentions
 | |
|     
 | |
|     func handleMentionSelected(_ mentionInfo: MentionInfo, from view: MentionSelectionView) {
 | |
|         guard let currentMentionStartIndex = currentMentionStartIndex else { return }
 | |
|         
 | |
|         mentions.append(mentionInfo)
 | |
|         
 | |
|         let newText: String = snInputView.text.replacingCharacters(
 | |
|             in: currentMentionStartIndex...,
 | |
|             with: "@\(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant)) "
 | |
|         )
 | |
|         
 | |
|         snInputView.text = newText
 | |
|         self.currentMentionStartIndex = nil
 | |
|         snInputView.hideMentionsUI()
 | |
|         
 | |
|         mentions = mentions.filter { mentionInfo -> Bool in
 | |
|             newText.contains(mentionInfo.profile.displayName(for: self.viewModel.threadData.threadVariant))
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func updateMentions(for newText: String) {
 | |
|         guard !newText.isEmpty else {
 | |
|             if currentMentionStartIndex != nil {
 | |
|                 snInputView.hideMentionsUI()
 | |
|             }
 | |
|             
 | |
|             resetMentions()
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let lastCharacterIndex = newText.index(before: newText.endIndex)
 | |
|         let lastCharacter = newText[lastCharacterIndex]
 | |
|         
 | |
|         // Check if there is whitespace before the '@' or the '@' is the first character
 | |
|         let isCharacterBeforeLastWhiteSpaceOrStartOfLine: Bool
 | |
|         if newText.count == 1 {
 | |
|             isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
 | |
|         }
 | |
|         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 == "@" { // the lastCharacter == "@" is to check for @@
 | |
|             currentMentionStartIndex = nil
 | |
|             snInputView.hideMentionsUI()
 | |
|         }
 | |
|         else {
 | |
|             if let currentMentionStartIndex = currentMentionStartIndex {
 | |
|                 let query = String(newText[newText.index(after: currentMentionStartIndex)...]) // + 1 to get rid of the @
 | |
|                 snInputView.showMentionsUI(for: self.viewModel.mentions(for: query))
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func resetMentions() {
 | |
|         currentMentionStartIndex = nil
 | |
|         mentions = []
 | |
|     }
 | |
| 
 | |
|     func replaceMentions(in text: String) -> String {
 | |
|         var result = text
 | |
|         for mention in mentions {
 | |
|             guard let range = result.range(of: "@\(mention.profile.displayName(for: mention.threadVariant))") else { continue }
 | |
|             result = result.replacingCharacters(in: range, with: "@\(mention.profile.id)")
 | |
|         }
 | |
|         
 | |
|         return result
 | |
|     }
 | |
|     
 | |
|     func hideInputAccessoryView() {
 | |
|         self.inputAccessoryView?.isHidden = true
 | |
|         self.inputAccessoryView?.alpha = 0
 | |
|     }
 | |
|     
 | |
|     func showInputAccessoryView() {
 | |
|         UIView.animate(withDuration: 0.25, animations: {
 | |
|             self.inputAccessoryView?.isHidden = false
 | |
|             self.inputAccessoryView?.alpha = 1
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     // MARK: MessageCellDelegate
 | |
| 
 | |
|     func handleItemLongPressed(_ cellViewModel: MessageViewModel) {
 | |
|         // Show the context menu if applicable
 | |
|         guard
 | |
|             // FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
 | |
|             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),
 | |
|                 delegate: self
 | |
|             )
 | |
|         else { return }
 | |
|         
 | |
|         /// Lock the contentOffset of the tableView so the transition doesn't look buggy
 | |
|         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 }
 | |
|                     
 | |
|                     // Unlock the contentOffset so everything will be in the right
 | |
|                     // place when we return
 | |
|                     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,
 | |
|         gestureRecognizer: UITapGestureRecognizer,
 | |
|         using dependencies: Dependencies = Dependencies()
 | |
|     ) {
 | |
|         guard cellViewModel.variant != .standardOutgoing || (cellViewModel.state != .failed && cellViewModel.state != .failedToSync) else {
 | |
|             // Show the failed message sheet
 | |
|             showFailedMessageSheet(for: cellViewModel, using: dependencies)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // For call info messages show the "call missed" modal
 | |
|         guard cellViewModel.variant != .infoCall else {
 | |
|             let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(caller: cellViewModel.authorName)
 | |
|             present(callMissedTipsModal, animated: true, completion: nil)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // If it's an incoming media message and the thread isn't trusted then show the placeholder view
 | |
|         if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
 | |
|             let message: String = String(
 | |
|                 format: "modal_download_attachment_explanation".localized(),
 | |
|                 cellViewModel.authorName
 | |
|             )
 | |
|             let confirmationModal: ConfirmationModal = ConfirmationModal(
 | |
|                 info: ConfirmationModal.Info(
 | |
|                     title: String(
 | |
|                         format: "modal_download_attachment_title".localized(),
 | |
|                         cellViewModel.authorName
 | |
|                     ),
 | |
|                     body: .attributedText(
 | |
|                         NSAttributedString(string: message)
 | |
|                             .adding(
 | |
|                                 attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
 | |
|                                 range: (message as NSString).range(of: cellViewModel.authorName)
 | |
|                             )
 | |
|                     ),
 | |
|                     confirmTitle: "modal_download_button_title".localized(),
 | |
|                     confirmAccessibility: Accessibility(identifier: "Download media"),
 | |
|                     cancelAccessibility: Accessibility(identifier: "Don't download media"),
 | |
|                     dismissOnConfirm: false // Custom dismissal logic
 | |
|                 ) { [weak self] _ in
 | |
|                     self?.viewModel.trustContact()
 | |
|                     self?.dismiss(animated: true, completion: nil)
 | |
|                 }
 | |
|             )
 | |
|             
 | |
|             present(confirmationModal, animated: true, completion: nil)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         switch cellViewModel.cellType {
 | |
|             case .audio: viewModel.playOrPauseAudio(for: cellViewModel)
 | |
|             
 | |
|             case .mediaMessage:
 | |
|                 guard
 | |
|                     let sectionIndex: Int = self.viewModel.interactionData
 | |
|                         .firstIndex(where: { $0.model == .messages }),
 | |
|                     let messageIndex: Int = self.viewModel.interactionData[sectionIndex]
 | |
|                         .elements
 | |
|                         .firstIndex(where: { $0.id == cellViewModel.id }),
 | |
|                     let cell = tableView.cellForRow(at: IndexPath(row: messageIndex, section: sectionIndex)) as? VisibleMessageCell,
 | |
|                     let albumView: MediaAlbumView = cell.albumView
 | |
|                 else { return }
 | |
|                 
 | |
|                 let locationInCell: CGPoint = gestureRecognizer.location(in: cell)
 | |
|                 
 | |
|                 // Figure out which of the media views was tapped
 | |
|                 let locationInAlbumView: CGPoint = cell.convert(locationInCell, to: albumView)
 | |
|                 guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
 | |
|                 
 | |
|                 switch mediaView.attachment.state {
 | |
|                     case .pendingDownload, .downloading, .uploading, .invalid: break
 | |
|                     
 | |
|                     // Failed uploads should be handled via the "resend" process instead
 | |
|                     case .failedUpload: break
 | |
|                         
 | |
|                     case .failedDownload:
 | |
|                         let threadId: String = self.viewModel.threadData.threadId
 | |
|                         
 | |
|                         // Retry downloading the failed attachment
 | |
|                         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:
 | |
|                         // Ignore invalid media
 | |
|                         guard mediaView.attachment.isValid else { return }
 | |
|                         
 | |
|                         let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController(
 | |
|                             for: self.viewModel.threadData.threadId,
 | |
|                             threadVariant: self.viewModel.threadData.threadVariant,
 | |
|                             interactionId: cellViewModel.id,
 | |
|                             selectedAttachmentId: mediaView.attachment.id,
 | |
|                             options: [ .sliderEnabled, .showAllMediaButton ]
 | |
|                         )
 | |
|                         
 | |
|                         if let viewController: UIViewController = viewController {
 | |
|                             /// Delay becoming the first responder to make the return transition a little nicer (allows
 | |
|                             /// for the footer on the detail view to slide out rather than instantly vanish)
 | |
|                             self.delayFirstResponder = true
 | |
|                             
 | |
|                             /// Dismiss the input before starting the presentation to make everything look smoother
 | |
|                             self.resignFirstResponder()
 | |
|                             
 | |
|                             /// Delay the actual presentation to give the 'resignFirstResponder' call the chance to complete
 | |
|                             DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
 | |
|                                 /// Lock the contentOffset of the tableView so the transition doesn't look buggy
 | |
|                                 self?.tableView.lockContentOffset = true
 | |
|                                 
 | |
|                                 self?.present(viewController, animated: true) { [weak self] in
 | |
|                                     // Unlock the contentOffset so everything will be in the right
 | |
|                                     // place when we return
 | |
|                                     self?.tableView.lockContentOffset = false
 | |
|                                 }
 | |
|                             }
 | |
|                         }
 | |
|                 }
 | |
|                 
 | |
|             case .genericAttachment:
 | |
|                 guard
 | |
|                     let attachment: Attachment = cellViewModel.attachments?.first,
 | |
|                     let originalFilePath: String = attachment.originalFilePath
 | |
|                 else { return }
 | |
|                 
 | |
|                 let fileUrl: URL = URL(fileURLWithPath: originalFilePath)
 | |
|                 
 | |
|                 // Open a preview of the document for text, pdf or microsoft files
 | |
|                 if
 | |
|                     attachment.isText ||
 | |
|                     attachment.isMicrosoftDoc ||
 | |
|                     attachment.contentType == OWSMimeTypeApplicationPdf
 | |
|                 {
 | |
|                     
 | |
|                     let interactionController: UIDocumentInteractionController = UIDocumentInteractionController(url: fileUrl)
 | |
|                     interactionController.delegate = self
 | |
|                     interactionController.presentPreview(animated: true)
 | |
|                     return
 | |
|                 }
 | |
|                 
 | |
|                 // Otherwise share the file
 | |
|                 let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil)
 | |
|                 
 | |
|                 if UIDevice.current.isIPad {
 | |
|                     shareVC.excludedActivityTypes = []
 | |
|                     shareVC.popoverPresentationController?.permittedArrowDirections = []
 | |
|                     shareVC.popoverPresentationController?.sourceView = self.view
 | |
|                     shareVC.popoverPresentationController?.sourceRect = self.view.bounds
 | |
|                 }
 | |
|                 
 | |
|                 navigationController?.present(shareVC, animated: true, completion: nil)
 | |
|                 
 | |
|             case .textOnlyMessage:
 | |
|                 if let quote: Quote = cellViewModel.quote {
 | |
|                     // Scroll to the original quoted message
 | |
|                     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)
 | |
|                 }
 | |
|                 else if let linkPreview: LinkPreview = cellViewModel.linkPreview {
 | |
|                     switch linkPreview.variant {
 | |
|                         case .standard: openUrl(linkPreview.url)
 | |
|                         case .openGroupInvitation: joinOpenGroup(name: linkPreview.title, url: linkPreview.url)
 | |
|                     }
 | |
|                 }
 | |
|                 
 | |
|             default: break
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) {
 | |
|         switch cellViewModel.cellType {
 | |
|             // The user can double tap a voice message when it's playing to speed it up
 | |
|             case .audio: self.viewModel.speedUpAudio(for: cellViewModel)
 | |
|             default: break
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) {
 | |
|         switch state {
 | |
|             case .began: tableView.isScrollEnabled = false
 | |
|             case .ended, .cancelled: tableView.isScrollEnabled = true
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func openUrl(_ urlString: String) {
 | |
|         guard let url: URL = URL(string: urlString) else { return }
 | |
|         
 | |
|         // URLs can be unsafe, so always ask the user whether they want to open one
 | |
|         let actionSheet: UIAlertController = UIAlertController(
 | |
|             title: "modal_open_url_title".localized(),
 | |
|             message: String(format: "modal_open_url_explanation".localized(), url.absoluteString),
 | |
|             preferredStyle: .actionSheet
 | |
|         )
 | |
|         actionSheet.addAction(UIAlertAction(title: "modal_open_url_button_title".localized(), style: .default) { [weak self] _ in
 | |
|             UIApplication.shared.open(url, options: [:], completionHandler: nil)
 | |
|             self?.showInputAccessoryView()
 | |
|         })
 | |
|         actionSheet.addAction(UIAlertAction(title: "modal_copy_url_button_title".localized(), style: .default) { [weak self] _ in
 | |
|             UIPasteboard.general.string = url.absoluteString
 | |
|             self?.showInputAccessoryView()
 | |
|         })
 | |
|         actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel) { [weak self] _ in
 | |
|             self?.showInputAccessoryView()
 | |
|         })
 | |
|         
 | |
|         Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
 | |
|         self.present(actionSheet, animated: true)
 | |
|     }
 | |
|     
 | |
|     func handleReplyButtonTapped(for cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         reply(cellViewModel, using: dependencies)
 | |
|     }
 | |
|     
 | |
|     func startThread(with sessionId: String, openGroupServer: String?, openGroupPublicKey: String?) {
 | |
|         guard viewModel.threadData.canWrite else { return }
 | |
|         // FIXME: Add in support for starting a thread with a 'blinded25' id
 | |
|         guard SessionId.Prefix(from: sessionId) != .blinded25 else { return }
 | |
|         guard SessionId.Prefix(from: sessionId) == .blinded15 else {
 | |
|             Storage.shared.write { db in
 | |
|                 try SessionThread
 | |
|                     .fetchOrCreate(db, id: sessionId, variant: .contact, shouldBeVisible: nil)
 | |
|             }
 | |
|             
 | |
|             let conversationVC: ConversationVC = ConversationVC(threadId: sessionId, threadVariant: .contact)
 | |
|                 
 | |
|             self.navigationController?.pushViewController(conversationVC, animated: true)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // If the sessionId is blinded then check if there is an existing un-blinded thread with the contact
 | |
|         // and use that, otherwise just use the blinded id
 | |
|         guard let openGroupServer: String = openGroupServer, let openGroupPublicKey: String = openGroupPublicKey else {
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let targetThreadId: String? = Storage.shared.write { db in
 | |
|             let lookup: BlindedIdLookup = try BlindedIdLookup
 | |
|                 .fetchOrCreate(
 | |
|                     db,
 | |
|                     blindedId: sessionId,
 | |
|                     openGroupServer: openGroupServer,
 | |
|                     openGroupPublicKey: openGroupPublicKey,
 | |
|                     isCheckingForOutbox: false
 | |
|                 )
 | |
|             
 | |
|             return try SessionThread
 | |
|                 .fetchOrCreate(
 | |
|                     db,
 | |
|                     id: (lookup.sessionId ?? lookup.blindedId),
 | |
|                     variant: .contact,
 | |
|                     shouldBeVisible: nil
 | |
|                 )
 | |
|                 .id
 | |
|         }
 | |
|         
 | |
|         guard let threadId: String = targetThreadId else { return }
 | |
|         
 | |
|         let conversationVC: ConversationVC = ConversationVC(threadId: threadId, threadVariant: .contact)
 | |
|         self.navigationController?.pushViewController(conversationVC, animated: true)
 | |
|     }
 | |
|     
 | |
|     func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) {
 | |
|         guard
 | |
|             cellViewModel.reactionInfo?.isEmpty == false &&
 | |
|             (
 | |
|                 self.viewModel.threadData.threadVariant == .legacyGroup ||
 | |
|                 self.viewModel.threadData.threadVariant == .group ||
 | |
|                 self.viewModel.threadData.threadVariant == .community
 | |
|             ),
 | |
|             let allMessages: [MessageViewModel] = self.viewModel.interactionData
 | |
|                 .first(where: { $0.model == .messages })?
 | |
|                 .elements
 | |
|         else { return }
 | |
|         
 | |
|         let reactionListSheet: ReactionListSheet = ReactionListSheet(for: cellViewModel.id) { [weak self] in
 | |
|             self?.currentReactionListSheet = nil
 | |
|         }
 | |
|         reactionListSheet.delegate = self
 | |
|         reactionListSheet.handleInteractionUpdates(
 | |
|             allMessages,
 | |
|             selectedReaction: selectedReaction,
 | |
|             initialLoad: true,
 | |
|             shouldShowClearAllButton: OpenGroupManager.isUserModeratorOrAdmin(
 | |
|                 self.viewModel.threadData.currentUserPublicKey,
 | |
|                 for: self.viewModel.threadData.openGroupRoomToken,
 | |
|                 on: self.viewModel.threadData.openGroupServer
 | |
|             )
 | |
|         )
 | |
|         reactionListSheet.modalPresentationStyle = .overFullScreen
 | |
|         present(reactionListSheet, animated: true, completion: nil)
 | |
|         
 | |
|         // Store so we can updated the content based on the current VC
 | |
|         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 -> (OpenGroupAPI.PreparedSendData<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 sendData: OpenGroupAPI.PreparedSendData<OpenGroupAPI.ReactionRemoveAllResponse> = try OpenGroupAPI
 | |
|                     .preparedReactionDeleteAll(
 | |
|                         db,
 | |
|                         emoji: emoji,
 | |
|                         id: openGroupServerMessageId,
 | |
|                         in: openGroup.roomToken,
 | |
|                         on: openGroup.server
 | |
|                     )
 | |
|                 let pendingChange: OpenGroupAPI.PendingChange = OpenGroupManager
 | |
|                     .addPendingReaction(
 | |
|                         emoji: emoji,
 | |
|                         id: openGroupServerMessageId,
 | |
|                         in: openGroup.roomToken,
 | |
|                         on: openGroup.server,
 | |
|                         type: .removeAll
 | |
|                     )
 | |
|                 
 | |
|                 return (sendData, pendingChange)
 | |
|             }
 | |
|             .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|             .flatMap { sendData, pendingChange in
 | |
|                 OpenGroupAPI.send(data: sendData)
 | |
|                     .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 }
 | |
|         
 | |
|         // Perform local rate limiting (don't allow more than 20 reactions within 60 seconds)
 | |
|         let threadVariant: SessionThread.Variant = self.viewModel.threadData.threadVariant
 | |
|         let openGroupRoom: String? = self.viewModel.threadData.openGroupRoomToken
 | |
|         let sentTimestamp: Int64 = SnodeAPI.currentOffsetTimestampMs()
 | |
|         let recentReactionTimestamps: [Int64] = dependencies.caches[.general].recentReactionTimestamps
 | |
|         
 | |
|         guard
 | |
|             recentReactionTimestamps.count < 20 ||
 | |
|             (sentTimestamp - (recentReactionTimestamps.first ?? sentTimestamp)) > (60 * 1000)
 | |
|         else {
 | |
|             let toastController: ToastController = ToastController(
 | |
|                 text: "EMOJI_REACTS_RATE_LIMIT_TOAST".localized(),
 | |
|                 background: .backgroundSecondary
 | |
|             )
 | |
|             toastController.presentToastView(
 | |
|                 fromBottomOfView: self.view,
 | |
|                 inset: (snInputView.bounds.height + Values.largeSpacing),
 | |
|                 duration: .milliseconds(2500)
 | |
|             )
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         dependencies.caches.mutate(cache: .general) {
 | |
|             $0.recentReactionTimestamps = Array($0.recentReactionTimestamps
 | |
|                 .suffix(19))
 | |
|                 .appending(sentTimestamp)
 | |
|         }
 | |
|         
 | |
|         typealias OpenGroupInfo = (
 | |
|             pendingReaction: Reaction?,
 | |
|             pendingChange: OpenGroupAPI.PendingChange,
 | |
|             sendData: OpenGroupAPI.PreparedSendData<Int64?>
 | |
|         )
 | |
|         
 | |
|         /// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup
 | |
|         /// cache from blocking either the main thread or the database write thread
 | |
|         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)) }
 | |
|                   
 | |
|                 // Create the pending change if we have open group info
 | |
|                 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
 | |
|                 // Update the thread to be visible (if it isn't already)
 | |
|                 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
 | |
|                     )
 | |
|                 }()
 | |
|                 
 | |
|                 // Update the database
 | |
|                 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)
 | |
|                     
 | |
|                     // Add it to the recent list
 | |
|                     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 sendData: OpenGroupAPI.PreparedSendData<Int64?> = try {
 | |
|                             guard !remove else {
 | |
|                                 return try OpenGroupAPI
 | |
|                                     .preparedReactionDelete(
 | |
|                                         db,
 | |
|                                         emoji: emoji,
 | |
|                                         id: serverMessageId,
 | |
|                                         in: openGroupRoom,
 | |
|                                         on: openGroupServer
 | |
|                                     )
 | |
|                                     .map { _, response in response.seqNo }
 | |
|                             }
 | |
|                             
 | |
|                             return try OpenGroupAPI
 | |
|                                 .preparedReactionAdd(
 | |
|                                     db,
 | |
|                                     emoji: emoji,
 | |
|                                     id: serverMessageId,
 | |
|                                     in: openGroupRoom,
 | |
|                                     on: openGroupServer
 | |
|                                 )
 | |
|                                 .map { _, response in response.seqNo }
 | |
|                         }()
 | |
|                         
 | |
|                         return (nil, (pendingReaction, pendingChange, sendData))
 | |
|                         
 | |
|                     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 OpenGroupAPI.send(data: info.sendData)
 | |
|                         .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
 | |
|             // Reverse the database
 | |
|             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: --action handling
 | |
|     
 | |
|     private func showFailedMessageSheet(for cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         let sheet = UIAlertController(
 | |
|             title: (cellViewModel.state == .failedToSync ?
 | |
|                 "MESSAGE_DELIVERY_FAILED_SYNC_TITLE".localized() :
 | |
|                 "MESSAGE_DELIVERY_FAILED_TITLE".localized()
 | |
|             ),
 | |
|             message: cellViewModel.mostRecentFailureText,
 | |
|             preferredStyle: .actionSheet
 | |
|         )
 | |
|         sheet.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
 | |
|         
 | |
|         if cellViewModel.state != .failedToSync {
 | |
|             sheet.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive, handler: { _ in
 | |
|                 Storage.shared.writeAsync { db in
 | |
|                     try Interaction
 | |
|                         .filter(id: cellViewModel.id)
 | |
|                         .deleteAll(db)
 | |
|                 }
 | |
|             }))
 | |
|         }
 | |
|         
 | |
|         sheet.addAction(UIAlertAction(
 | |
|             title: (cellViewModel.state == .failedToSync ?
 | |
|                 "context_menu_resync".localized() :
 | |
|                 "context_menu_resend".localized()
 | |
|             ),
 | |
|             style: .default,
 | |
|             handler: { [weak self] _ in self?.retry(cellViewModel, using: dependencies) }
 | |
|         ))
 | |
|         
 | |
|         // HACK: Extracting this info from the error string is pretty dodgy
 | |
|         let prefix: String = "HTTP request failed at destination (Service node "
 | |
|         if let mostRecentFailureText: String = cellViewModel.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) {
 | |
|             let rest = mostRecentFailureText.substring(from: prefix.count)
 | |
|             
 | |
|             if let index = rest.firstIndex(of: ")") {
 | |
|                 let snodeAddress = String(rest[rest.startIndex..<index])
 | |
|                 
 | |
|                 sheet.addAction(UIAlertAction(title: "Copy Service Node Info", style: .default) { _ in
 | |
|                     UIPasteboard.general.string = snodeAddress
 | |
|                 })
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         Modal.setupForIPadIfNeeded(sheet, targetView: self.view)
 | |
|         present(sheet, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func joinOpenGroup(name: String?, url: String) {
 | |
|         // Open groups can be unsafe, so always ask the user whether they want to join one
 | |
|         let finalName: String = (name ?? "Open Group")
 | |
|         let message: String = "Are you sure you want to join the \(finalName) open group?";
 | |
|         let modal: ConfirmationModal = ConfirmationModal(
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: "Join \(finalName)?",
 | |
|                 body: .attributedText(
 | |
|                     NSMutableAttributedString(string: message)
 | |
|                         .adding(
 | |
|                             attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ],
 | |
|                             range: (message as NSString).range(of: finalName)
 | |
|                         )
 | |
|                 ),
 | |
|                 confirmTitle: "JOIN_COMMUNITY_BUTTON_TITLE".localized(),
 | |
|                 onConfirm: { modal in
 | |
|                     guard let presentingViewController: UIViewController = modal.presentingViewController else {
 | |
|                         return
 | |
|                     }
 | |
|                     
 | |
|                     guard let (room, server, publicKey) = SessionUtil.parseCommunity(url: url) else {
 | |
|                         let errorModal: ConfirmationModal = ConfirmationModal(
 | |
|                             info: ConfirmationModal.Info(
 | |
|                                 title: "COMMUNITY_ERROR_GENERIC".localized(),
 | |
|                                 cancelTitle: "BUTTON_OK".localized(),
 | |
|                                 cancelStyle: .alert_text
 | |
|                             )
 | |
|                         )
 | |
|                         
 | |
|                         return presentingViewController.present(errorModal, animated: true, completion: nil)
 | |
|                     }
 | |
|                     
 | |
|                     Storage.shared
 | |
|                         .writePublisher { db in
 | |
|                             OpenGroupManager.shared.add(
 | |
|                                 db,
 | |
|                                 roomToken: room,
 | |
|                                 server: server,
 | |
|                                 publicKey: publicKey,
 | |
|                                 calledFromConfigHandling: false
 | |
|                             )
 | |
|                         }
 | |
|                         .flatMap { successfullyAddedGroup in
 | |
|                             OpenGroupManager.shared.performInitialRequestsAfterAdd(
 | |
|                                 successfullyAddedGroup: successfullyAddedGroup,
 | |
|                                 roomToken: room,
 | |
|                                 server: server,
 | |
|                                 publicKey: publicKey,
 | |
|                                 calledFromConfigHandling: false
 | |
|                             )
 | |
|                         }
 | |
|                         .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|                         .receive(on: DispatchQueue.main)
 | |
|                         .sinkUntilComplete(
 | |
|                             receiveCompletion: { result in
 | |
|                                 switch result {
 | |
|                                     case .finished: break
 | |
|                                     case .failure(let error):
 | |
|                                         // If there was a failure then the group will be in invalid state until
 | |
|                                         // the next launch so remove it (the user will be left on the previous
 | |
|                                         // screen so can re-trigger the join)
 | |
|                                         Storage.shared.writeAsync { db in
 | |
|                                             OpenGroupManager.shared.delete(
 | |
|                                                 db,
 | |
|                                                 openGroupId: OpenGroup.idFor(roomToken: room, server: server),
 | |
|                                                 calledFromConfigHandling: false
 | |
|                                             )
 | |
|                                         }
 | |
|                                         
 | |
|                                         // Show the user an error indicating they failed to properly join the group
 | |
|                                         let errorModal: ConfirmationModal = ConfirmationModal(
 | |
|                                             info: ConfirmationModal.Info(
 | |
|                                                 title: "COMMUNITY_ERROR_GENERIC".localized(),
 | |
|                                                 body: .text(error.localizedDescription),
 | |
|                                                 cancelTitle: "BUTTON_OK".localized(),
 | |
|                                                 cancelStyle: .alert_text
 | |
|                                             )
 | |
|                                         )
 | |
|                                         
 | |
|                                         presentingViewController.present(errorModal, animated: true, completion: nil)
 | |
|                                 }
 | |
|                             }
 | |
|                         )
 | |
|                 }
 | |
|             )
 | |
|         )
 | |
|         
 | |
|         present(modal, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     // MARK: - ContextMenuActionDelegate
 | |
|     
 | |
|     func info(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         let mediaInfoVC = MediaInfoVC(
 | |
|             attachments: (cellViewModel.attachments ?? []),
 | |
|             isOutgoing: (cellViewModel.variant == .standardOutgoing),
 | |
|             threadId: self.viewModel.threadData.threadId,
 | |
|             threadVariant: self.viewModel.threadData.threadVariant,
 | |
|             interactionId: cellViewModel.id
 | |
|         )
 | |
|         navigationController?.pushViewController(mediaInfoVC, 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 {
 | |
|                 // Show an error for the retry
 | |
|                 let modal: ConfirmationModal = ConfirmationModal(
 | |
|                     info: ConfirmationModal.Info(
 | |
|                         title: "ALERT_ERROR_TITLE".localized(),
 | |
|                         body: .text("FAILED_TO_STORE_OUTGOING_MESSAGE".localized()),
 | |
|                         cancelTitle: "BUTTON_OK".localized(),
 | |
|                         cancelStyle: .alert_text
 | |
|                     )
 | |
|                 )
 | |
|                 
 | |
|                 self.present(modal, animated: true, completion: nil)
 | |
|                 return
 | |
|             }
 | |
|             
 | |
|             // Try to send the optimistic message again
 | |
|             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)
 | |
|             }
 | |
|             
 | |
|             // Remove message sending jobs for the same interaction in database
 | |
|             // Prevent the same message being sent twice
 | |
|             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, .genericAttachment, .mediaMessage:
 | |
|                 guard
 | |
|                     cellViewModel.attachments?.count == 1,
 | |
|                     let attachment: Attachment = cellViewModel.attachments?.first,
 | |
|                     attachment.isValid,
 | |
|                     (
 | |
|                         attachment.state == .downloaded ||
 | |
|                         attachment.state == .uploaded
 | |
|                     ),
 | |
|                     let utiType: String = MIMETypeUtil.utiType(forMIMEType: attachment.contentType),
 | |
|                     let originalFilePath: String = attachment.originalFilePath,
 | |
|                     let data: Data = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath))
 | |
|                 else { return }
 | |
|             
 | |
|                 UIPasteboard.general.setData(data, forPasteboardType: utiType)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func copySessionID(_ cellViewModel: MessageViewModel) {
 | |
|         guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardIncomingDeleted else {
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         UIPasteboard.general.string = cellViewModel.authorId
 | |
|     }
 | |
| 
 | |
|     func delete(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         switch cellViewModel.variant {
 | |
|             case .standardIncomingDeleted, .infoCall,
 | |
|                 .infoScreenshotNotification, .infoMediaSavedNotification,
 | |
|                 .infoClosedGroupCreated, .infoClosedGroupUpdated,
 | |
|                 .infoClosedGroupCurrentUserLeft, .infoClosedGroupCurrentUserLeaving, .infoClosedGroupCurrentUserErrorLeaving,
 | |
|                 .infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
 | |
|                 // Info messages and unsent messages should just trigger a local
 | |
|                 // deletion (they are created as side effects so we wouldn't be
 | |
|                 // able to delete them for all participants anyway)
 | |
|                 Storage.shared.writeAsync { db in
 | |
|                     _ = try Interaction
 | |
|                         .filter(id: cellViewModel.id)
 | |
|                         .deleteAll(db)
 | |
|                 }
 | |
|                 return
 | |
|                 
 | |
|             case .standardOutgoing, .standardIncoming: break
 | |
|         }
 | |
|         
 | |
|         let threadName: String = self.viewModel.threadData.displayName
 | |
|         let userPublicKey: String = getUserHexEncodedPublicKey()
 | |
|         
 | |
|         // Remote deletion logic
 | |
|         func deleteRemotely(from viewController: UIViewController?, request: AnyPublisher<Void, Error>, onComplete: (() -> ())?) {
 | |
|             // Show a loading indicator
 | |
|             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:
 | |
|                             // Delete the interaction (and associated data) from the database
 | |
|                             Storage.shared.writeAsync { db in
 | |
|                                 _ = try Interaction
 | |
|                                     .filter(id: cellViewModel.id)
 | |
|                                     .deleteAll(db)
 | |
|                             }
 | |
|                     }
 | |
|                     
 | |
|                     // Regardless of success we should dismiss and callback
 | |
|                     if self?.presentedViewController is ModalActivityIndicatorViewController {
 | |
|                         self?.dismiss(animated: true, completion: nil) // Dismiss the loader
 | |
|                     }
 | |
|                     
 | |
|                     onComplete?()
 | |
|                 }
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // How we delete the message differs depending on the type of thread
 | |
|         switch cellViewModel.threadVariant {
 | |
|             // Handle open group messages the old way
 | |
|             case .community:
 | |
|                 // If it's an incoming message the user must have moderator status
 | |
|                 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 {
 | |
|                     // If the message hasn't been sent yet then just delete locally
 | |
|                     guard cellViewModel.state == .sending || cellViewModel.state == .failed else { return }
 | |
|                     
 | |
|                     // Retrieve any message send jobs for this interaction
 | |
|                     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: [])
 | |
|                     
 | |
|                     // If the job is currently running then wait until it's done before triggering
 | |
|                     // the deletion
 | |
|                     let targetJob: Job? = jobs.first(where: { JobRunner.isCurrentlyRunning($0) })
 | |
|                     
 | |
|                     guard targetJob == nil else {
 | |
|                         JobRunner.afterCurrentlyRunningJob(targetJob) { [weak self] result in
 | |
|                             switch result {
 | |
|                                 // If it succeeded then we'll need to delete from the server so re-run
 | |
|                                 // this function (if we still don't have the server id for some reason
 | |
|                                 // then this would result in a local-only deletion which should be fine
 | |
|                                 case .succeeded: self?.delete(cellViewModel)
 | |
|                                     
 | |
|                                 // Otherwise we just need to cancel the pending job (in case it retries)
 | |
|                                 // and delete the interaction
 | |
|                                 default:
 | |
|                                     JobRunner.removePendingJob(targetJob)
 | |
|                                     
 | |
|                                     Storage.shared.writeAsync { db in
 | |
|                                         _ = try Interaction
 | |
|                                             .filter(id: cellViewModel.id)
 | |
|                                             .deleteAll(db)
 | |
|                                     }
 | |
|                             }
 | |
|                         }
 | |
|                         return
 | |
|                     }
 | |
|                     
 | |
|                     // If it's not currently running then remove any pending jobs (just to be safe) and
 | |
|                     // delete the interaction locally
 | |
|                     jobs.forEach { JobRunner.removePendingJob($0) }
 | |
|                     
 | |
|                     Storage.shared.writeAsync { db in
 | |
|                         _ = try Interaction
 | |
|                             .filter(id: cellViewModel.id)
 | |
|                             .deleteAll(db)
 | |
|                     }
 | |
|                     return
 | |
|                 }
 | |
|                 
 | |
|                 // Delete the message from the open group
 | |
|                 deleteRemotely(
 | |
|                     from: self,
 | |
|                     request: Storage.shared
 | |
|                         .readPublisher { db in
 | |
|                             try OpenGroupAPI.preparedMessageDelete(
 | |
|                                 db,
 | |
|                                 id: openGroupServerMessageId,
 | |
|                                 in: openGroup.roomToken,
 | |
|                                 on: openGroup.server
 | |
|                             )
 | |
|                         }
 | |
|                         .flatMap { OpenGroupAPI.send(data: $0) }
 | |
|                         .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
 | |
|                     )
 | |
|                 )
 | |
|                 
 | |
|                 // For incoming interactions or interactions with no serverHash just delete them locally
 | |
|                 guard cellViewModel.variant == .standardOutgoing, let serverHash: String = serverHash else {
 | |
|                     Storage.shared.writeAsync { db in
 | |
|                         _ = try Interaction
 | |
|                             .filter(id: cellViewModel.id)
 | |
|                             .deleteAll(db)
 | |
|                         
 | |
|                         // No need to send the unsendRequest if there is no serverHash (ie. the message
 | |
|                         // was outgoing but never got to the server)
 | |
|                         guard serverHash != nil else { return }
 | |
|                         
 | |
|                         MessageSender
 | |
|                             .send(
 | |
|                                 db,
 | |
|                                 message: unsendRequest,
 | |
|                                 threadId: cellViewModel.threadId,
 | |
|                                 interactionId: nil,
 | |
|                                 to: .contact(publicKey: userPublicKey),
 | |
|                                 using: dependencies
 | |
|                             )
 | |
|                     }
 | |
|                     return
 | |
|                 }
 | |
|                 
 | |
|                 let actionSheet: UIAlertController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet)
 | |
|                 actionSheet.addAction(UIAlertAction(
 | |
|                     title: "delete_message_for_me".localized(),
 | |
|                     accessibilityIdentifier: "Delete for me",
 | |
|                     style: .destructive
 | |
|                 ) { [weak self] _ in
 | |
|                     Storage.shared.writeAsync { db in
 | |
|                         _ = try Interaction
 | |
|                             .filter(id: cellViewModel.id)
 | |
|                             .deleteAll(db)
 | |
|                         
 | |
|                         MessageSender
 | |
|                             .send(
 | |
|                                 db,
 | |
|                                 message: unsendRequest,
 | |
|                                 threadId: cellViewModel.threadId,
 | |
|                                 interactionId: nil,
 | |
|                                 to: .contact(publicKey: userPublicKey),
 | |
|                                 using: dependencies
 | |
|                             )
 | |
|                     }
 | |
|                     self?.showInputAccessoryView()
 | |
|                 })
 | |
|                 
 | |
|                 actionSheet.addAction(UIAlertAction(
 | |
|                     title: {
 | |
|                         switch cellViewModel.threadVariant {
 | |
|                             case .legacyGroup, .group: return "delete_message_for_everyone".localized()
 | |
|                             default:
 | |
|                                 return (cellViewModel.threadId == userPublicKey ?
 | |
|                                     "delete_message_for_me_and_my_devices".localized() :
 | |
|                                     String(format: "delete_message_for_me_and_recipient".localized(), threadName)
 | |
|                                 )
 | |
|                         }
 | |
|                     }(),
 | |
|                     accessibilityIdentifier: "Delete for everyone",
 | |
|                     style: .destructive
 | |
|                 ) { [weak self] _ in
 | |
|                     let completeServerDeletion = { [weak self] in
 | |
|                         Storage.shared.writeAsync { db in
 | |
|                             try MessageSender
 | |
|                                 .send(
 | |
|                                     db,
 | |
|                                     message: unsendRequest,
 | |
|                                     interactionId: nil,
 | |
|                                     threadId: cellViewModel.threadId,
 | |
|                                     threadVariant: cellViewModel.threadVariant,
 | |
|                                     using: dependencies
 | |
|                                 )
 | |
|                         }
 | |
|                         
 | |
|                         self?.showInputAccessoryView()
 | |
|                     }
 | |
|                     
 | |
|                     // We can only delete messages on the server for `contact` and `group` conversations
 | |
|                     guard cellViewModel.threadVariant == .contact || cellViewModel.threadVariant == .group else {
 | |
|                         return completeServerDeletion()
 | |
|                     }
 | |
|                     
 | |
|                     deleteRemotely(
 | |
|                         from: self,
 | |
|                         request: SnodeAPI
 | |
|                             .deleteMessages(
 | |
|                                 publicKey: targetPublicKey,
 | |
|                                 serverHashes: [serverHash]
 | |
|                             )
 | |
|                             .map { _ in () }
 | |
|                             .eraseToAnyPublisher()
 | |
|                     ) { completeServerDeletion() }
 | |
|                 })
 | |
| 
 | |
|                 actionSheet.addAction(UIAlertAction.init(title: "TXT_CANCEL_TITLE".localized(), style: .cancel) { [weak self] _ in
 | |
|                     self?.showInputAccessoryView()
 | |
|                 })
 | |
| 
 | |
|                 self.hideInputAccessoryView()
 | |
|                 Modal.setupForIPadIfNeeded(actionSheet, targetView: self.view)
 | |
|                 self.present(actionSheet, animated: true)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func save(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         guard cellViewModel.cellType == .mediaMessage else { return }
 | |
|         
 | |
|         let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? [])
 | |
|             .filter { attachment in
 | |
|                 attachment.isValid &&
 | |
|                 attachment.isVisualMedia && (
 | |
|                     attachment.state == .downloaded ||
 | |
|                     attachment.state == .uploaded
 | |
|                 )
 | |
|             }
 | |
|             .compactMap { attachment in
 | |
|                 guard let originalFilePath: String = attachment.originalFilePath else { return nil }
 | |
|                 
 | |
|                 return (attachment, originalFilePath)
 | |
|             }
 | |
|         
 | |
|         guard !mediaAttachments.isEmpty else { return }
 | |
|     
 | |
|         mediaAttachments.forEach { attachment, originalFilePath in
 | |
|             PHPhotoLibrary.shared().performChanges(
 | |
|                 {
 | |
|                     if attachment.isImage || attachment.isAnimated {
 | |
|                         PHAssetChangeRequest.creationRequestForAssetFromImage(
 | |
|                             atFileURL: URL(fileURLWithPath: originalFilePath)
 | |
|                         )
 | |
|                     }
 | |
|                     else if attachment.isVideo {
 | |
|                         PHAssetChangeRequest.creationRequestForAssetFromVideo(
 | |
|                             atFileURL: URL(fileURLWithPath: originalFilePath)
 | |
|                         )
 | |
|                     }
 | |
|                 },
 | |
|                 completionHandler: { _, _ in }
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // Send a 'media saved' notification if needed
 | |
|         guard self.viewModel.threadData.threadVariant == .contact, cellViewModel.variant == .standardIncoming else {
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)))
 | |
|     }
 | |
| 
 | |
|     func ban(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         guard cellViewModel.threadVariant == .community else { return }
 | |
|         
 | |
|         let threadId: String = self.viewModel.threadData.threadId
 | |
|         let modal: ConfirmationModal = ConfirmationModal(
 | |
|             targetView: self.view,
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: "Session",
 | |
|                 body: .text("This will ban the selected user from this room. It won't ban them from other rooms."),
 | |
|                 confirmTitle: "BUTTON_OK".localized(),
 | |
|                 cancelStyle: .alert_text,
 | |
|                 onConfirm: { [weak self] _ in
 | |
|                     Storage.shared
 | |
|                         .readPublisher { db -> OpenGroupAPI.PreparedSendData<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
 | |
|                                 )
 | |
|                         }
 | |
|                         .flatMap { OpenGroupAPI.send(data: $0) }
 | |
|                         .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|                         .receive(on: DispatchQueue.main)
 | |
|                         .sinkUntilComplete(
 | |
|                             receiveCompletion: { result in
 | |
|                                 switch result {
 | |
|                                     case .finished: break
 | |
|                                     case .failure:
 | |
|                                         let modal: ConfirmationModal = ConfirmationModal(
 | |
|                                             targetView: self?.view,
 | |
|                                             info: ConfirmationModal.Info(
 | |
|                                                 title: CommonStrings.errorAlertTitle,
 | |
|                                                 body: .text("context_menu_ban_user_error_alert_message".localized()),
 | |
|                                                 cancelTitle: "BUTTON_OK".localized(),
 | |
|                                                 cancelStyle: .alert_text
 | |
|                                             )
 | |
|                                         )
 | |
|                                         self?.present(modal, animated: true)
 | |
|                                 }
 | |
|                             }
 | |
|                         )
 | |
|                     
 | |
|                     self?.becomeFirstResponder()
 | |
|                 },
 | |
|                 afterClosed: { [weak self] in self?.becomeFirstResponder() }
 | |
|             )
 | |
|         )
 | |
|         self.present(modal, animated: true)
 | |
|     }
 | |
| 
 | |
|     func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) {
 | |
|         guard cellViewModel.threadVariant == .community else { return }
 | |
|         
 | |
|         let threadId: String = self.viewModel.threadData.threadId
 | |
|         let modal: ConfirmationModal = ConfirmationModal(
 | |
|             targetView: self.view,
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: "Session",
 | |
|                 body: .text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there."),
 | |
|                 confirmTitle: "BUTTON_OK".localized(),
 | |
|                 cancelStyle: .alert_text,
 | |
|                 onConfirm: { [weak self] _ in
 | |
|                     Storage.shared
 | |
|                         .readPublisher { db in
 | |
|                             guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else {
 | |
|                                 throw StorageError.objectNotFound
 | |
|                             }
 | |
|                         
 | |
|                             return try OpenGroupAPI
 | |
|                                 .preparedUserBanAndDeleteAllMessages(
 | |
|                                     db,
 | |
|                                     sessionId: cellViewModel.authorId,
 | |
|                                     in: openGroup.roomToken,
 | |
|                                     on: openGroup.server
 | |
|                                 )
 | |
|                         }
 | |
|                         .flatMap { OpenGroupAPI.send(data: $0) }
 | |
|                         .subscribe(on: DispatchQueue.global(qos: .userInitiated))
 | |
|                         .receive(on: DispatchQueue.main)
 | |
|                         .sinkUntilComplete(
 | |
|                             receiveCompletion: { result in
 | |
|                                 switch result {
 | |
|                                     case .finished: break
 | |
|                                     case .failure:
 | |
|                                         let modal: ConfirmationModal = ConfirmationModal(
 | |
|                                             targetView: self?.view,
 | |
|                                             info: ConfirmationModal.Info(
 | |
|                                                 title: CommonStrings.errorAlertTitle,
 | |
|                                                 body: .text("context_menu_ban_user_error_alert_message".localized()),
 | |
|                                                 cancelTitle: "BUTTON_OK".localized(),
 | |
|                                                 cancelStyle: .alert_text
 | |
|                                             )
 | |
|                                         )
 | |
|                                         self?.present(modal, animated: true)
 | |
|                                 }
 | |
|                             }
 | |
|                         )
 | |
|                     
 | |
|                     self?.becomeFirstResponder()
 | |
|                 },
 | |
|                 afterClosed: { [weak self] in self?.becomeFirstResponder() }
 | |
|             )
 | |
|         )
 | |
|         self.present(modal, animated: true)
 | |
|     }
 | |
| 
 | |
|     // MARK: - VoiceMessageRecordingViewDelegate
 | |
| 
 | |
|     func startVoiceMessageRecording(using dependencies: Dependencies) {
 | |
|         // Request permission if needed
 | |
|         Permissions.requestMicrophonePermissionIfNeeded() { [weak self] in
 | |
|             DispatchQueue.main.async {
 | |
|                 self?.cancelVoiceMessageRecording()
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         // Keep screen on
 | |
|         UIApplication.shared.isIdleTimerDisabled = false
 | |
|         guard AVAudioSession.sharedInstance().recordPermission == .granted else { return }
 | |
|         
 | |
|         // Cancel any current audio playback
 | |
|         self.viewModel.stopAudio()
 | |
|         
 | |
|         // Create URL
 | |
|         let directory: String = OWSTemporaryDirectory()
 | |
|         let fileName: String = "\(SnodeAPI.currentOffsetTimestampMs()).m4a"
 | |
|         let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName)
 | |
|         
 | |
|         // Set up audio session
 | |
|         let isConfigured = (Environment.shared?.audioSession.startAudioActivity(recordVoiceMessageActivity) == true)
 | |
|         guard isConfigured else {
 | |
|             return cancelVoiceMessageRecording()
 | |
|         }
 | |
|         
 | |
|         // Set up audio recorder
 | |
|         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()
 | |
|         }
 | |
|         
 | |
|         // Limit voice messages to a minute
 | |
|         audioTimer = Timer.scheduledTimer(withTimeInterval: 180, repeats: false, block: { [weak self] _ in
 | |
|             self?.snInputView.hideVoiceMessageUI()
 | |
|             self?.endVoiceMessageRecording(using: dependencies)
 | |
|         })
 | |
|         
 | |
|         // Prepare audio recorder
 | |
|         guard audioRecorder.prepareToRecord() else {
 | |
|             SNLog("Couldn't prepare audio recorder.")
 | |
|             return cancelVoiceMessageRecording()
 | |
|         }
 | |
|         
 | |
|         // Start recording
 | |
|         guard audioRecorder.record() else {
 | |
|             SNLog("Couldn't record audio.")
 | |
|             return cancelVoiceMessageRecording()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func endVoiceMessageRecording(using dependencies: Dependencies) {
 | |
|         UIApplication.shared.isIdleTimerDisabled = true
 | |
|         
 | |
|         // Hide the UI
 | |
|         snInputView.hideVoiceMessageUI()
 | |
|         
 | |
|         // Cancel the timer
 | |
|         audioTimer?.invalidate()
 | |
|         
 | |
|         // Check preconditions
 | |
|         guard let audioRecorder = audioRecorder else { return }
 | |
|         
 | |
|         // Get duration
 | |
|         let duration = audioRecorder.currentTime
 | |
|         
 | |
|         // Stop the recording
 | |
|         stopVoiceMessageRecording()
 | |
|         
 | |
|         // Check for user misunderstanding
 | |
|         guard duration > 1 else {
 | |
|             self.audioRecorder = nil
 | |
|             
 | |
|             let modal: ConfirmationModal = ConfirmationModal(
 | |
|                 targetView: self.view,
 | |
|                 info: ConfirmationModal.Info(
 | |
|                     title: "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized(),
 | |
|                     body: .text("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized()),
 | |
|                     cancelTitle: "BUTTON_OK".localized(),
 | |
|                     cancelStyle: .alert_text
 | |
|                 )
 | |
|             )
 | |
|             self.present(modal, animated: true)
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Get data
 | |
|         let dataSourceOrNil = DataSourcePath.dataSource(with: audioRecorder.url, shouldDeleteOnDeallocation: true)
 | |
|         self.audioRecorder = nil
 | |
|         
 | |
|         guard let dataSource = dataSourceOrNil else { return SNLog("Couldn't load recorded data.") }
 | |
|         
 | |
|         // Create attachment
 | |
|         let fileName = ("VOICE_MESSAGE_FILE_NAME".localized() as NSString).appendingPathExtension("m4a")
 | |
|         dataSource.sourceFilename = fileName
 | |
|         
 | |
|         let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String)
 | |
|         
 | |
|         guard !attachment.hasError else {
 | |
|             return showErrorAlert(for: attachment)
 | |
|         }
 | |
|         
 | |
|         // Send attachment
 | |
|         sendMessage(text: "", attachments: [attachment], using: dependencies)
 | |
|     }
 | |
| 
 | |
|     func cancelVoiceMessageRecording() {
 | |
|         snInputView.hideVoiceMessageUI()
 | |
|         audioTimer?.invalidate()
 | |
|         stopVoiceMessageRecording()
 | |
|         audioRecorder = nil
 | |
|     }
 | |
| 
 | |
|     func stopVoiceMessageRecording() {
 | |
|         audioRecorder?.stop()
 | |
|         Environment.shared?.audioSession.endAudioActivity(recordVoiceMessageActivity)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Data Extraction Notifications
 | |
|     
 | |
|     @objc func sendScreenshotNotification() { sendDataExtraction(kind: .screenshot) }
 | |
|     
 | |
|     func sendDataExtraction(
 | |
|         kind: DataExtractionNotification.Kind,
 | |
|         using dependencies: Dependencies = Dependencies()
 | |
|     ) {
 | |
|         // Only send screenshot notifications to one-to-one conversations
 | |
|         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())
 | |
|                 ),
 | |
|                 interactionId: nil,
 | |
|                 threadId: threadId,
 | |
|                 threadVariant: threadVariant,
 | |
|                 using: dependencies
 | |
|             )
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - Convenience
 | |
|     
 | |
|     func showErrorAlert(for attachment: SignalAttachment) {
 | |
|         let modal: ConfirmationModal = ConfirmationModal(
 | |
|             targetView: self.view,
 | |
|             info: ConfirmationModal.Info(
 | |
|                 title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(),
 | |
|                 body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage),
 | |
|                 cancelTitle: "BUTTON_OK".localized(),
 | |
|                 cancelStyle: .alert_text
 | |
|             )
 | |
|         )
 | |
|         self.present(modal, animated: true)
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - UIDocumentInteractionControllerDelegate
 | |
| 
 | |
| extension ConversationVC: UIDocumentInteractionControllerDelegate {
 | |
|     func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
 | |
|         return self
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Message Request Actions
 | |
| 
 | |
| 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 = {
 | |
|             // Remove the 'MessageRequestsViewController' from the nav hierarchy if present
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 if
 | |
|                     let viewControllers: [UIViewController] = self?.navigationController?.viewControllers,
 | |
|                     let messageRequestsIndex = viewControllers.firstIndex(where: { $0 is MessageRequestsViewController }),
 | |
|                     messageRequestsIndex > 0
 | |
|                 {
 | |
|                     var newViewControllers = viewControllers
 | |
|                     newViewControllers.remove(at: messageRequestsIndex)
 | |
|                     self?.navigationController?.viewControllers = newViewControllers
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // If the contact doesn't exist then we should create it so we can store the 'isApproved' state
 | |
|         // (it'll be updated with correct profile info if they accept the message request so this
 | |
|         // shouldn't cause weird behaviours)
 | |
|         guard
 | |
|             let contact: Contact = Storage.shared.read({ db in Contact.fetchOrCreate(db, id: threadId) }),
 | |
|             !contact.isApproved
 | |
|         else { return }
 | |
|         
 | |
|         Storage.shared
 | |
|             .writePublisher { db in
 | |
|                 // If we aren't creating a new thread (ie. sending a message request) then send a
 | |
|                 // messageRequestResponse back to the sender (this allows the sender to know that
 | |
|                 // they have been approved and can now use this contact in closed groups)
 | |
|                 if !isNewThread {
 | |
|                     try MessageSender.send(
 | |
|                         db,
 | |
|                         message: MessageRequestResponse(
 | |
|                             isApproved: true,
 | |
|                             sentTimestampMs: UInt64(timestampMs)
 | |
|                         ),
 | |
|                         interactionId: nil,
 | |
|                         threadId: threadId,
 | |
|                         threadVariant: threadVariant,
 | |
|                         using: dependencies
 | |
|                     )
 | |
|                 }
 | |
|                 
 | |
|                 // Default 'didApproveMe' to true for the person approving the message request
 | |
|                 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
 | |
|                     // Update the UI
 | |
|                     updateNavigationBackStack()
 | |
|                 }
 | |
|             )
 | |
|     }
 | |
| 
 | |
|     @objc func acceptMessageRequest() {
 | |
|         self.approveMessageRequestIfNeeded(
 | |
|             for: self.viewModel.threadData.threadId,
 | |
|             threadVariant: self.viewModel.threadData.threadVariant,
 | |
|             isNewThread: false,
 | |
|             timestampMs: SnodeAPI.currentOffsetTimestampMs()
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     @objc func deleteMessageRequest() {
 | |
|         let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions(
 | |
|             [.delete],
 | |
|             for: .trailing,
 | |
|             indexPath: IndexPath(row: 0, section: 0),
 | |
|             tableView: self.tableView,
 | |
|             threadViewModel: self.viewModel.threadData,
 | |
|             viewController: self
 | |
|         )
 | |
|         
 | |
|         guard let action: UIContextualAction = actions?.first else { return }
 | |
|         
 | |
|         action.handler(action, self.view, { [weak self] didConfirm in
 | |
|             guard didConfirm else { return }
 | |
|             
 | |
|             self?.stopObservingChanges()
 | |
|             
 | |
|             DispatchQueue.main.async {
 | |
|                 self?.navigationController?.popViewController(animated: true)
 | |
|             }
 | |
|         })
 | |
|     }
 | |
|     
 | |
|     @objc func blockMessageRequest() {
 | |
|         let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions(
 | |
|             [.block],
 | |
|             for: .trailing,
 | |
|             indexPath: IndexPath(row: 0, section: 0),
 | |
|             tableView: self.tableView,
 | |
|             threadViewModel: self.viewModel.threadData,
 | |
|             viewController: self
 | |
|         )
 | |
|         
 | |
|         guard let action: UIContextualAction = actions?.first else { return }
 | |
|         
 | |
|         action.handler(action, self.view, { [weak self] didConfirm in
 | |
|             guard didConfirm else { return }
 | |
|             
 | |
|             self?.stopObservingChanges()
 | |
|             
 | |
|             DispatchQueue.main.async {
 | |
|                 self?.navigationController?.popViewController(animated: true)
 | |
|             }
 | |
|         })
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - MediaPresentationContextProvider
 | |
| 
 | |
| extension ConversationVC: MediaPresentationContextProvider {
 | |
|     func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
 | |
|         guard case let .gallery(galleryItem) = mediaItem else { return nil }
 | |
|         
 | |
|         // Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an
 | |
|         // unsorted array which means we can't use it to determine the desired 'visibleCell'
 | |
|         // we are after, due to this we will need to iterate all of the visible cells to find
 | |
|         // the one we want
 | |
|         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 {
 | |
|             // If the frames don't match then assume it's either multiple images or there is a caption
 | |
|             // and determine which corners need to be rounded
 | |
|             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)
 | |
|     }
 | |
| }
 |