session-ios/Session/Conversations/ConversationVC+Interaction....

824 lines
39 KiB
Swift
Raw Normal View History

import CoreServices
import Photos
2021-04-07 08:47:39 +02:00
import PhotosUI
2021-02-17 00:06:17 +01:00
extension ConversationVC : InputViewDelegate, MessageCellDelegate, ContextMenuActionDelegate, ScrollToBottomButtonDelegate,
2021-03-01 05:15:37 +01:00
SendMediaNavDelegate, UIDocumentPickerDelegate, AttachmentApprovalViewControllerDelegate, GifPickerViewControllerDelegate,
ConversationTitleViewDelegate {
2021-01-29 01:46:32 +01:00
2021-03-01 05:15:37 +01:00
func handleTitleViewTapped() {
openSettings()
}
2021-01-29 01:46:32 +01:00
@objc func openSettings() {
let settingsVC = OWSConversationSettingsViewController()
settingsVC.configure(with: thread, uiDatabaseConnection: OWSPrimaryStorage.shared().uiDatabaseConnection)
2021-02-19 00:50:18 +01:00
settingsVC.conversationSettingsViewDelegate = self
2021-01-29 01:46:32 +01:00
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.contactSessionID()
UIView.animate(withDuration: 0.25, animations: {
self.blockedBanner.alpha = 0
}, completion: { _ in
OWSBlockingManager.shared().removeBlockedPhoneNumber(publicKey)
})
}
2021-02-19 00:50:18 +01:00
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 sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) {
dismiss(animated: true, completion: nil)
}
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
sendAttachments(attachments, with: messageText ?? "")
2021-02-17 04:26:43 +01:00
resetMentions()
2021-02-17 00:06:17 +01:00
self.snInputView.text = ""
dismiss(animated: true) { }
}
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
return snInputView.text
}
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) {
snInputView.text = newMessageText ?? ""
}
2021-02-17 00:06:17 +01:00
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didApproveAttachments attachments: [SignalAttachment], messageText: String?) {
sendAttachments(attachments, with: messageText ?? "")
scrollToBottom(isAnimated: false)
2021-02-17 04:26:43 +01:00
resetMentions()
2021-02-17 00:06:17 +01:00
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 ?? ""
}
2021-01-29 01:46:32 +01:00
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)
2021-01-29 01:46:32 +01:00
}
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)
}
}
2021-01-29 01:46:32 +01:00
}
func handleGIFButtonTapped() {
2021-02-17 00:23:50 +01:00
let gifVC = GifPickerViewController(thread: thread)
gifVC.delegate = self
let navController = OWSNavigationController(rootViewController: gifVC)
2021-02-18 01:02:19 +01:00
navController.modalPresentationStyle = .fullScreen
2021-02-17 00:23:50 +01:00
present(navController, animated: true) { }
}
func gifPickerDidSelect(attachment: SignalAttachment) {
showAttachmentApprovalDialog(for: [ attachment ])
2021-01-29 01:46:32 +01:00
}
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
2021-02-17 00:06:17 +01:00
documentPickerVC.modalPresentationStyle = .fullScreen
2021-02-17 00:14:48 +01:00
SNAppearance.switchToDocumentPickerAppearance()
present(documentPickerVC, animated: true, completion: nil)
2021-01-29 01:46:32 +01:00
}
2021-02-16 22:01:54 +01:00
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
2021-03-02 00:18:08 +01:00
SNAppearance.switchToSessionAppearance() // Switch back to the correct appearance
2021-02-16 22:01:54 +01:00
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
2021-02-17 00:14:48 +01:00
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 ])
}
2021-02-19 00:50:18 +01:00
func showAttachmentApprovalDialog(for attachments: [SignalAttachment]) {
let navController = AttachmentApprovalViewController.wrappedInNavController(attachments: attachments, approvalDelegate: self)
present(navController, animated: true, completion: nil)
}
2021-02-19 00:50:18 +01:00
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 {
2021-02-17 00:06:17 +01:00
self?.showAttachmentApprovalDialog(for: [ attachment ])
} else {
self?.showErrorAlert(for: attachment)
}
}
}.retainUntilComplete()
}
}
// MARK: Message Sending
2021-01-29 01:46:32 +01:00
func handleSendButtonTapped() {
sendMessage()
}
func sendMessage() {
2021-02-16 22:01:54 +01:00
guard !showBlockedModalIfNeeded() else { return }
2021-02-17 04:26:43 +01:00
let text = replaceMentions(in: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines))
2021-01-29 01:46:32 +01:00
let thread = self.thread
guard !text.isEmpty else { return }
let message = VisibleMessage()
message.sentTimestamp = NSDate.millisecondTimestamp()
message.text = text
2021-02-10 04:46:59 +01:00
message.quote = VisibleMessage.Quote.from(snInputView.quoteDraftInfo?.model)
2021-02-15 04:45:46 +01:00
let linkPreviewDraft = snInputView.linkPreviewInfo?.draft
2021-01-29 01:46:32 +01:00
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
viewModel.appendUnsavedOutgoingTextMessage(tsMessage)
2021-02-15 04:45:46 +01:00
Storage.write(with: { transaction in
message.linkPreview = VisibleMessage.LinkPreview.from(linkPreviewDraft, using: transaction)
2021-01-29 01:46:32 +01:00
}, completion: { [weak self] in
2021-02-15 04:45:46 +01:00
tsMessage.linkPreview = OWSLinkPreview.from(message.linkPreview)
2021-03-30 04:55:18 +02:00
Storage.shared.write(with: { transaction in
2021-01-29 01:46:32 +01:00
tsMessage.save(with: transaction as! YapDatabaseReadWriteTransaction)
2021-03-30 04:55:18 +02:00
}, 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)
})
2021-01-29 01:46:32 +01:00
Storage.shared.write { transaction in
MessageSender.send(message, with: [], in: thread, using: transaction as! YapDatabaseReadWriteTransaction)
}
2021-02-16 22:01:54 +01:00
self?.handleMessageSent()
2021-01-29 01:46:32 +01:00
})
}
2021-02-16 22:01:54 +01:00
func sendAttachments(_ attachments: [SignalAttachment], with text: String) {
guard !showBlockedModalIfNeeded() else { return }
for attachment in attachments {
if attachment.hasError {
return showErrorAlert(for: attachment)
2021-02-16 22:01:54 +01:00
}
}
let thread = self.thread
let message = VisibleMessage()
message.sentTimestamp = NSDate.millisecondTimestamp()
2021-02-17 04:26:43 +01:00
message.text = replaceMentions(in: text)
2021-02-16 22:01:54 +01:00
let tsMessage = TSOutgoingMessage.from(message, associatedWith: thread)
Storage.write(with: { transaction in
tsMessage.save(with: transaction)
2021-03-19 06:53:27 +01:00
// The new message cell is inserted at this point, but the TSOutgoingMessage doesn't have its attachment yet
2021-02-16 22:01:54 +01:00
}, completion: { [weak self] in
Storage.write(with: { transaction in
2021-02-16 22:01:54 +01:00
MessageSender.send(message, with: attachments, in: thread, using: transaction)
}, completion: { [weak self] in
2021-03-19 06:53:27 +01:00
// 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)
})
2021-02-16 22:01:54 +01:00
self?.handleMessageSent()
})
}
func handleMessageSent() {
2021-02-17 04:26:43 +01:00
resetMentions()
2021-02-16 22:01:54 +01:00
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)
2021-03-01 23:33:31 +01:00
Storage.write { transaction in
self.thread.setDraft("", transaction: transaction)
}
2021-02-16 22:01:54 +01:00
}
2021-02-17 04:26:43 +01:00
// MARK: Input View
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
let newText = inputTextView.text ?? ""
if !newText.isEmpty {
SSKEnvironment.shared.typingIndicators.didStartTypingOutgoingInput(inThread: thread)
}
updateMentions(for: newText)
}
2021-02-17 05:57:07 +01:00
func showLinkPreviewSuggestionModal() {
let linkPreviewModel = LinkPreviewModal() { [weak self] in
self?.snInputView.autoGenerateLinkPreview()
}
linkPreviewModel.modalPresentationStyle = .overFullScreen
linkPreviewModel.modalTransitionStyle = .crossDissolve
present(linkPreviewModel, animated: true, completion: nil)
}
2021-02-17 04:26:43 +01:00
// MARK: Mentions
2021-02-19 00:50:18 +01:00
func updateMentions(for newText: String) {
2021-02-17 04:26:43 +01:00
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
2021-05-07 02:10:25 +02:00
let isCharacterBeforeLastWhiteSpaceOrStartOfLine: Bool
2021-02-17 04:26:43 +01:00
if newText.count == 1 {
2021-05-07 02:10:25 +02:00
isCharacterBeforeLastWhiteSpaceOrStartOfLine = true // Start of line
2021-02-17 04:26:43 +01:00
} else {
let characterBeforeLast = newText[newText.index(before: lastCharacterIndex)]
2021-05-07 02:10:25 +02:00
isCharacterBeforeLastWhiteSpaceOrStartOfLine = characterBeforeLast.isWhitespace
2021-02-17 04:26:43 +01:00
}
2021-05-07 02:10:25 +02:00
if lastCharacter == "@" && isCharacterBeforeLastWhiteSpaceOrStartOfLine {
2021-02-17 04:26:43 +01:00
let candidates = MentionsManager.getMentionCandidates(for: "", in: thread.uniqueId!)
currentMentionStartIndex = lastCharacterIndex
snInputView.showMentionsUI(for: candidates, in: thread)
2021-05-07 02:10:25 +02:00
} else if lastCharacter.isWhitespace || lastCharacter == "@" { // the lastCharacter == "@" is to check for @@
2021-02-17 04:26:43 +01:00
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
}
2021-02-19 00:50:18 +01:00
func resetMentions() {
2021-02-17 04:26:43 +01:00
oldText = ""
currentMentionStartIndex = nil
mentions = []
}
2021-02-19 00:50:18 +01:00
func replaceMentions(in text: String) -> String {
2021-02-17 04:26:43 +01:00
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
2021-01-29 01:46:32 +01:00
func handleViewItemLongPressed(_ viewItem: ConversationViewItem) {
2021-03-02 00:18:08 +01:00
// Show the context menu if applicable
2021-01-29 01:46:32 +01:00
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()
2021-02-17 06:27:35 +01:00
let contextMenuVC = ContextMenuVC(snapshot: snapshot, viewItem: viewItem, frame: frame, delegate: self) { [weak self] in
2021-01-29 01:46:32 +01:00
window.isHidden = true
2021-02-17 06:27:35 +01:00
guard let self = self else { return }
2021-01-29 01:46:32 +01:00
self.contextMenuVC = nil
self.contextMenuWindow = nil
2021-02-17 06:27:35 +01:00
self.scrollButton.alpha = 0
UIView.animate(withDuration: 0.25) {
self.scrollButton.alpha = self.getScrollButtonOpacity()
self.unreadCountView.alpha = self.scrollButton.alpha
2021-02-17 06:27:35 +01:00
}
2021-01-29 01:46:32 +01:00
}
self.contextMenuVC = contextMenuVC
contextMenuWindow = window
window.rootViewController = contextMenuVC
window.makeKeyAndVisible()
window.backgroundColor = .clear
}
func handleViewItemTapped(_ viewItem: ConversationViewItem, gestureRecognizer: UITapGestureRecognizer) {
2021-04-08 07:32:36 +02:00
func confirmDownload() {
let modal = DownloadAttachmentModal(viewItem: viewItem)
modal.modalPresentationStyle = .overFullScreen
modal.modalTransitionStyle = .crossDissolve
present(modal, animated: true, completion: nil)
}
2021-02-15 05:42:16 +01:00
if let message = viewItem.interaction as? TSOutgoingMessage, message.messageState == .failed {
2021-03-02 00:18:08 +01:00
// Show the failed message sheet
2021-02-15 05:42:16 +01:00
showFailedMessageSheet(for: message)
} else {
switch viewItem.messageCellType {
2021-04-08 07:32:36 +02:00
case .audio:
if viewItem.interaction is TSIncomingMessage,
let thread = self.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true {
2021-04-08 07:32:36 +02:00
confirmDownload()
} else {
playOrPauseAudio(for: viewItem)
}
2021-02-15 05:42:16 +01:00
case .mediaMessage:
guard let index = viewItems.firstIndex(where: { $0 === viewItem }),
let cell = messagesTableView.cellForRow(at: IndexPath(row: index, section: 0)) as? VisibleMessageCell else { return }
2021-04-08 07:08:34 +02:00
if viewItem.interaction is TSIncomingMessage,
let thread = self.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true {
2021-04-08 07:32:36 +02:00
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
}
2021-02-15 05:42:16 +01:00
}
// 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() {
2021-02-15 05:42:16 +01:00
// 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)
2021-02-15 05:42:16 +01:00
}
case .genericAttachment:
2021-04-08 07:32:36 +02:00
if viewItem.interaction is TSIncomingMessage,
let thread = self.thread as? TSContactThread,
Storage.shared.getContact(with: thread.contactSessionID())?.isTrusted != true {
2021-04-08 07:32:36 +02:00
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)
}
2021-02-15 05:42:16 +01:00
case .textOnlyMessage:
2021-02-19 04:43:49 +01:00
if let preview = viewItem.linkPreview, let urlAsString = preview.urlString, let url = URL(string: urlAsString) {
2021-03-02 00:18:08 +01:00
// Open the link preview URL
2021-02-19 04:43:49 +01:00
openURL(url)
} else if let reply = viewItem.quotedReply {
2021-03-02 00:18:08 +01:00
// Scroll to the source of the reply
2021-02-19 04:43:49 +01:00
guard let indexPath = viewModel.ensureLoadWindowContainsQuotedReply(reply) else { return }
messagesTableView.scrollToRow(at: indexPath, at: UITableView.ScrollPosition.middle, animated: true)
2021-05-07 07:18:57 +02:00
} else if let message = viewItem.interaction as? TSIncomingMessage, let name = message.openGroupInvitationName,
let url = message.openGroupInvitationURL {
joinOpenGroup(name: name, url: url)
2021-02-19 04:43:49 +01:00
}
2021-02-15 05:42:16 +01:00
default: break
2021-01-29 01:46:32 +01:00
}
}
}
2021-02-17 05:57:07 +01:00
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)
}
2021-01-29 01:46:32 +01:00
func handleViewItemDoubleTapped(_ viewItem: ConversationViewItem) {
switch viewItem.messageCellType {
2021-03-02 00:18:08 +01:00
case .audio: speedUpAudio(for: viewItem) // The user can double tap a voice message when it's playing to speed it up
2021-01-29 01:46:32 +01:00
default: break
}
}
func showFullText(_ viewItem: ConversationViewItem) {
let longMessageVC = LongTextViewController(viewItem: viewItem)
navigationController!.pushViewController(longMessageVC, animated: true)
}
func reply(_ viewItem: ConversationViewItem) {
2021-02-10 04:43:57 +01:00
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()
2021-01-29 01:46:32 +01:00
}
func copy(_ viewItem: ConversationViewItem) {
if viewItem.canCopyMedia() {
viewItem.copyMediaAction()
} else {
viewItem.copyTextAction()
}
}
func copySessionID(_ viewItem: ConversationViewItem) {
2021-03-02 00:18:08 +01:00
// FIXME: Copying media
2021-01-29 01:46:32 +01:00
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)
2021-01-29 01:46:32 +01:00
}
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
let publicKey = message.authorId
2021-05-05 06:22:29 +02:00
guard let openGroupV2 = Storage.shared.getV2OpenGroup(for: threadID) else { return }
OpenGroupAPIV2.ban(publicKey, from: openGroupV2.room, on: openGroupV2.server).retainUntilComplete()
2021-01-29 01:46:32 +01:00
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
2021-02-10 07:04:26 +01:00
func handleQuoteViewCancelButtonTapped() {
snInputView.quoteDraftInfo = nil
}
2021-02-11 04:24:38 +01:00
func openURL(_ url: URL) {
2021-03-02 00:18:08 +01:00
// URLs can be unsafe, so always ask the user whether they want to open one
2021-02-11 04:24:38 +01:00
let urlModal = URLModal(url: url)
urlModal.modalPresentationStyle = .overFullScreen
urlModal.modalTransitionStyle = .crossDissolve
present(urlModal, animated: true, completion: nil)
}
2021-02-12 01:14:01 +01:00
2021-05-07 07:18:57 +02:00
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)
}
2021-02-12 01:14:01 +01:00
func handleReplyButtonTapped(for viewItem: ConversationViewItem) {
reply(viewItem)
}
// MARK: Voice Message Playback
2021-02-17 05:57:07 +01:00
@objc func handleAudioDidFinishPlayingNotification(_ notification: Notification) {
2021-03-02 00:18:08 +01:00
// Play the next voice message if there is one
2021-02-17 05:57:07 +01:00
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()
}
2021-02-16 09:28:32 +01:00
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
2021-02-16 09:28:32 +01:00
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
2021-02-16 22:01:54 +01:00
sendAttachments([ attachment ], with: "")
}
func cancelVoiceMessageRecording() {
snInputView.hideVoiceMessageUI()
audioTimer?.invalidate()
stopVoiceMessageRecording()
audioRecorder = nil
}
func stopVoiceMessageRecording() {
audioRecorder?.stop()
audioSession.endAudioActivity(recordVoiceMessageActivity)
}
2021-03-02 04:59:07 +01:00
2021-03-02 05:14:00 +01:00
// MARK: Data Extraction Notifications
2021-03-02 04:59:07 +01:00
@objc func sendScreenshotNotificationIfNeeded() {
// Disabled until other platforms implement it as well
/*
2021-03-02 04:59:07 +01:00
guard thread is TSContactThread else { return }
let message = DataExtractionNotification()
message.kind = .screenshot
Storage.write { transaction in
MessageSender.send(message, in: self.thread, using: transaction)
}
*/
2021-03-02 04:59:07 +01:00
}
2021-03-02 05:14:00 +01:00
func sendMediaSavedNotificationIfNeeded(for viewItem: ConversationViewItem) {
// Disabled until other platforms implement it as well
/*
guard thread is TSContactThread, viewItem.interaction.interactionType() == .incomingMessage else { return }
2021-03-02 05:14:00 +01:00
let message = DataExtractionNotification()
message.kind = .mediaSaved(timestamp: viewItem.interaction.timestamp)
2021-03-02 05:14:00 +01:00
Storage.write { transaction in
MessageSender.send(message, in: self.thread, using: transaction)
}
*/
2021-03-02 05:14:00 +01:00
}
// 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
}
}
2021-04-08 08:14:25 +02:00
func requestLibraryPermissionIfNeeded(onAuthorized: @escaping () -> Void) {
2021-04-07 08:47:39 +02:00
let authorizationStatus: PHAuthorizationStatus
if #available(iOS 14, *) {
authorizationStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite)
2021-04-08 08:03:52 +02:00
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.
2021-04-08 08:03:52 +02:00
// 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
2021-04-08 08:14:25 +02:00
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
2021-04-08 08:14:25 +02:00
DispatchQueue.main.async {
AppModeManager.shared.setCurrentAppMode(to: appMode)
}
Environment.shared.isRequestingPermission = false
2021-04-08 08:03:52 +02:00
if [ PHAuthorizationStatus.authorized, PHAuthorizationStatus.limited ].contains(status) {
2021-04-08 08:14:25 +02:00
onAuthorized()
2021-04-07 08:47:39 +02:00
}
}
}
} else {
authorizationStatus = PHPhotoLibrary.authorizationStatus()
2021-04-08 08:03:52 +02:00
if authorizationStatus == .notDetermined {
PHPhotoLibrary.requestAuthorization { status in
2021-04-08 08:03:52 +02:00
if status == .authorized {
2021-04-08 08:14:25 +02:00
onAuthorized()
}
}
2021-04-07 08:47:39 +02:00
}
}
switch authorizationStatus {
case .authorized, .limited:
2021-04-08 08:14:25 +02:00
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) {
let title = NSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "")
let message = attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage
OWSAlerts.showAlert(title: title, message: message)
}
2021-01-29 01:46:32 +01:00
}