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.
		
		
		
		
		
			
		
			
				
	
	
		
			745 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			745 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			Swift
		
	
| import CoreServices
 | |
| import Photos
 | |
| 
 | |
| 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() {
 | |
|         scrollToBottom(isAnimated: true)
 | |
|     }
 | |
| 
 | |
|     // MARK: Blocking
 | |
|     @objc func unblock() {
 | |
|         guard let thread = thread as? TSContactThread else { return }
 | |
|         let publicKey = thread.contactIdentifier()
 | |
|         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.contactIdentifier()
 | |
|         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 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 ?? "")
 | |
|         scrollToBottom(isAnimated: false)
 | |
|         resetMentions()
 | |
|         self.snInputView.text = ""
 | |
|         dismiss(animated: true) { }
 | |
|     }
 | |
| 
 | |
|     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() {
 | |
|         // FIXME: We're not yet handling the case where the user only gives access to selected photos/videos
 | |
|         guard requestLibraryPermissionIfNeeded() else { return }
 | |
|         let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst()
 | |
|         sendMediaNavController.sendMediaNavDelegate = self
 | |
|         sendMediaNavController.modalPresentationStyle = .fullScreen
 | |
|         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)
 | |
|                     }
 | |
|                 }
 | |
|             }.retainUntilComplete()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Message Sending
 | |
|     func handleSendButtonTapped() {
 | |
|         sendMessage()
 | |
|     }
 | |
| 
 | |
|     func sendMessage() {
 | |
|         guard !showBlockedModalIfNeeded() else { return }
 | |
|         let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
 | |
|         let thread = self.thread
 | |
|         guard !text.isEmpty else { return }
 | |
|         let message = VisibleMessage()
 | |
|         message.sentTimestamp = NSDate.millisecondTimestamp()
 | |
|         message.text = text
 | |
|         message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model)
 | |
|         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 { transaction in
 | |
|                 tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction)
 | |
|             }
 | |
|             Storage.shared.write { transaction in
 | |
|                 MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction)
 | |
|             }
 | |
|             self?.handleMessageSent()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     func sendAttachments(_ attachments: [SignalAttachment], with text: String) {
 | |
|         guard !showBlockedModalIfNeeded() else { return }
 | |
|         for attachment in attachments {
 | |
|             if attachment.hasError {
 | |
|                 return showErrorAlert(for: attachment)
 | |
|             }
 | |
|         }
 | |
|         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()
 | |
|         })
 | |
|     }
 | |
| 
 | |
|     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 a whitespace before the '@' or the '@' is the first character
 | |
|             let isCharacterBeforeLastAtSignOrStartOfLine: Bool
 | |
|             if newText.count == 1 {
 | |
|                 isCharacterBeforeLastAtSignOrStartOfLine = true // Start of line
 | |
|             } else {
 | |
|                 let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)]
 | |
|                 isCharacterBeforeLastAtSignOrStartOfLine = (characterBeforeLast == "@")
 | |
|             }
 | |
|             if lastCharacter == "@" && isCharacterBeforeLastAtSignOrStartOfLine {
 | |
|                 let candidates = MentionsManager.getMentionCandidates(for: "", in: thread.uniqueId!)
 | |
|                 currentMentionStartIndex = lastCharacterIndex
 | |
|                 snInputView.showMentionsUI(for: candidates, in: thread)
 | |
|             } else if lastCharacter.isWhitespace {
 | |
|                 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
 | |
|     }
 | |
| 
 | |
|     // 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) {
 | |
|         if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
 | |
|             // Show the failed message sheet
 | |
|             showFailedMessageSheet(for: message)
 | |
|         } else {
 | |
|             switch viewItem.messageCellType {
 | |
|             case .audio: 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, 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:
 | |
|                 // 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)
 | |
|                 }
 | |
|             default: break
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func showFailedMessageSheet(for tsMessage: TSOutgoingMessage) {
 | |
|         let thread = self.thread
 | |
|         let sheet = UIAlertController(title: tsMessage.mostRecentFailureText, 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)
 | |
|             }
 | |
|         }))
 | |
|         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) {
 | |
|         viewItem.deleteAction()
 | |
|     }
 | |
|     
 | |
|     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 alert = UIAlertController(title: "Ban This User?", message: nil, preferredStyle: .alert)
 | |
|         let threadID = thread.uniqueId!
 | |
|         alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in
 | |
|             guard let openGroup = Storage.shared.getOpenGroup(for: threadID) else { return }
 | |
|             let publicKey = message.authorId
 | |
|             OpenGroupAPI.ban(publicKey, from: openGroup.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 urlModal = URLModal(url: url)
 | |
|         urlModal.modalPresentationStyle = .overFullScreen
 | |
|         urlModal.modalTransitionStyle = .crossDissolve
 | |
|         present(urlModal, animated: true, completion: nil)
 | |
|     }
 | |
|     
 | |
|     func handleReplyButtonTapped(for viewItem: ConversationViewItem) {
 | |
|         reply(viewItem)
 | |
|     }
 | |
| 
 | |
|     // 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()
 | |
|         }
 | |
|         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: 60, 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() {
 | |
|         // 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)
 | |
|         }
 | |
|         // 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() {
 | |
|         // Disabled until other platforms implement it as well
 | |
|         /*
 | |
|         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) {
 | |
|         // Disabled until other platforms implement it as well
 | |
|         /*
 | |
|         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() -> Bool {
 | |
|         switch PHPhotoLibrary.authorizationStatus() {
 | |
|         case .authorized, .limited: return true
 | |
|         case .denied, .restricted:
 | |
|             let modal = PermissionMissingModal(permission: "library") { }
 | |
|             modal.modalPresentationStyle = .overFullScreen
 | |
|             modal.modalTransitionStyle = .crossDissolve
 | |
|             present(modal, animated: true, completion: nil)
 | |
|             return false
 | |
|         case .notDetermined:
 | |
|             PHPhotoLibrary.requestAuthorization { _ in }
 | |
|             return false
 | |
|         default: return false
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: Convenience
 | |
|     func showErrorAlert(for attachment: SignalAttachment) {
 | |
|         let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "")
 | |
|         let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage
 | |
|         OWSAlerts.showAlert(title: title, message: message)
 | |
|     }
 | |
| }
 |