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.
		
		
		
		
		
			
		
			
				
	
	
		
			1121 lines
		
	
	
		
			54 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			1121 lines
		
	
	
		
			54 KiB
		
	
	
	
		
			Swift
		
	
| import UIKit
 | |
| import CoreServices
 | |
| import Photos
 | |
| import PhotosUI
 | |
| import SignalUtilitiesKit
 | |
| 
 | |
| extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate,
 | |
|     SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate,
 | |
|     ConversationTitleViewDelegate {
 | |
| 
 | |
|     func handleTitleViewTapped() {
 | |
|         openSettings()
 | |
|     }
 | |
|     
 | |
|     @objc func openSettings() {
 | |
|         let settingsVC = OWSConversationSettingsViewController()
 | |
|         settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection)
 | |
|         settingsVC.conversationSettingsViewDelegate = self
 | |
|         navigationController!.pushViewController(settingsVC, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func handleScrollToBottomButtonTapped() {
 | |
|         // The table view's content size is calculated by the estimated height of cells,
 | |
|         // so the result may be inaccurate before all the cells are loaded. Use this
 | |
|         // to scroll to the last row instead.
 | |
|         let indexPath = IndexPath(row: viewItems.count - 1, section: 0)
 | |
|         unreadViewItems.removeAll()
 | |
|         messagesTableView.scrollToRow(at: indexPath, at: .top, animated: true)
 | |
|     }
 | |
| 
 | |
|     // MARK: Blocking
 | |
|     @objc func unblock() {
 | |
|         guard let thread = thread as? TSContactThread else { return }
 | |
|         let publicKey = thread.contactSessionID()
 | |
|         UIView.animate(withDuration: 0.25, animations: {
 | |
|             self.blockedBanner.alpha = 0
 | |
|         }, completion: { _ in
 | |
|             OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey)
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     func showBlockedModalIfNeeded() -> Bool {
 | |
|         guard let thread = thread as? TSContactThread else { return false }
 | |
|         let publicKey = thread.contactSessionID()
 | |
|         guard OWSBlockingManager.shared().isRecipientIdBlocked(publicKey) else { return false }
 | |
|         let blockedModal = BlockedModal(publicKey: publicKey)
 | |
|         blockedModal.modalPresentationStyle = .overFullScreen
 | |
|         blockedModal.modalTransitionStyle = .crossDissolve
 | |
|         present(blockedModal, animated: true, completion: nil)
 | |
|         return true
 | |
|     }
 | |
| 
 | |
|     // 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(attachments: [ attachment ], approvalDelegate: self)
 | |
|         approvalVC.modalPresentationStyle = .fullScreen
 | |
|         self.present(approvalVC, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) {
 | |
|         dismiss(animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
 | |
|         sendAttachments(attachments, with: messageText ?? "")
 | |
|         resetMentions()
 | |
|         self.snInputView.text = ""
 | |
|         dismiss(animated: true) { }
 | |
|     }
 | |
| 
 | |
|     func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
 | |
|         return snInputView.text
 | |
|     }
 | |
| 
 | |
|     func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) {
 | |
|         snInputView.text = newMessageText ?? ""
 | |
|     }
 | |
| 
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
 | |
|         sendAttachments(attachments, with: messageText ?? "") { [weak self] in
 | |
|             self?.dismiss(animated: true, completion: nil)
 | |
|         }
 | |
|         
 | |
|         scrollToBottom(isAnimated: false)
 | |
|         resetMentions()
 | |
|         self.snInputView.text = ""
 | |
|     }
 | |
| 
 | |
|     func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
 | |
|         dismiss(animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
 | |
|         snInputView.text = newMessageText ?? ""
 | |
|     }
 | |
| 
 | |
|     func handleCameraButtonTapped() {
 | |
|         guard requestCameraPermissionIfNeeded() else { return }
 | |
|         requestMicrophonePermissionIfNeeded { }
 | |
|         if AVAudioSession.sharedInstance().recordPermission != .granted {
 | |
|             SNLog("Proceeding without microphone access. Any recorded video will be silent.")
 | |
|         }
 | |
|         let sendMediaNavController = SendMediaNavigationController.showingCameraFirst()
 | |
|         sendMediaNavController.sendMediaNavDelegate = self
 | |
|         sendMediaNavController.modalPresentationStyle = .fullScreen
 | |
|         present(sendMediaNavController, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func handleLibraryButtonTapped() {
 | |
|         requestLibraryPermissionIfNeeded { [weak self] in
 | |
|             DispatchQueue.main.async {
 | |
|                 let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst()
 | |
|                 sendMediaNavController.sendMediaNavDelegate = self
 | |
|                 sendMediaNavController.modalPresentationStyle = .fullScreen
 | |
|                 self?.present(sendMediaNavController, animated: true, completion: nil)
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func handleGIFButtonTapped() {
 | |
|         let gifVC = GifPickerViewController(thread: thread)
 | |
|         gifVC.delegate = self
 | |
|         let navController = OWSNavigationController(rootViewController: gifVC)
 | |
|         navController.modalPresentationStyle = .fullScreen
 | |
|         present(navController, animated: true) { }
 | |
|     }
 | |
| 
 | |
|     func gifPickerDidSelect(attachment: SignalAttachment) {
 | |
|         showAttachmentApprovalDialog(for: [ attachment ])
 | |
|     }
 | |
|     
 | |
|     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
 | |
|         SNAppearance.switchToDocumentPickerAppearance()
 | |
|         present(documentPickerVC, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
 | |
|         SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance
 | |
|     }
 | |
| 
 | |
|     func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
 | |
|         SNAppearance.switchToSessionAppearance()
 | |
|         guard let url = urls.first else { return } // TODO: Handle multiple?
 | |
|         let urlResourceValues: URLResourceValues
 | |
|         do {
 | |
|             urlResourceValues = try url.resourceValues(forKeys: [ .typeIdentifierKey, .isDirectoryKey, .nameKey ])
 | |
|         } catch {
 | |
|             let alert = UIAlertController(title: "Session", message: "An error occurred.", preferredStyle: .alert)
 | |
|             alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
 | |
|             return present(alert, animated: true, completion: nil)
 | |
|         }
 | |
|         let type = urlResourceValues.typeIdentifier ?? (kUTTypeData as String)
 | |
|         guard urlResourceValues.isDirectory != true else {
 | |
|             DispatchQueue.main.async {
 | |
|                 let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE", comment: "")
 | |
|                 let message = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY", comment: "")
 | |
|                 OWSAlerts.showAlert(title: title, message: message)
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         let fileName = urlResourceValues.name ?? NSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "")
 | |
|         guard let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false) else {
 | |
|             DispatchQueue.main.async {
 | |
|                 let title = NSLocalizedString("ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE", comment: "")
 | |
|                 OWSAlerts.showAlert(title: title)
 | |
|             }
 | |
|             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(attachments: attachments, approvalDelegate: self)
 | |
|         present(navController, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func showAttachmentApprovalDialogAfterProcessingVideo(at url: URL, with fileName: String) {
 | |
|         ModalActivityIndicatorViewController.present(fromViewController: self, canCancel: true, message: nil) { [weak self] modalActivityIndicator in
 | |
|             let dataSource = DataSourcePath.dataSource(with: url, shouldDeleteOnDeallocation: false)!
 | |
|             dataSource.sourceFilename = fileName
 | |
|             let compressionResult: SignalAttachment.VideoCompressionResult = SignalAttachment.compressVideoAsMp4(dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)
 | |
|             compressionResult.attachmentPromise.done { attachment in
 | |
|                 guard !modalActivityIndicator.wasCancelled, let attachment = attachment as? SignalAttachment else { return }
 | |
|                 modalActivityIndicator.dismiss {
 | |
|                     if !attachment.hasError {
 | |
|                         self?.showAttachmentApprovalDialog(for: [ attachment ])
 | |
|                     } else {
 | |
|                         self?.showErrorAlert(for: attachment, onDismiss: nil)
 | |
|                     }
 | |
|                 }
 | |
|             }.retainUntilComplete()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Message Sending
 | |
|     func handleSendButtonTapped() {
 | |
|         sendMessage()
 | |
|     }
 | |
| 
 | |
|     func sendMessage(hasPermissionToSendSeed: Bool = false) {
 | |
|         guard !showBlockedModalIfNeeded() else { return }
 | |
|         
 | |
|         let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
 | |
|         let thread = self.thread
 | |
|         
 | |
|         guard !text.isEmpty else { return }
 | |
|         
 | |
|         if text.contains(mnemonic) && !thread.isNoteToSelf() && !hasPermissionToSendSeed {
 | |
|             // Warn the user if they're about to send their seed to someone
 | |
|             let modal = SendSeedModal()
 | |
|             modal.modalPresentationStyle = .overFullScreen
 | |
|             modal.modalTransitionStyle = .crossDissolve
 | |
|             modal.proceed = { self.sendMessage(hasPermissionToSendSeed: true) }
 | |
|             return present(modal, animated: true, completion: nil)
 | |
|         }
 | |
|         
 | |
|         let sentTimestamp: UInt64 = NSDate.millisecondTimestamp()
 | |
|         let message: VisibleMessage = VisibleMessage()
 | |
|         message.sentTimestamp = sentTimestamp
 | |
|         message.text = text
 | |
|         message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model)
 | |
|         
 | |
|         // 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 = thread.shouldBeVisible
 | |
|         let linkPreviewDraft = snInputView.linkPreviewInfo?.draft
 | |
|         let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
 | |
|         
 | |
|         viewModel.appendUnsavedOutgoingTextMessage(tsMessage)
 | |
|         
 | |
|         Storage.write(with: { transaction in
 | |
|             message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction)
 | |
|         }, completion: { [weak self] in
 | |
|             tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview)
 | |
|             
 | |
|             Storage.shared.write(
 | |
|                 with: { transaction in
 | |
|                     tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction)
 | |
|                 },
 | |
|                 completion: { [weak self] in
 | |
|                     // At this point the TSOutgoingMessage should have its link preview set, so we can scroll to the bottom knowing
 | |
|                     // the height of the new message cell
 | |
|                     self?.scrollToBottom(isAnimated: false)
 | |
|                 }
 | |
|             )
 | |
|             
 | |
|             Storage.shared.write(
 | |
|                 with: { transaction in
 | |
|                     self?.approveMessageRequestIfNeeded(
 | |
|                         for: self?.thread,
 | |
|                         with: (transaction as! YapDatabaseReadWriteTransaction),
 | |
|                         isNewThread: !oldThreadShouldBeVisible,
 | |
|                         timestamp: (sentTimestamp - 1)  // Set 1ms earlier as this is used for sorting
 | |
|                     )
 | |
|                 },
 | |
|                 completion: { [weak self] in
 | |
|                     Storage.shared.write { transaction in
 | |
|                         MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction)
 | |
|                     }
 | |
|                     
 | |
|                     self?.handleMessageSent()
 | |
|                 }
 | |
|             )
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     func sendAttachments(_ attachments: [SignalAttachment], with text: String, onComplete: (() -> ())? = nil) {
 | |
|         guard !showBlockedModalIfNeeded() else { return }
 | |
|         for attachment in attachments {
 | |
|             if attachment.hasError {
 | |
|                 return showErrorAlert(for: attachment, onDismiss: onComplete)
 | |
|             }
 | |
|         }
 | |
|         let thread = self.thread
 | |
|         let message = VisibleMessage()
 | |
|         message.sentTimestamp = NSDate.millisecondTimestamp()
 | |
|         message.text = replaceMentions(in: text)
 | |
|         let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
 | |
|         Storage.write(with: { transaction in
 | |
|             tsMessage.save(with: transaction)
 | |
|             // The new message cell is inserted at this point, but the TSOutgoingMessage doesn't have its attachment yet
 | |
|         }, completion: { [weak self] in
 | |
|             Storage.write(with: { transaction in
 | |
|                 MessageSender.send(message, with: attachments, in: thread, using: transaction)
 | |
|             }, completion: { [weak self] in
 | |
|                 // At this point the TSOutgoingMessage should have its attachments set, so we can scroll to the bottom knowing
 | |
|                 // the height of the new message cell
 | |
|                 self?.scrollToBottom(isAnimated: false)
 | |
|             })
 | |
|             self?.handleMessageSent()
 | |
|             
 | |
|             // Attachment successfully sent - dismiss the screen
 | |
|             onComplete?()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     func handleMessageSent() {
 | |
|         resetMentions()
 | |
|         self.snInputView.text = ""
 | |
|         self.snInputView.quoteDraftInfo = nil
 | |
|         self.markAllAsRead()
 | |
|         if Environment.shared.preferences.soundInForeground() {
 | |
|             let soundID = OWSSounds.systemSoundID(for: .messageSent, quiet: true)
 | |
|             AudioServicesPlaySystemSound(soundID)
 | |
|         }
 | |
|         SSKEnvironment.shared.typingIndicators.didSendOutgoingMessage(inThread: thread)
 | |
|         Storage.write { transaction in
 | |
|             self.thread.setDraft("", transaction: transaction)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Input View
 | |
|     func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
 | |
|         let newText = inputTextView.text ?? ""
 | |
|         if !newText.isEmpty {
 | |
|             SSKEnvironment.shared.typingIndicators.didStartTypingOutgoingInput(inThread: thread)
 | |
|         }
 | |
|         updateMentions(for: newText)
 | |
|     }
 | |
| 
 | |
|     func showLinkPreviewSuggestionModal() {
 | |
|         let linkPreviewModel = LinkPreviewModal() { [weak self] in
 | |
|             self?.snInputView.autoGenerateLinkPreview()
 | |
|         }
 | |
|         linkPreviewModel.modalPresentationStyle = .overFullScreen
 | |
|         linkPreviewModel.modalTransitionStyle = .crossDissolve
 | |
|         present(linkPreviewModel, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     // MARK: Mentions
 | |
|     func updateMentions(for newText: String) {
 | |
|         if newText.count < oldText.count {
 | |
|             currentMentionStartIndex = nil
 | |
|             snInputView.hideMentionsUI()
 | |
|             mentions = mentions.filter { $0.isContained(in: newText) }
 | |
|         }
 | |
|         if !newText.isEmpty {
 | |
|             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 {
 | |
|                 let candidates = MentionsManager.getMentionCandidates(for: "", in: thread.uniqueId!)
 | |
|                 currentMentionStartIndex = lastCharacterIndex
 | |
|                 snInputView.showMentionsUI(for: candidates, in: thread)
 | |
|             } 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 @
 | |
|                     let candidates = MentionsManager.getMentionCandidates(for: query, in: thread.uniqueId!)
 | |
|                     snInputView.showMentionsUI(for: candidates, in: thread)
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         oldText = newText
 | |
|     }
 | |
| 
 | |
|     func resetMentions() {
 | |
|         oldText = ""
 | |
|         currentMentionStartIndex = nil
 | |
|         mentions = []
 | |
|     }
 | |
| 
 | |
|     func replaceMentions(in text: String) -> String {
 | |
|         var result = text
 | |
|         for mention in mentions {
 | |
|             guard let range = result.range(of: "@\(mention.displayName)") else { continue }
 | |
|             result = result.replacingCharacters(in: range, with: "@\(mention.publicKey)")
 | |
|         }
 | |
|         return result
 | |
|     }
 | |
| 
 | |
|     func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
 | |
|         guard let currentMentionStartIndex = currentMentionStartIndex else { return }
 | |
|         mentions.append(mention)
 | |
|         let oldText = snInputView.text
 | |
|         let newText = oldText.replacingCharacters(in: currentMentionStartIndex..., with: "@\(mention.displayName) ")
 | |
|         snInputView.text = newText
 | |
|         self.currentMentionStartIndex = nil
 | |
|         snInputView.hideMentionsUI()
 | |
|         self.oldText = newText
 | |
|     }
 | |
|     
 | |
|     func showInputAccessoryView() {
 | |
|         UIView.animate(withDuration: 0.25, animations: {
 | |
|             self.inputAccessoryView?.isHidden = false
 | |
|             self.inputAccessoryView?.alpha = 1
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     // MARK: View Item Interaction
 | |
|     func handleViewItemLongPressed(_ viewItem: ConversationViewItem) {
 | |
|         // Show the context menu if applicable
 | |
|         guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
 | |
|             let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell,
 | |
|             let snapshot = cell.bubbleView.snapshotView(afterScreenUpdates: false), contextMenuWindow == nil,
 | |
|             !ContextMenuVC.actions(for: viewItem, delegate: self).isEmpty else { return }
 | |
|         UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
 | |
|         let frame = cell.convert(cell.bubbleView.frame, to: UIApplication.shared.keyWindow!)
 | |
|         let window = ContextMenuWindow()
 | |
|         let contextMenuVC = ContextMenuVC(snapshot: snapshot, viewItem: viewItem, frame: frame, delegate: self) { [weak self] in
 | |
|             window.isHidden = true
 | |
|             guard let self = self else { return }
 | |
|             self.contextMenuVC = nil
 | |
|             self.contextMenuWindow = nil
 | |
|             self.scrollButton.alpha = 0
 | |
|             UIView.animate(withDuration: 0.25) {
 | |
|                 self.scrollButton.alpha = self.getScrollButtonOpacity()
 | |
|                 self.unreadCountView.alpha = self.scrollButton.alpha
 | |
|             }
 | |
|         }
 | |
|         self.contextMenuVC = contextMenuVC
 | |
|         contextMenuWindow = window
 | |
|         window.rootViewController = contextMenuVC
 | |
|         window.makeKeyAndVisible()
 | |
|         window.backgroundColor = .clear
 | |
|     }
 | |
| 
 | |
|     func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) {
 | |
|         func confirmDownload() {
 | |
|             let modal = DownloadAttachmentModal(viewItem: viewItem)
 | |
|             modal.modalPresentationStyle = .overFullScreen
 | |
|             modal.modalTransitionStyle = .crossDissolve
 | |
|             present(modal, animated: true, completion: nil)
 | |
|         }
 | |
|         if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
 | |
|             // Show the failed message sheet
 | |
|             showFailedMessageSheet(for: message)
 | |
|         } else {
 | |
|             switch viewItem.messageCellType {
 | |
|             case .audio:
 | |
|                 if viewItem.interaction is TSIncomingMessage,
 | |
|                     let thread = self.thread as? TSContactThread,
 | |
|                     Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true {
 | |
|                     confirmDownload()
 | |
|                 } else {
 | |
|                     playOrPauseAudio(for: viewItem)
 | |
|                 }
 | |
|             case .mediaMessage:
 | |
|                 guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
 | |
|                     let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return }
 | |
|                 if viewItem.interaction is TSIncomingMessage,
 | |
|                     let thread = self.thread as? TSContactThread,
 | |
|                     Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true {
 | |
|                     confirmDownload()
 | |
|                 } else {
 | |
|                     guard let albumView = cell.albumView else { return }
 | |
|                     let locationInCell = gestureRecognizer.location(in: cell)
 | |
|                     // Figure out whether the "read more" button was tapped
 | |
|                     if let overlayView = cell.mediaTextOverlayView {
 | |
|                         let locationInOverlayView = cell.convert(locationInCell, to: overlayView)
 | |
|                         if let readMoreButton = overlayView.readMoreButton, readMoreButton.frame.contains(locationInOverlayView) {
 | |
|                             return showFullText(viewItem) // HACK: This is a dirty way to do this
 | |
|                         }
 | |
|                     }
 | |
|                     // Otherwise, figure out which of the media views was tapped
 | |
|                     let locationInAlbumView = cell.convert(locationInCell, to: albumView)
 | |
|                     guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
 | |
|                     if albumView.isMoreItemsView(mediaView: mediaView) && viewItem.mediaAlbumHasFailedAttachment() {
 | |
|                         // TODO: Tapped a failed incoming attachment
 | |
|                     }
 | |
|                     let attachment = mediaView.attachment
 | |
|                     if let pointer = attachment as? TSAttachmentPointer {
 | |
|                         if pointer.state == .failed {
 | |
|                             // TODO: Tapped a failed incoming attachment
 | |
|                         }
 | |
|                     }
 | |
|                     guard let stream = attachment as? TSAttachmentStream else { return }
 | |
|                     let gallery = MediaGallery(thread: thread, options: [ .sliderEnabled, .showAllMediaButton ])
 | |
|                     gallery.presentDetailView(fromViewController: self, mediaAttachment: stream)
 | |
|                 }
 | |
|             case .genericAttachment:
 | |
|                 if viewItem.interaction is TSIncomingMessage,
 | |
|                     let thread = self.thread as? TSContactThread,
 | |
|                     Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true {
 | |
|                     confirmDownload()
 | |
|                 } else {
 | |
|                     // Open the document if possible
 | |
|                     guard let url = viewItem.attachmentStream?.originalMediaURL else { return }
 | |
|                     let shareVC = UIActivityViewController(activityItems: [ url ], applicationActivities: nil)
 | |
|                     navigationController!.present(shareVC, animated: true, completion: nil)
 | |
|                 }
 | |
|             case .textOnlyMessage:
 | |
|                 if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) {
 | |
|                     // Open the link preview URL
 | |
|                     openURL(url)
 | |
|                 } else if let reply = viewItem.quotedReply {
 | |
|                     // Scroll to the source of the reply
 | |
|                     guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return }
 | |
|                     messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true)
 | |
|                 } else if let message = viewItem.interaction as? TSIncomingMessage, let name = message.openGroupInvitationName,
 | |
|                     let url = message.openGroupInvitationURL {
 | |
|                     joinOpenGroup(name: name, url: url)
 | |
|                 }
 | |
|             default: break
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func handleViewItemSwiped(_ viewItem: ConversationViewItem, state: SwipeState) {
 | |
|         switch state {
 | |
|         case .began:
 | |
|             messagesTableView.isScrollEnabled = false
 | |
|         case .ended, .cancelled:
 | |
|             messagesTableView.isScrollEnabled = true
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func showFailedMessageSheet(for tsMessage: TSOutgoingMessage) {
 | |
|         let thread = self.thread
 | |
|         let error = tsMessage.mostRecentFailureText
 | |
|         let sheet = UIAlertController(title: error, message: nil, preferredStyle: .actionSheet)
 | |
|         sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
 | |
|         sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in
 | |
|             Storage.write { transaction in
 | |
|                 tsMessage.remove(with: transaction)
 | |
|                 Storage.shared.cancelPendingMessageSendJobIfNeeded(for: tsMessage.timestamp, using: transaction)
 | |
|             }
 | |
|         }))
 | |
|         sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in
 | |
|             let message = VisibleMessage.from(tsMessage)
 | |
|             Storage.write { transaction in
 | |
|                 var attachments: [TSAttachmentStream] = []
 | |
|                 tsMessage.attachmentIds.forEach { attachmentID in
 | |
|                     guard let attachmentID = attachmentID as? String else { return }
 | |
|                     let attachment = TSAttachment.fetch(uniqueId: attachmentID, transaction: transaction)
 | |
|                     guard let stream = attachment as? TSAttachmentStream else { return }
 | |
|                     attachments.append(stream)
 | |
|                 }
 | |
|                 MessageSender.prep(attachments, for: message, using: transaction)
 | |
|                 MessageSender.send(message, in: thread, using: transaction)
 | |
|             }
 | |
|         }))
 | |
|         // HACK: Extracting this info from the error string is pretty dodgy
 | |
|         let prefix = "HTTP request failed at destination (Service node "
 | |
|         if error.hasPrefix(prefix) {
 | |
|             let rest = error.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, handler: { _ in
 | |
|                     UIPasteboard.general.string = snodeAddress
 | |
|                 }))
 | |
|             }
 | |
|         }
 | |
|         present(sheet, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) {
 | |
|         switch viewItem.messageCellType {
 | |
|         case .audio: speedUpAudio(for: viewItem) // The user can double tap a voice message when it's playing to speed it up
 | |
|         default: break
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func showFullText(_ viewItem: ConversationViewItem) {
 | |
|         let longMessageVC = LongTextViewController(viewItem: viewItem)
 | |
|         navigationController!.pushViewController(longMessageVC, animated: true)
 | |
|     }
 | |
|     
 | |
|     func reply(_ viewItem: ConversationViewItem) {
 | |
|         var quoteDraftOrNil: OWSQuotedReplyModel?
 | |
|         Storage.read { transaction in
 | |
|             quoteDraftOrNil = OWSQuotedReplyModel.quotedReplyForSending(with: viewItem, threadId: viewItem.interaction.uniqueThreadId, transaction: transaction)
 | |
|         }
 | |
|         guard let quoteDraft = quoteDraftOrNil else { return }
 | |
|         let isOutgoing = (viewItem.interaction.interactionType() == .outgoingMessage)
 | |
|         snInputView.quoteDraftInfo = (model: quoteDraft, isOutgoing: isOutgoing)
 | |
|         snInputView.becomeFirstResponder()
 | |
|     }
 | |
|     
 | |
|     func copy(_ viewItem: ConversationViewItem) {
 | |
|         if viewItem.canCopyMedia() {
 | |
|             viewItem.copyMediaAction()
 | |
|         } else {
 | |
|             viewItem.copyTextAction()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func copySessionID(_ viewItem: ConversationViewItem) {
 | |
|         // FIXME: Copying media
 | |
|         guard let message = viewItem.interaction as? TSIncomingMessage else { return }
 | |
|         UIPasteboard.general.string = message.authorId
 | |
|     }
 | |
|     
 | |
|     func delete(_ viewItem: ConversationViewItem) {
 | |
|         if (!self.isUnsendRequestsEnabled) {
 | |
|             viewItem.deleteAction()
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         guard let message = viewItem.interaction as? TSMessage else { return self.deleteLocally(viewItem) }
 | |
|         
 | |
|         // Handle open group messages the old way
 | |
|         if message.isOpenGroupMessage { return self.deleteForEveryone(viewItem) }
 | |
|         
 | |
|         // Handle 1-1 and closed group messages with unsend request
 | |
|         if viewItem.interaction.interactionType() == .outgoingMessage, message.serverHash != nil  {
 | |
|             let alertVC = UIAlertController.init(title: nil, message: nil, preferredStyle: .actionSheet)
 | |
|             let deleteLocallyAction = UIAlertAction.init(title: NSLocalizedString("delete_message_for_me", comment: ""), style: .destructive) { _ in
 | |
|                 self.deleteLocally(viewItem)
 | |
|                 self.showInputAccessoryView()
 | |
|             }
 | |
|             alertVC.addAction(deleteLocallyAction)
 | |
|             
 | |
|             var title = NSLocalizedString("delete_message_for_everyone", comment: "")
 | |
|             if !viewItem.isGroupThread {
 | |
|                 title = String(format: NSLocalizedString("delete_message_for_me_and_recipient", comment: ""), viewItem.interaction.thread.name())
 | |
|             }
 | |
|             let deleteRemotelyAction = UIAlertAction.init(title: title, style: .destructive) { _ in
 | |
|                 self.deleteForEveryone(viewItem)
 | |
|                 self.showInputAccessoryView()
 | |
|             }
 | |
|             alertVC.addAction(deleteRemotelyAction)
 | |
|             
 | |
|             let cancelAction = UIAlertAction.init(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel) {_ in
 | |
|                 self.showInputAccessoryView()
 | |
|             }
 | |
|             alertVC.addAction(cancelAction)
 | |
|             
 | |
|             self.inputAccessoryView?.isHidden = true
 | |
|             self.inputAccessoryView?.alpha = 0
 | |
|             self.presentAlert(alertVC)
 | |
|         } else {
 | |
|             deleteLocally(viewItem)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func buildUnsendRequest(_ viewItem: ConversationViewItem) -> UnsendRequest? {
 | |
|         if let message = viewItem.interaction as? TSMessage,
 | |
|            message.isOpenGroupMessage || message.serverHash == nil { return nil }
 | |
|         let unsendRequest = UnsendRequest()
 | |
|         switch viewItem.interaction.interactionType() {
 | |
|         case .incomingMessage:
 | |
|             if let incomingMessage = viewItem.interaction as? TSIncomingMessage {
 | |
|                 unsendRequest.author = incomingMessage.authorId
 | |
|             }
 | |
|         case .outgoingMessage: unsendRequest.author = getUserHexEncodedPublicKey()
 | |
|         default: return nil // Should never occur
 | |
|         }
 | |
|         unsendRequest.timestamp = viewItem.interaction.timestamp
 | |
|         return unsendRequest
 | |
|     }
 | |
|     
 | |
|     func deleteLocally(_ viewItem: ConversationViewItem) {
 | |
|         viewItem.deleteLocallyAction()
 | |
|         if let unsendRequest = buildUnsendRequest(viewItem) {
 | |
|             SNMessagingKitConfiguration.shared.storage.write { transaction in
 | |
|                 MessageSender.send(unsendRequest, to: .contact(publicKey: getUserHexEncodedPublicKey()), using: transaction).retainUntilComplete()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func deleteForEveryone(_ viewItem: ConversationViewItem) {
 | |
|         viewItem.deleteLocallyAction()
 | |
|         viewItem.deleteRemotelyAction()
 | |
|         if let unsendRequest = buildUnsendRequest(viewItem) {
 | |
|             SNMessagingKitConfiguration.shared.storage.write { transaction in
 | |
|                 MessageSender.send(unsendRequest, in: self.thread, using: transaction as! YapDatabaseReadWriteTransaction)
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     func save(_ viewItem: ConversationViewItem) {
 | |
|         guard viewItem.canSaveMedia() else { return }
 | |
|         viewItem.saveMediaAction()
 | |
|         sendMediaSavedNotificationIfNeeded(for: viewItem)
 | |
|     }
 | |
|     
 | |
|     func ban(_ viewItem: ConversationViewItem) {
 | |
|         guard let message = viewItem.interaction as? TSIncomingMessage, message.isOpenGroupMessage else { return }
 | |
|         let explanation = "This will ban the selected user from this room. It won't ban them from other rooms."
 | |
|         let alert = UIAlertController(title: "Session", message: explanation, preferredStyle: .alert)
 | |
|         let threadID = thread.uniqueId!
 | |
|         alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
 | |
|             let publicKey = message.authorId
 | |
|             guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return }
 | |
|             OpenGroupAPI.legacyBan(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete()
 | |
|         }))
 | |
|         alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
 | |
|         present(alert, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func banAndDeleteAllMessages(_ viewItem: ConversationViewItem) {
 | |
|         guard let message = viewItem.interaction as? TSIncomingMessage, message.isOpenGroupMessage else { return }
 | |
|         let explanation = "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."
 | |
|         let alert = UIAlertController(title: "Session", message: explanation, preferredStyle: .alert)
 | |
|         let threadID = thread.uniqueId!
 | |
|         alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
 | |
|             let publicKey = message.authorId
 | |
|             guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return }
 | |
|             OpenGroupAPI.legacyBanAndDeleteAllMessages(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete()
 | |
|         }))
 | |
|         alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
 | |
|         present(alert, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     func handleQuoteViewCancelButtonTapped() {
 | |
|         snInputView.quoteDraftInfo = nil
 | |
|     }
 | |
|     
 | |
|     func openURL(_ url: URL) {
 | |
|         // URLs can be unsafe, so always ask the user whether they want to open one
 | |
|         let title = NSLocalizedString("modal_open_url_title", comment: "")
 | |
|         let message = String(format: NSLocalizedString("modal_open_url_explanation", comment: ""), url.absoluteString)
 | |
|         let alertVC = UIAlertController.init(title: title, message: message, preferredStyle: .actionSheet)
 | |
|         let openAction = UIAlertAction.init(title: NSLocalizedString("modal_open_url_button_title", comment: ""), style: .default) { _ in
 | |
|             UIApplication.shared.open(url, options: [:], completionHandler: nil)
 | |
|             self.showInputAccessoryView()
 | |
|         }
 | |
|         alertVC.addAction(openAction)
 | |
|         let copyAction = UIAlertAction.init(title: NSLocalizedString("modal_copy_url_button_title", comment: ""), style: .default) { _ in
 | |
|             UIPasteboard.general.string = url.absoluteString
 | |
|             self.showInputAccessoryView()
 | |
|         }
 | |
|         alertVC.addAction(copyAction)
 | |
|         let cancelAction = UIAlertAction.init(title: NSLocalizedString("cancel", comment: ""), style: .cancel) {_ in
 | |
|             self.showInputAccessoryView()
 | |
|         }
 | |
|         alertVC.addAction(cancelAction)
 | |
|         self.presentAlert(alertVC)
 | |
|     }
 | |
|     
 | |
|     func joinOpenGroup(name: String, url: String) {
 | |
|         // Open groups can be unsafe, so always ask the user whether they want to join one
 | |
|         let joinOpenGroupModal = JoinOpenGroupModal(name: name, url: url)
 | |
|         joinOpenGroupModal.modalPresentationStyle = .overFullScreen
 | |
|         joinOpenGroupModal.modalTransitionStyle = .crossDissolve
 | |
|         present(joinOpenGroupModal, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func handleReplyButtonTapped(for viewItem: ConversationViewItem) {
 | |
|         reply(viewItem)
 | |
|     }
 | |
|     
 | |
|     func showUserDetails(for sessionID: String) {
 | |
|         let userDetailsSheet = UserDetailsSheet(for: sessionID)
 | |
|         userDetailsSheet.modalPresentationStyle = .overFullScreen
 | |
|         userDetailsSheet.modalTransitionStyle = .crossDissolve
 | |
|         present(userDetailsSheet, animated: true, completion: nil)
 | |
|     }
 | |
| 
 | |
|     // MARK: Voice Message Playback
 | |
|     @objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) {
 | |
|         // Play the next voice message if there is one
 | |
|         guard let audioPlayer = audioPlayer, let viewItem = audioPlayer.owner as? ConversationViewItem,
 | |
|             let index = viewItems.firstIndex(where: { $0 === viewItem }), index < (viewItems.endIndex - 1) else { return }
 | |
|         let nextViewItem = viewItems[index + 1]
 | |
|         guard nextViewItem.messageCellType == .audio else { return }
 | |
|         playOrPauseAudio(for: nextViewItem)
 | |
|     }
 | |
|     
 | |
|     func playOrPauseAudio(for viewItem: ConversationViewItem) {
 | |
|         guard let attachment = viewItem.attachmentStream else { return }
 | |
|         let fileManager = FileManager.default
 | |
|         guard let path = attachment.originalFilePath, fileManager.fileExists(atPath: path),
 | |
|             let url = attachment.originalMediaURL else { return }
 | |
|         if let audioPlayer = audioPlayer {
 | |
|             if let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem {
 | |
|                 audioPlayer.playbackRate = 1
 | |
|                 audioPlayer.togglePlayState()
 | |
|                 return
 | |
|             } else {
 | |
|                 audioPlayer.stop()
 | |
|                 self.audioPlayer = nil
 | |
|             }
 | |
|         }
 | |
|         let audioPlayer = OWSAudioPlayer(mediaUrl: url, audioBehavior: .audioMessagePlayback, delegate: viewItem)
 | |
|         self.audioPlayer = audioPlayer
 | |
|         audioPlayer.owner = viewItem
 | |
|         audioPlayer.play()
 | |
|         audioPlayer.setCurrentTime(Double(viewItem.audioProgressSeconds))
 | |
|     }
 | |
| 
 | |
|     func speedUpAudio(for viewItem: ConversationViewItem) {
 | |
|         guard let audioPlayer = audioPlayer, let owner = audioPlayer.owner as? ConversationViewItem, owner === viewItem, audioPlayer.isPlaying else { return }
 | |
|         audioPlayer.playbackRate = 1.5
 | |
|         viewItem.lastAudioMessageView?.showSpeedUpLabel()
 | |
|     }
 | |
| 
 | |
|     // MARK: Voice Message Recording
 | |
|     func startVoiceMessageRecording() {
 | |
|         // Request permission if needed
 | |
|         requestMicrophonePermissionIfNeeded() { [weak self] in
 | |
|             self?.cancelVoiceMessageRecording()
 | |
|         }
 | |
|         // Keep screen on
 | |
|         UIApplication.shared.isIdleTimerDisabled = false
 | |
|         guard AVAudioSession.sharedInstance().recordPermission == .granted else { return }
 | |
|         // Cancel any current audio playback
 | |
|         audioPlayer?.stop()
 | |
|         audioPlayer = nil
 | |
|         // Create URL
 | |
|         let directory = OWSTemporaryDirectory()
 | |
|         let fileName = "\(NSDate.millisecondTimestamp()).m4a"
 | |
|         let path = (directory as NSString).appendingPathComponent(fileName)
 | |
|         let url = URL(fileURLWithPath: path)
 | |
|         // Set up audio session
 | |
|         let isConfigured = audioSession.startAudioActivity(recordVoiceMessageActivity)
 | |
|         guard isConfigured else {
 | |
|             return cancelVoiceMessageRecording()
 | |
|         }
 | |
|         // Set up audio recorder
 | |
|         let settings: [String:NSNumber] = [
 | |
|             AVFormatIDKey : NSNumber(value: kAudioFormatMPEG4AAC),
 | |
|             AVSampleRateKey : NSNumber(value: 44100),
 | |
|             AVNumberOfChannelsKey : NSNumber(value: 2),
 | |
|             AVEncoderBitRateKey : NSNumber(value: 128 * 1024)
 | |
|         ]
 | |
|         let audioRecorder: AVAudioRecorder
 | |
|         do {
 | |
|             audioRecorder = try AVAudioRecorder(url: url, settings: settings)
 | |
|             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()
 | |
|         })
 | |
|         // 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() {
 | |
|         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 title = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
 | |
|             let message = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
 | |
|             return OWSAlerts.showAlert(title: title, message: message)
 | |
|         }
 | |
|         // 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 = (NSLocalizedString("VOICE_MESSAGE_FILE_NAME", comment: "") as NSString).appendingPathExtension("m4a")
 | |
|         dataSource.sourceFilename = fileName
 | |
|         let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String)
 | |
|         guard !attachment.hasError else {
 | |
|             return showErrorAlert(for: attachment, onDismiss: nil)
 | |
|         }
 | |
|         // Send attachment
 | |
|         sendAttachments([ attachment ], with: "")
 | |
|     }
 | |
| 
 | |
|     func cancelVoiceMessageRecording() {
 | |
|         snInputView.hideVoiceMessageUI()
 | |
|         audioTimer?.invalidate()
 | |
|         stopVoiceMessageRecording()
 | |
|         audioRecorder = nil
 | |
|     }
 | |
| 
 | |
|     func stopVoiceMessageRecording() {
 | |
|         audioRecorder?.stop()
 | |
|         audioSession.endAudioActivity(recordVoiceMessageActivity)
 | |
|     }
 | |
|     
 | |
|     // MARK: Data Extraction Notifications
 | |
|     @objc func sendScreenshotNotificationIfNeeded() {
 | |
|         /*
 | |
|         guard thread is TSContactThread else { return }
 | |
|         let message = DataExtractionNotification()
 | |
|         message.kind = .screenshot
 | |
|         Storage.write { transaction in
 | |
|             MessageSender.send(message, in: self.thread, using: transaction)
 | |
|         }
 | |
|          */
 | |
|     }
 | |
|     
 | |
|     func sendMediaSavedNotificationIfNeeded(for viewItem: ConversationViewItem) {
 | |
|         guard thread is TSContactThread, viewItem.interaction.interactionType() == .incomingMessage else { return }
 | |
|         let message = DataExtractionNotification()
 | |
|         message.kind = .mediaSaved(timestamp: viewItem.interaction.timestamp)
 | |
|         Storage.write { transaction in
 | |
|             MessageSender.send(message, in: self.thread, using: transaction)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Requesting Permission
 | |
|     func requestCameraPermissionIfNeeded() -> Bool {
 | |
|         switch AVCaptureDevice.authorizationStatus(for: .video) {
 | |
|         case .authorized: return true
 | |
|         case .denied, .restricted:
 | |
|             let modal = PermissionMissingModal(permission: "camera") { }
 | |
|             modal.modalPresentationStyle = .overFullScreen
 | |
|             modal.modalTransitionStyle = .crossDissolve
 | |
|             present(modal, animated: true, completion: nil)
 | |
|             return false
 | |
|         case .notDetermined:
 | |
|             AVCaptureDevice.requestAccess(for: .video, completionHandler: { _ in })
 | |
|             return false
 | |
|         default: return false
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func requestMicrophonePermissionIfNeeded(onNotGranted: @escaping () -> Void) {
 | |
|         switch AVAudioSession.sharedInstance().recordPermission {
 | |
|         case .granted: break
 | |
|         case .denied:
 | |
|             onNotGranted()
 | |
|             let modal = PermissionMissingModal(permission: "microphone") {
 | |
|                 onNotGranted()
 | |
|             }
 | |
|             modal.modalPresentationStyle = .overFullScreen
 | |
|             modal.modalTransitionStyle = .crossDissolve
 | |
|             present(modal, animated: true, completion: nil)
 | |
|         case .undetermined:
 | |
|             onNotGranted()
 | |
|             AVAudioSession.sharedInstance().requestRecordPermission { _ in }
 | |
|         default: break
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) {
 | |
|         let authorizationStatus: PHAuthorizationStatus
 | |
|         if #available(iOS 14, *) {
 | |
|             authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
 | |
|             if authorizationStatus == .notDetermined {
 | |
|                 // When the user chooses to select photos (which is the .limit status),
 | |
|                 // the PHPhotoUI will present the picker view on the top of the front view.
 | |
|                 // Since we have the ScreenLockUI showing when we request premissions,
 | |
|                 // the picker view will be presented on the top of the ScreenLockUI.
 | |
|                 // However, the ScreenLockUI will dismiss with the permission request alert view, so
 | |
|                 // the picker view then will dismiss, too. The selection process cannot be finished
 | |
|                 // this way. So we add a flag (isRequestingPermission) to prevent the ScreenLockUI
 | |
|                 // from showing when we request the photo library permission.
 | |
|                 Environment.shared.isRequestingPermission = true
 | |
|                 let appMode = AppModeManager.shared.currentAppMode
 | |
|                 // FIXME: Rather than setting the app mode to light and then to dark again once we're done,
 | |
|                 // it'd be better to just customize the appearance of the image picker. There doesn't currently
 | |
|                 // appear to be a good way to do so though...
 | |
|                 AppModeManager.shared.setCurrentAppMode(to: .light)
 | |
|                 PHPhotoLibrary.requestAuthorization(for: .readWrite) { status in
 | |
|                     DispatchQueue.main.async {
 | |
|                         AppModeManager.shared.setCurrentAppMode(to: appMode)
 | |
|                     }
 | |
|                     Environment.shared.isRequestingPermission = false
 | |
|                     if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) {
 | |
|                         onAuthorized()
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         } else {
 | |
|             authorizationStatus = PHPhotoLibrary.authorizationStatus()
 | |
|             if authorizationStatus == .notDetermined {
 | |
|                 PHPhotoLibrary.requestAuthorization { status in
 | |
|                     if status == .authorized {
 | |
|                         onAuthorized()
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|         switch authorizationStatus {
 | |
|         case .authorized, .limited:
 | |
|             onAuthorized()
 | |
|         case .denied, .restricted:
 | |
|             let modal = PermissionMissingModal(permission: "library") { }
 | |
|             modal.modalPresentationStyle = .overFullScreen
 | |
|             modal.modalTransitionStyle = .crossDissolve
 | |
|             present(modal, animated: true, completion: nil)
 | |
|         default: return
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - Convenience
 | |
|     
 | |
|     func showErrorAlert(for attachment: SignalAttachment, onDismiss: (() -> ())?) {
 | |
|         let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "")
 | |
|         let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage
 | |
|         
 | |
|         OWSAlerts.showAlert(title: title, message: message, buttonTitle: nil) { _ in
 | |
|             onDismiss?()
 | |
|         }
 | |
|     }
 | |
| }
 | |
| 
 | |
| // MARK: - Message Request Actions
 | |
| 
 | |
| extension ConversationVC {
 | |
|     fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, with transaction: YapDatabaseReadWriteTransaction, isNewThread: Bool, timestamp: UInt64) {
 | |
|         guard let contactThread: TSContactThread = thread as? TSContactThread else { return }
 | |
|         
 | |
|         // 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)
 | |
|         let sessionId: String = contactThread.contactSessionID()
 | |
|         let contact: Contact = (Storage.shared.getContact(with: sessionId) ?? Contact(sessionID: sessionId))
 | |
|         
 | |
|         if !contact.isApproved {
 | |
|             // Default 'didApproveMe' to true for the person approving the message request
 | |
|             contact.isApproved = true
 | |
|             contact.didApproveMe = (contact.didApproveMe || !isNewThread)
 | |
|             Storage.shared.setContact(contact, using: transaction)
 | |
|             
 | |
|             // 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 {
 | |
|                 let messageRequestResponse: MessageRequestResponse = MessageRequestResponse(
 | |
|                     publicKey: sessionId,
 | |
|                     isApproved: true
 | |
|                 )
 | |
|                 messageRequestResponse.sentTimestamp = timestamp
 | |
|                 
 | |
|                 MessageSender.send(messageRequestResponse, in: contactThread, using: transaction)
 | |
|             }
 | |
|             
 | |
|             // Hide the 'messageRequestView' since the request has been approved and force a config
 | |
|             // sync to propagate the contact approval state (both must run on the main thread)
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 self?.messageRequestView.isHidden = true
 | |
|             
 | |
|                 // Send a sync message with the details of the contact
 | |
|                 if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
 | |
|                    appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete()
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func acceptMessageRequest() {
 | |
|         Storage.write { [weak self] transaction in
 | |
|             self?.approveMessageRequestIfNeeded(
 | |
|                 for: self?.thread,
 | |
|                 with: transaction,
 | |
|                 isNewThread: false,
 | |
|                 timestamp: NSDate.millisecondTimestamp()
 | |
|             )
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func deleteMessageRequest() {
 | |
|         guard let uniqueId: String = thread.uniqueId else { return }
 | |
| 
 | |
|         Storage.write(
 | |
|             with: { [weak self] transaction in
 | |
|                 Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction)
 | |
|                 
 | |
|                 // Update the contact
 | |
|                 if let contactThread: TSContactThread = self?.thread as? TSContactThread {
 | |
|                     let sessionId: String = contactThread.contactSessionID()
 | |
|                     
 | |
|                     if let contact: Contact = Storage.shared.getContact(with: sessionId) {
 | |
|                         contact.isApproved = false
 | |
|                         contact.isBlocked = true
 | |
|                         Storage.shared.setContact(contact, using: transaction)
 | |
|                     }
 | |
|                 }
 | |
|                 
 | |
|                 // Delete all thread content
 | |
|                 self?.thread.removeAllThreadInteractions(with: transaction)
 | |
|                 self?.thread.remove(with: transaction)
 | |
|             },
 | |
|             completion: { [weak self] in
 | |
|                 // Block the contact
 | |
|                 if let sessionId: String = (self?.thread as? TSContactThread)?.contactSessionID(), !OWSBlockingManager.shared().isRecipientIdBlocked(sessionId) {
 | |
|                     OWSBlockingManager.shared().addBlockedPhoneNumber(sessionId)
 | |
|                 }
 | |
|                 
 | |
|                 // Force a config sync and pop to the previous screen (both must run on the main thread)
 | |
|                 DispatchQueue.main.async {
 | |
|                     if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
 | |
|                         appDelegate.forceSyncConfigurationNowIfNeeded().retainUntilComplete()
 | |
|                     }
 | |
|                     
 | |
|                     self?.navigationController?.popViewController(animated: true)
 | |
|                 }
 | |
|             }
 | |
|         )
 | |
|     }
 | |
| }
 |