diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 91722a794..7744b88ab 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -251,7 +251,6 @@ B8F5F56525EC8453003BF8D4 /* Notification+Contacts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */; }; B8F5F58325EC94A6003BF8D4 /* Collection+Subscripting.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */; }; B8F5F60325EDE16F003BF8D4 /* DataExtractionNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */; }; - B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */; }; B8F5F71A25F1B35C003BF8D4 /* MediaPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */; }; B8F5F72325F1B4CA003BF8D4 /* DownloadAttachmentModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */; }; B8FF8DAE25C0D00F004D1F22 /* SessionMessagingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C3C2A6F025539DE700C340D1 /* SessionMessagingKit.framework */; }; @@ -722,6 +721,7 @@ FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */; }; FD09C5E828264937000CE219 /* MediaDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E728264937000CE219 /* MediaDetailViewController.swift */; }; FD09C5EA282A1BB2000CE219 /* ThreadTypingIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */; }; + FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD09C5EB282B8F17000CE219 /* AttachmentError.swift */; }; FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */; }; FD17D79C27F40B2E00122BE0 /* SMKLegacyModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */; }; FD17D7A027F40CC800122BE0 /* _001_InitialSetupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D79F27F40CC800122BE0 /* _001_InitialSetupMigration.swift */; }; @@ -1278,7 +1278,6 @@ B8F5F56425EC8453003BF8D4 /* Notification+Contacts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Contacts.swift"; sourceTree = ""; }; B8F5F58225EC94A6003BF8D4 /* Collection+Subscripting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Subscripting.swift"; sourceTree = ""; }; B8F5F60225EDE16F003BF8D4 /* DataExtractionNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotification.swift; sourceTree = ""; }; - B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtractionNotificationInfoMessage.swift; sourceTree = ""; }; B8F5F71925F1B35C003BF8D4 /* MediaPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlaceholderView.swift; sourceTree = ""; }; B8F5F72225F1B4CA003BF8D4 /* DownloadAttachmentModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadAttachmentModal.swift; sourceTree = ""; }; B8FF8E6125C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 */ = {isa = PBXFileReference; lastKnownFileType = file.bplist; name = "GeoLite2-Country-Blocks-IPv4"; path = "Countries/GeoLite2-Country-Blocks-IPv4"; sourceTree = ""; }; @@ -1771,6 +1770,7 @@ FD09C5E528260FF9000CE219 /* MediaGalleryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryViewModel.swift; sourceTree = ""; }; FD09C5E728264937000CE219 /* MediaDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaDetailViewController.swift; sourceTree = ""; }; FD09C5E9282A1BB2000CE219 /* ThreadTypingIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadTypingIndicator.swift; sourceTree = ""; }; + FD09C5EB282B8F17000CE219 /* AttachmentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentError.swift; sourceTree = ""; }; FD17D79527F3E04600122BE0 /* _001_InitialSetupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _001_InitialSetupMigration.swift; sourceTree = ""; }; FD17D79827F40AB800122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = ""; }; FD17D79B27F40B2E00122BE0 /* SMKLegacyModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMKLegacyModels.swift; sourceTree = ""; }; @@ -2439,14 +2439,6 @@ path = Shared; sourceTree = ""; }; - B8F5F61925EDE4B0003BF8D4 /* Data Extraction */ = { - isa = PBXGroup; - children = ( - B8F5F61A25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift */, - ); - path = "Data Extraction"; - sourceTree = ""; - }; B8FF8E6025C10D8B004D1F22 /* Countries */ = { isa = PBXGroup; children = ( @@ -2503,7 +2495,6 @@ children = ( FDF0B7562807F35E004C14C5 /* Errors */, C3D9E3B52567685D0040E4F3 /* Attachments */, - B8F5F61925EDE4B0003BF8D4 /* Data Extraction */, C32C5D22256DD496003C73A2 /* Link Previews */, C379DC6825672B5E0002D4EB /* Notifications */, C32C59F8256DB5A6003C73A2 /* Pollers */, @@ -3759,6 +3750,7 @@ children = ( FDF0B7572807F368004C14C5 /* MessageReceiverError.swift */, FDF0B7592807F3A3004C14C5 /* MessageSenderError.swift */, + FD09C5EB282B8F17000CE219 /* AttachmentError.swift */, ); path = Errors; sourceTree = ""; @@ -4877,7 +4869,6 @@ FDA8EAFE280E8B78002B68E5 /* FailedMessagesJob.swift in Sources */, C3BBE0802554CDD70050F1E3 /* Storage.swift in Sources */, C3DB66AC260ACA42001EFC55 /* OpenGroupManagerV2.swift in Sources */, - B8F5F61B25EDE4BF003BF8D4 /* DataExtractionNotificationInfoMessage.swift in Sources */, C379DCF4256735770002D4EB /* VisibleMessage+Attachment.swift in Sources */, FD09797F27FCFBFF00936362 /* OWSAES256Key+Utilities.swift in Sources */, FD09798327FD1A1500936362 /* ClosedGroup.swift in Sources */, @@ -4890,6 +4881,7 @@ FDF0B7552807C4BB004C14C5 /* SSKEnvironment.swift in Sources */, C32C59C3256DB41F003C73A2 /* TSGroupModel.m in Sources */, B8856ECE256F1E58001CE70E /* OWSPreferences.m in Sources */, + FD09C5EC282B8F18000CE219 /* AttachmentError.swift in Sources */, FD17D79927F40AB800122BE0 /* _003_YDBToGRDBMigration.swift in Sources */, C3C2A7842553AAF300C340D1 /* SNProto.swift in Sources */, FDF0B7512807BA56004C14C5 /* NotificationsProtocol.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index b9f9c3372..40ea11916 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -85,8 +85,15 @@ extension ContextMenuVC { ) ) let canSave: Bool = ( - item.cellType != .textOnlyMessage && - canCopy + item.cellType == .mediaMessage && + (item.attachments ?? []) + .filter { attachment in + attachment.isValid && + attachment.isVisualMedia && ( + attachment.state == .downloaded || + attachment.state == .uploaded + ) + }.isEmpty == false ) let canCopySessionId: Bool = ( item.interactionVariant == .standardIncoming && diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 36a28d0e2..0ac633d1f 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -741,41 +741,6 @@ extension ConversationVC: } } - 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.. 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 } - OpenGroupAPIV2.ban(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 } - OpenGroupAPIV2.banAndDeleteAllMessages(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 + let alertVC = UIAlertController.init( + title: "modal_open_url_title".localized(), + message: String(format: "modal_open_url_explanation".localized(), url.absoluteString), + preferredStyle: .actionSheet + ) + alertVC.addAction(UIAlertAction.init(title: "modal_open_url_button_title".localized(), style: .default) { [weak self] _ 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 + self?.showInputAccessoryView() + }) + alertVC.addAction(UIAlertAction.init(title: "modal_copy_url_button_title".localized(), style: .default) { [weak self] _ 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?.showInputAccessoryView() + }) + alertVC.addAction(UIAlertAction.init(title: "cancel".localized(), style: .cancel) { [weak self] _ in + self?.showInputAccessoryView() + }) + 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 item: ConversationViewModel.Item) { reply(item) } @@ -966,93 +786,450 @@ extension ConversationVC: 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 + // MARK: --action handling + + + func showFailedMessageSheet(for item: ConversationViewModel.Item) { + let sheet = UIAlertController(title: item.mostRecentFailureText, message: nil, preferredStyle: .actionSheet) + sheet.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) + sheet.addAction(UIAlertAction(title: "Delete", style: .destructive, handler: { _ in + GRDBStorage.shared.writeAsync { db in + try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + } + })) + sheet.addAction(UIAlertAction(title: "Resend", style: .default, handler: { _ in + GRDBStorage.shared.writeAsync { [weak self] db in + guard + let interaction: Interaction = try? Interaction.fetchOne(db, id: item.interactionId), + let thread: SessionThread = self?.viewModel.viewData.thread + else { return } + try MessageSender.send( + db, + interaction: interaction, + in: thread + ) + } + })) + + // HACK: Extracting this info from the error string is pretty dodgy + let prefix: String = "HTTP request failed at destination (Service node " + if let mostRecentFailureText: String = item.mostRecentFailureText, mostRecentFailureText.hasPrefix(prefix) { + let rest = mostRecentFailureText.substring(from: prefix.count) + + if let index = rest.firstIndex(of: ")") { + let snodeAddress = String(rest[rest.startIndex.., onComplete: (() -> ())?) { + // Show a loading indicator + let (promise, seal) = Promise.pending() + + ModalActivityIndicatorViewController.present(fromViewController: viewController, canCancel: false) { _ in + seal.fulfill(()) + } + + promise + .then { _ -> Promise in request } + .done { _ in + // Delete the interaction (and associated data) from the database + GRDBStorage.shared.writeAsync { db in + _ = try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + } + } + .ensure { + DispatchQueue.main.async { [weak self] in + if self?.presentedViewController is ModalActivityIndicatorViewController { + self?.dismiss(animated: true, completion: nil) // Dismiss the loader + } + + onComplete?() + } + } + .retainUntilComplete() + } + + // How we delete the message differs depending on the type of thread + switch item.threadVariant { + // Handle open group messages the old way + case .openGroup: + // If it's an incoming message the user must have moderator status + let result: (openGroupServerMessageId: Int64?, openGroup: OpenGroup?)? = GRDBStorage.shared.read { db -> (Int64?, OpenGroup?) in + ( + try Interaction + .select(.openGroupServerMessageId) + .filter(id: item.interactionId) + .asRequest(of: Int64.self) + .fetchOne(db), + try OpenGroup.fetchOne(db, id: thread.id) + ) + } + + guard + let openGroup: OpenGroup = result?.openGroup, + let openGroupServerMessageId: Int64 = result?.openGroupServerMessageId, ( + item.interactionVariant != .standardIncoming || + OpenGroupAPIV2.isUserModerator(userPublicKey, for: openGroup.room, on: openGroup.server) + ) + else { return } + + // Delete the message from the open group + deleteRemotely( + from: self, + request: OpenGroupAPIV2.deleteMessage( + with: openGroupServerMessageId, + from: openGroup.room, + on: openGroup.server + ) + ) { [weak self] in + self?.showInputAccessoryView() + } + + case .contact, .closedGroup: + let serverHash: String? = GRDBStorage.shared.read { db -> String? in + try Interaction + .select(.serverHash) + .filter(id: item.interactionId) + .asRequest(of: String.self) + .fetchOne(db) + } + let unsendRequest: UnsendRequest = UnsendRequest( + timestamp: UInt64(item.timestampMs), + author: (item.interactionVariant == .standardOutgoing ? + userPublicKey : + item.authorId + ) + ) + + // For incoming interactions or interactions with no serverHash just delete them locally + guard item.interactionVariant == .standardOutgoing, let serverHash: String = serverHash else { + GRDBStorage.shared.writeAsync { db in + _ = try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + + // No need to send the unsendRequest if there is no serverHash (ie. the message + // was outgoing but never got to the server) + guard serverHash != nil else { return } + + MessageSender + .send( + db, + message: unsendRequest, + threadId: thread.id, + interactionId: nil, + to: .contact(publicKey: userPublicKey) + ) + } + return + } + + let alertVC = UIAlertController.init(title: nil, message: nil, preferredStyle: .actionSheet) + alertVC.addAction(UIAlertAction(title: "delete_message_for_me".localized(), style: .destructive) { [weak self] _ in + GRDBStorage.shared.writeAsync { db in + _ = try Interaction + .filter(id: item.interactionId) + .deleteAll(db) + + MessageSender + .send( + db, + message: unsendRequest, + threadId: thread.id, + interactionId: nil, + to: .contact(publicKey: userPublicKey) + ) + } + self?.showInputAccessoryView() + }) + + alertVC.addAction(UIAlertAction( + title: (item.threadVariant == .closedGroup ? + "delete_message_for_everyone".localized() : + String(format: "delete_message_for_me_and_recipient".localized(), threadName) + ), + style: .destructive + ) { [weak self] _ in + deleteRemotely( + from: self, + request: SnodeAPI + .deleteMessage( + publicKey: thread.id, + serverHashes: [serverHash] + ) + .map { _ in () } + ) { [weak self] in + GRDBStorage.shared.writeAsync { db in + try MessageSender + .send( + db, + message: unsendRequest, + interactionId: nil, + in: thread + ) + } + + self?.showInputAccessoryView() + } + }) + + alertVC.addAction(UIAlertAction.init(title: "TXT_CANCEL_TITLE".localized(), style: .cancel) { [weak self] _ in + self?.showInputAccessoryView() + }) + + self.inputAccessoryView?.isHidden = true + self.inputAccessoryView?.alpha = 0 + self.presentAlert(alertVC) + } + } + + func save(_ item: ConversationViewModel.Item) { + guard item.cellType == .mediaMessage else { return } + + let mediaAttachments: [(Attachment, String)] = (item.attachments ?? []) + .filter { attachment in + attachment.isValid && + attachment.isVisualMedia && ( + attachment.state == .downloaded || + attachment.state == .uploaded + ) + } + .compactMap { attachment in + guard let originalFilePath: String = attachment.originalFilePath else { return nil } + + return (attachment, originalFilePath) + } + + guard !mediaAttachments.isEmpty else { return } + + mediaAttachments.forEach { attachment, originalFilePath in + PHPhotoLibrary.shared().performChanges( + { + if attachment.isImage || attachment.isAnimated { + PHAssetChangeRequest.creationRequestForAssetFromImage( + atFileURL: URL(fileURLWithPath: originalFilePath) + ) + } + else if attachment.isVideo { + PHAssetChangeRequest.creationRequestForAssetFromVideo( + atFileURL: URL(fileURLWithPath: originalFilePath) + ) + } + }, + completionHandler: { _, _ in } + ) + } + + // Send a 'media saved' notification if needed + guard self.viewModel.viewData.thread.variant == .contact, item.interactionVariant == .standardIncoming else { + return + } + + let thread: SessionThread = self.viewModel.viewData.thread + + GRDBStorage.shared.writeAsync { db in + try MessageSender.send( + db, + message: DataExtractionNotification( + kind: .mediaSaved(timestamp: UInt64(item.timestampMs)) + ), + interactionId: nil, + in: thread + ) + } + } + + func ban(_ item: ConversationViewModel.Item) { + guard item.threadVariant == .openGroup else { return } + + let threadId: String = self.viewModel.viewData.thread.id + let alert: UIAlertController = UIAlertController( + title: "Session", + message: "This will ban the selected user from this room. It won't ban them from other rooms.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else { + return + } + + OpenGroupAPIV2 + .ban(item.authorId, from: openGroup.room, on: openGroup.server) + .retainUntilComplete() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alert, animated: true, completion: nil) + } + + func banAndDeleteAllMessages(_ item: ConversationViewModel.Item) { + guard item.threadVariant == .openGroup else { return } + + let threadId: String = self.viewModel.viewData.thread.id + let alert: UIAlertController = UIAlertController( + title: "Session", + message: "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.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: { _ in + guard let openGroup: OpenGroup = GRDBStorage.shared.read({ db in try OpenGroup.fetchOne(db, id: threadId) }) else { + return + } + + OpenGroupAPIV2 + .banAndDeleteAllMessages(item.authorId, from: openGroup.room, on: openGroup.server) + .retainUntilComplete() + })) + alert.addAction(UIAlertAction(title: "Cancel", style: .default, handler: nil)) + + present(alert, animated: true, completion: nil) + } + + // MARK: - VoiceMessageRecordingViewDelegate + 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 + self.viewModel.stopAudio() + // Create URL - let directory = OWSTemporaryDirectory() - let fileName = "\(NSDate.millisecondTimestamp()).m4a" - let path = (directory as NSString).appendingPathComponent(fileName) - let url = URL(fileURLWithPath: path) + let directory: String = OWSTemporaryDirectory() + let fileName: String = "\(Int64(floor(Date().timeIntervalSince1970 * 1000))).m4a" + let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName) + // 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 = try AVAudioRecorder( + url: url, + settings: [ + AVFormatIDKey: NSNumber(value: kAudioFormatMPEG4AAC), + AVSampleRateKey: NSNumber(value: 44100), + AVNumberOfChannelsKey: NSNumber(value: 2), + AVEncoderBitRateKey: NSNumber(value: 128 * 1024) + ] + ) audioRecorder.isMeteringEnabled = true self.audioRecorder = audioRecorder - } catch { + } + 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.") @@ -1062,34 +1239,49 @@ extension ConversationVC: 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) + + OWSAlerts.showAlert( + title: "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized(), + message: "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized() + ) + return } + // Get data let dataSourceOrNil = DataSourcePath.dataSource(with: audioRecorder.url, shouldDeleteOnDeallocation: true) self.audioRecorder = nil + guard let dataSource = dataSourceOrNil else { return SNLog("Couldn't load recorded data.") } + // Create attachment - let fileName = (NSLocalizedString("VOICE_MESSAGE_FILE_NAME", comment: "") as NSString).appendingPathExtension("m4a") + let fileName = ("VOICE_MESSAGE_FILE_NAME".localized() as NSString).appendingPathExtension("m4a") dataSource.sourceFilename = fileName + let attachment = SignalAttachment.voiceMessageAttachment(dataSource: dataSource, dataUTI: kUTTypeMPEG4Audio as String) + guard !attachment.hasError else { return showErrorAlert(for: attachment, onDismiss: nil) } + // Send attachment sendAttachments([ attachment ], with: "") } @@ -1106,59 +1298,43 @@ extension ConversationVC: 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) - } - */ - } + // MARK: - Permissions - 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 + 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") { + case .granted: break + case .denied: onNotGranted() - } - modal.modalPresentationStyle = .overFullScreen - modal.modalTransitionStyle = .crossDissolve - present(modal, animated: true, completion: nil) - case .undetermined: - onNotGranted() - AVAudioSession.sharedInstance().requestRecordPermission { _ in } - default: break + 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 } } @@ -1201,25 +1377,29 @@ extension ConversationVC: } } } + 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 + 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 + OWSAlerts.showAlert( + title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(), + message: (attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), + buttonTitle: nil + ) { _ in onDismiss?() } } @@ -1236,52 +1416,52 @@ extension ConversationVC: UIDocumentInteractionControllerDelegate { // MARK: - Message Request Actions extension ConversationVC { - - fileprivate func approveMessageRequestIfNeeded(for thread: TSThread?, isNewThread: Bool, timestamp: Double) -> Promise { - guard let contactThread: TSContactThread = thread as? TSContactThread else { return Promise.value(()) } - + fileprivate func approveMessageRequestIfNeeded( + for thread: SessionThread?, + isNewThread: Bool, + timestampMs: Int64 + ) -> Promise { + guard let thread: SessionThread = thread, thread.variant == .contact else { return Promise.value(()) } + // 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() - guard - let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: sessionId) }), + let contact: Contact = GRDBStorage.shared.read({ db in Contact.fetchOrCreate(db, id: thread.id) }), !contact.isApproved else { return Promise.value(()) } - + return Promise.value(()) .then { [weak self] _ -> Promise in guard !isNewThread else { return Promise.value(()) } - guard let strongSelf = self else { return Promise(error: MessageSender.Error.noThread) } - + guard let strongSelf = self else { return Promise(error: MessageSenderError.noThread) } + // 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) let (promise, seal) = Promise.pending() let messageRequestResponse: MessageRequestResponse = MessageRequestResponse( - isApproved: true + isApproved: true, + sentTimestampMs: UInt64(timestampMs) ) - messageRequestResponse.sentTimestamp = timestamp - + // Show a loading indicator ModalActivityIndicatorViewController.present(fromViewController: strongSelf, canCancel: false) { _ in seal.fulfill(()) } - + return promise .then { _ -> Promise in - let (promise, seal) = Promise.pending() - Storage.writeSync { transaction in - MessageSender.sendNonDurably(messageRequestResponse, in: contactThread, using: transaction) - .done { seal.fulfill(()) } - .catch { _ in seal.fulfill(()) } // Fulfill even if this failed; the configuration in the swarm should be at most 2 days old - .retainUntilComplete() + GRDBStorage.shared.write { db in + try MessageSender.sendNonDurably( + db, + message: messageRequestResponse, + interactionId: nil, + in: thread + ) } - - return promise } .map { _ in if self?.presentedViewController is ModalActivityIndicatorViewController { @@ -1299,26 +1479,26 @@ extension ConversationVC { didApproveMe: .update(contact.didApproveMe || !isNewThread) ) .save(db) + + // Send a sync message with the details of the contact + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() }, completion: { db, _ in - // Send a sync message with the details of the contact - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - // Hide the 'messageRequestView' since the request has been approved DispatchQueue.main.async { [weak self] in let messageRequestViewWasVisible: Bool = (self?.messageRequestView.isHidden == false) - + UIView.animate(withDuration: 0.3) { self?.messageRequestView.isHidden = true self?.scrollButtonMessageRequestsBottomConstraint?.isActive = false self?.scrollButtonBottomConstraint?.isActive = true - + // Update the table content inset and offset to account for // the dissapearance of the messageRequestsView if messageRequestViewWasVisible { let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) - let oldContentInset: UIEdgeInsets = (self?.messagesTableView.contentInset ?? UIEdgeInsets.zero) - self?.messagesTableView.contentInset = UIEdgeInsets( + let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) + self?.tableView.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), @@ -1327,9 +1507,6 @@ extension ConversationVC { } } - // Update UI - self?.updateNavBarButtons() - // Remove the 'MessageRequestsViewController' from the nav hierarchy if present if let viewControllers: [UIViewController] = self?.navigationController?.viewControllers, @@ -1345,80 +1522,67 @@ extension ConversationVC { ) } } - + @objc func acceptMessageRequest() { - let promise: Promise = self.approveMessageRequestIfNeeded( - for: self.thread, + self.approveMessageRequestIfNeeded( + for: self.viewModel.viewData.thread, isNewThread: false, - timestamp: NSDate.millisecondTimestamp() + timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)) ) - - // Show an error indicating that approving the thread failed - promise.catch(on: DispatchQueue.main) { [weak self] _ in - let alert = UIAlertController(title: "Session", message: NSLocalizedString("MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE", comment: ""), preferredStyle: .alert) - alert.addAction(UIAlertAction(title: NSLocalizedString("BUTTON_OK", comment: ""), style: .default, handler: nil)) + .catch(on: DispatchQueue.main) { [weak self] _ in + // Show an error indicating that approving the thread failed + let alert = UIAlertController( + title: "Session", + message: "MESSAGE_REQUESTS_APPROVAL_ERROR_MESSAGE".localized(), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "BUTTON_OK".localized(), style: .default, handler: nil)) self?.present(alert, animated: true, completion: nil) } - - promise.retainUntilComplete() + .retainUntilComplete() } - + @objc func deleteMessageRequest() { - guard let uniqueId: String = thread.uniqueId else { return } + guard self.viewModel.viewData.thread.variant == .contact else { return } - let alertVC: UIAlertController = UIAlertController(title: NSLocalizedString("MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON", comment: ""), message: nil, preferredStyle: .actionSheet) - alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_DELETE_TITLE", comment: ""), style: .destructive) { _ in + let threadId: String = self.viewModel.viewData.thread.id + let alertVC: UIAlertController = UIAlertController( + title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(), + message: nil, + preferredStyle: .actionSheet + ) + alertVC.addAction(UIAlertAction(title: "TXT_DELETE_TITLE".localized(), style: .destructive) { _ in // Delete the request GRDBStorage.shared.writeAsync( updates: { [weak self] db in // Update the contact - if let contactThread: TSContactThread = self?.thread as? TSContactThread { - let sessionId: String = contactThread.contactSessionID() - - // Stop observing the `BlockListDidChange` notification (we are about to pop the screen - // so showing the banner just looks buggy) - if let strongSelf = self { - NotificationCenter.default.removeObserver(strongSelf, name: .contactBlockedStateChanged, object: nil) - } - - try? Contact - .fetchOne(db, id: sessionId)? - .with( - isApproved: false, - isBlocked: true, - - // Note: We set this to true so the current user will be able to send a - // message to the person who originally sent them the message request in - // the future if they unblock them - didApproveMe: true - ) - .update(db) - } + try? Contact + .fetchOrCreate(db, id: threadId) + .with( + isApproved: false, + isBlocked: true, + + // Note: We set this to true so the current user will be able to send a + // message to the person who originally sent them the message request in + // the future if they unblock them + didApproveMe: true + ) + .saved(db) + + _ = try SessionThread + .filter(id: threadId) + .deleteAll(db) + + try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() }, completion: { db, _ in - Storage.write( - with: { [weak self] transaction in - // TODO: This should be above the contact updating - Storage.shared.cancelPendingMessageSendJobs(for: uniqueId, using: transaction) - - // Delete all thread content - self?.thread.removeAllThreadInteractions(with: transaction) - self?.thread.remove(with: transaction) - }, - completion: { [weak self] in - // Force a config sync and pop to the previous screen - // TODO: This might cause an "incorrect thread" crash - MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete() - - DispatchQueue.main.async { - self?.navigationController?.popViewController(animated: true) - } - } - ) + DispatchQueue.main.async { [weak self] in + self?.navigationController?.popViewController(animated: true) + } } ) }) - alertVC.addAction(UIAlertAction(title: NSLocalizedString("TXT_CANCEL_TITLE", comment: ""), style: .cancel, handler: nil)) + alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil)) self.present(alertVC, animated: true, completion: nil) } } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 2a97a0e86..b89ef4443 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -410,8 +410,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers name: UIResponder.keyboardWillHideNotification, object: nil ) - // Mentions - MentionsManager.populateUserPublicKeyCacheIfNeeded(for: viewModel.viewData.thread.id) // Draft if let draft: String = viewModel.viewData.thread.messageDraft, !draft.isEmpty { @@ -535,6 +533,10 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers updateNavBarButtons(viewData: updatedViewData) } + if viewModel.viewData.isClosedGroupMember != updatedViewData.isClosedGroupMember { + reloadInputViews() + } + if initialLoad || viewModel.viewData.enabledMessageTypes != updatedViewData.enabledMessageTypes { snInputView.setEnabledMessageTypes( updatedViewData.enabledMessageTypes, @@ -821,11 +823,6 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers func conversationViewModelDidReset() { // Not currently in use } - - @objc private func handleGroupUpdatedNotification() { - thread.reload() // Needed so that thread.isCurrentUserMemberInGroup() is up to date - reloadInputViews() - } @objc private func handleMessageSentStatusChanged() { DispatchQueue.main.async { @@ -869,7 +866,7 @@ final class ConversationVC: BaseVC, OWSConversationSettingsViewDelegate, Convers cell.update( with: item, mediaCache: mediaCache, - playbackInfo: viewModel.playbackInfo(for: item) { [weak self] updatedInfo, error in + playbackInfo: viewModel.playbackInfo(for: item) { updatedInfo, error in DispatchQueue.main.async { guard error == nil else { OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 584884f40..6d9034cc4 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -11,22 +11,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M case none } + // MARK: - Variables + + private static let linkPreviewViewInset: CGFloat = 6 + + private let threadVariant: SessionThread.Variant private weak var delegate: InputViewDelegate? - var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } + + var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } } var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)? private var voiceMessageRecordingView: VoiceMessageRecordingView? private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0) private lazy var linkPreviewView: LinkPreviewView = { - let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset - return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self) + let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset) + + return LinkPreviewView(maxWidth: maxWidth) { [weak self] in + self?.linkPreviewInfo = nil + self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } }() var text: String { - get { inputTextView.text } + get { inputTextView.text ?? "" } set { inputTextView.text = newValue } } - + var enabledMessageTypes: MessageTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) @@ -96,71 +106,78 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity) label.textAlignment = .center label.alpha = 0 - + return label }() private lazy var additionalContentContainer = UIView() - // MARK: Settings - private static let linkPreviewViewInset: CGFloat = 6 + // MARK: - Initialization - // MARK: Lifecycle - init(delegate: InputViewDelegate) { + init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) { + self.threadVariant = threadVariant self.delegate = delegate + super.init(frame: CGRect.zero) + setUpViewHierarchy() } - + override init(frame: CGRect) { preconditionFailure("Use init(delegate:) instead.") } - + required init?(coder: NSCoder) { preconditionFailure("Use init(delegate:) instead.") } - + private func setUpViewHierarchy() { autoresizingMask = .flexibleHeight + // Background & blur let backgroundView = UIView() backgroundView.backgroundColor = isLightMode ? .white : .black backgroundView.alpha = Values.lowOpacity addSubview(backgroundView) backgroundView.pin(to: self) + let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) addSubview(blurView) blurView.pin(to: self) + // Separator let separator = UIView() separator.backgroundColor = Colors.text.withAlphaComponent(0.2) separator.set(.height, to: 1 / UIScreen.main.scale) addSubview(separator) separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self) + // Bottom stack view let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ]) bottomStackView.axis = .horizontal bottomStackView.spacing = Values.smallSpacing bottomStackView.alignment = .center self.bottomStackView = bottomStackView + // Main stack view let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ]) mainStackView.axis = .vertical mainStackView.isLayoutMarginsRelativeArrangement = true + let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2 mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment) addSubview(mainStackView) mainStackView.pin(.top, to: .bottom, of: separator) mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self) mainStackView.pin(.bottom, to: .bottom, of: self) - + addSubview(disabledInputLabel) - + disabledInputLabel.pin(.top, to: .top, of: mainStackView) disabledInputLabel.pin(.left, to: .left, of: mainStackView) disabledInputLabel.pin(.right, to: .right, of: mainStackView) disabledInputLabel.set(.height, to: InputViewButton.expandedSize) - + // Mentions insertSubview(mentionsViewContainer, belowSubview: mainStackView) mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self) @@ -168,12 +185,14 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M mentionsViewContainer.addSubview(mentionsView) mentionsView.pin(to: mentionsViewContainer) mentionsViewHeightConstraint.isActive = true + // Voice message button addSubview(voiceMessageButtonContainer) voiceMessageButtonContainer.center(in: sendButton) } + + // MARK: - Updating - // MARK: Updating func inputTextViewDidChangeSize(_ inputTextView: InputTextView) { invalidateIntrinsicContentSize() } @@ -185,7 +204,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M autoGenerateLinkPreviewIfPossible() delegate?.inputTextViewDidChangeContent(inputTextView) } - + func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) { delegate?.didPasteImageFromPasteboard(image) } @@ -193,15 +212,29 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // We want to show either a link preview or a quote draft, but never both at the same time. When trying to // generate a link preview, wait until we're sure that we'll be able to build a link preview from the given // URL before removing the quote draft. - + private func handleQuoteDraftChanged() { additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } linkPreviewInfo = nil + guard let quoteDraftInfo = quoteDraftInfo else { return } - let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming + let hInset: CGFloat = 6 // Slight visual adjustment let maxWidth = additionalContentContainer.bounds.width - let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self) + + let quoteView: QuoteView = QuoteView( + for: .draft, + authorId: quoteDraftInfo.model.authorId, + quotedText: quoteDraftInfo.model.body, + threadVariant: threadVariant, + direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming), + attachment: quoteDraftInfo.model.attachment, + hInset: hInset, + maxWidth: maxWidth + ) { [weak self] in + self?.quoteDraftInfo = nil + } + additionalContentContainer.addSubview(quoteView) quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset) quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12) @@ -212,7 +245,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M private func autoGenerateLinkPreviewIfPossible() { // Don't allow link previews on 'none' or 'textOnly' input guard enabledMessageTypes == .all else { return } - + // Suggest that the user enable link previews if they haven't already and we haven't // told them about link previews yet let text = inputTextView.text! @@ -234,42 +267,51 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else { return } + // Guard against obsolete updates guard linkPreviewURL != self.linkPreviewInfo?.url else { return } + // Clear content container additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } quoteDraftInfo = nil + // Set the state to loading linkPreviewInfo = (url: linkPreviewURL, draft: nil) - linkPreviewView.linkPreviewState = LinkPreviewLoading() + linkPreviewView.update(with: LinkPreviewLoading(), isOutgoing: false) + // Add the link preview view additionalContentContainer.addSubview(linkPreviewView) linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset) linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10) linkPreviewView.pin(.right, to: .right, of: additionalContentContainer) linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4) + // Build the link preview - OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in - guard let self = self else { return } - guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete - self.linkPreviewInfo = (url: linkPreviewURL, draft: draft) - self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft) - }.catch { _ in - guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete - self.linkPreviewInfo = nil - self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - }.retainUntilComplete() + OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL) + .done { [weak self] draft in + guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + + self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft) + self?.linkPreviewView.update(with: LinkPreviewDraft(linkPreviewDraft: draft), isOutgoing: false) + } + .catch { [weak self] _ in + guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete + + self?.linkPreviewInfo = nil + self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } + } + .retainUntilComplete() } - + func setEnabledMessageTypes(_ messageTypes: MessageTypes, message: String?) { guard enabledMessageTypes != messageTypes else { return } - + enabledMessageTypes = messageTypes disabledInputLabel.text = (message ?? "") - + attachmentsButton.isUserInteractionEnabled = (messageTypes == .all) voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all) - + UIView.animate(withDuration: 0.3) { [weak self] in self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0) self?.attachmentsButton.alpha = (messageTypes == .all ? @@ -283,35 +325,40 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1) } } + + // MARK: - Interaction - // MARK: Interaction override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { // Needed so that the user can tap the buttons when the expanding attachments button is expanded let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton, attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ] - let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) } - if let buttonContainer = buttonContainer { + + if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) { return buttonContainer - } else { - return super.hitTest(point, with: event) } + + return super.hitTest(point, with: event) } - + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer, attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ] - let isPointInsideAttachmentsButton = buttonContainers.contains { $0.superview!.convert($0.frame, to: self).contains(point) } + let isPointInsideAttachmentsButton = buttonContainers + .contains { $0.superview!.convert($0.frame, to: self).contains(point) } + if isPointInsideAttachmentsButton { // Needed so that the user can tap the buttons when the expanding attachments button is expanded return true - } else if mentionsViewContainer.frame.contains(point) { + } + + if mentionsViewContainer.frame.contains(point) { // Needed so that the user can tap mentions return true - } else { - return super.point(inside: point, with: event) } + + return super.point(inside: point, with: event) } - + func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) { if inputViewButton == sendButton { delegate?.handleSendButtonTapped() } } @@ -334,10 +381,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M voiceMessageRecordingView.handleLongPressEnded(at: location) } - func handleQuoteViewCancelButtonTapped() { - delegate?.handleQuoteViewCancelButtonTapped() - } - override func resignFirstResponder() -> Bool { inputTextView.resignFirstResponder() } @@ -346,11 +389,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M // Not relevant in this case } - func handleLinkPreviewCanceled() { - linkPreviewInfo = nil - additionalContentContainer.subviews.forEach { $0.removeFromSuperview() } - } - @objc private func showVoiceMessageUI() { voiceMessageRecordingView?.removeFromSuperview() let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self) @@ -378,30 +416,32 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } func hideMentionsUI() { - UIView.animate(withDuration: 0.25, animations: { - self.mentionsViewContainer.alpha = 0 - }, completion: { _ in - self.mentionsViewHeightConstraint.constant = 0 - self.mentionsView.tableView.contentOffset = CGPoint.zero - }) + UIView.animate( + withDuration: 0.25, + animations: { [weak self] in + self?.mentionsViewContainer.alpha = 0 + }, + completion: { [weak self] _ in + self?.mentionsViewHeightConstraint.constant = 0 + self?.mentionsView.contentOffset = CGPoint.zero + } + ) } - func showMentionsUI(for candidates: [Mention], in thread: TSThread) { - if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) { - mentionsView.openGroupServer = openGroupV2.server - mentionsView.openGroupRoom = openGroupV2.room - } + func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) { mentionsView.candidates = candidates - let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing + + let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing) mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight layoutIfNeeded() + UIView.animate(withDuration: 0.25) { self.mentionsViewContainer.alpha = 1 } } - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) { - delegate?.handleMentionSelected(mention, from: view) + func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) { + delegate?.handleMentionSelected(mentionInfo, from: view) } // MARK: - Convenience @@ -417,13 +457,12 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M } } -// MARK: Delegate -protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { +// MARK: - Delegate +protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate { func showLinkPreviewSuggestionModal() func handleSendButtonTapped() - func handleQuoteViewCancelButtonTapped() func inputTextViewDidChangeContent(_ inputTextView: InputTextView) - func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) + func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) func didPasteImageFromPasteboard(_ image: UIImage) } diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift index 402ac6281..d24974df6 100644 --- a/Session/Conversations/Message Cells/Content Views/DocumentView.swift +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -32,21 +32,25 @@ final class DocumentView: UIView { let iconImageViewSize = DocumentView.iconImageViewSize imageView.set(.width, to: iconImageViewSize.width) imageView.set(.height, to: iconImageViewSize.height) + // Body label let titleLabel = UILabel() titleLabel.lineBreakMode = .byTruncatingTail - titleLabel.text = attachment.sourceFilename ?? "File" + titleLabel.text = (attachment.sourceFilename ?? "File") titleLabel.textColor = textColor titleLabel.font = .systemFont(ofSize: Values.smallFontSize, weight: .light) + // Size label let sizeLabel = UILabel() sizeLabel.lineBreakMode = .byTruncatingTail sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount)) sizeLabel.textColor = textColor sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize) + // Label stack view let labelStackView = UIStackView(arrangedSubviews: [ titleLabel, sizeLabel ]) labelStackView.axis = .vertical + // Stack view let stackView = UIStackView(arrangedSubviews: [ imageView, labelStackView ]) stackView.axis = .horizontal diff --git a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift index 7a1065044..98a653d61 100644 --- a/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift +++ b/Session/Conversations/Message Cells/Content Views/OpenGroupInvitationView.swift @@ -1,30 +1,24 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. -final class OpenGroupInvitationView : UIView { - private let name: String - private let rawURL: String - private let textColor: UIColor - private let isOutgoing: Bool - - private lazy var url: String = { - if let range = rawURL.range(of: "?public_key=") { - return String(rawURL[.. recentThreshold } @@ -364,7 +364,7 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { return false } - mostRecentNotifications.append(now) + mostRecentNotifications.append(nowMs) return true } } diff --git a/Session/Onboarding/RestoreVC.swift b/Session/Onboarding/RestoreVC.swift index 762b1e5c1..87589c81a 100644 --- a/Session/Onboarding/RestoreVC.swift +++ b/Session/Onboarding/RestoreVC.swift @@ -13,6 +13,7 @@ final class RestoreVC: BaseVC { // MARK: Components private lazy var mnemonicTextView: TextView = { let result = TextView(placeholder: NSLocalizedString("vc_restore_seed_text_field_hint", comment: "")) + result.autocapitalizationType = .none result.layer.borderColor = Colors.text.cgColor result.accessibilityLabel = "Recovery phrase text view" return result diff --git a/Session/Shared/ConversationCell.swift b/Session/Shared/ConversationCell.swift index 119d10806..998d227b6 100644 --- a/Session/Shared/ConversationCell.swift +++ b/Session/Shared/ConversationCell.swift @@ -365,15 +365,17 @@ final class ConversationCell: UITableViewCell { ) displayNameLabel.text = threadInfo.displayName timestampLabel.text = DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate) -// if SSKEnvironment.shared.typingIndicators.typingRecipientId(forThread: thread) != nil { -// snippetLabel.text = "" -// typingIndicatorView.isHidden = false -// typingIndicatorView.startAnimation() -// } else { + + if threadInfo.contactIsTyping { + snippetLabel.text = "" + typingIndicatorView.isHidden = false + typingIndicatorView.startAnimation() + } + else { snippetLabel.attributedText = getSnippet(threadInfo: threadInfo) typingIndicatorView.isHidden = true typingIndicatorView.stopAnimation() -// } + } statusIndicatorView.backgroundColor = nil diff --git a/Session/Sheets & Modals/Modal.swift b/Session/Sheets & Modals/Modal.swift index 91c6bcd14..7a4859814 100644 --- a/Session/Sheets & Modals/Modal.swift +++ b/Session/Sheets & Modals/Modal.swift @@ -1,4 +1,7 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + import UIKit +import SessionUIKit @objc(LKModal) class Modal: BaseVC, UIGestureRecognizerDelegate { diff --git a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift index c089ce5ae..04f345630 100644 --- a/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionMessagingKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -293,7 +293,8 @@ enum _003_YDBToGRDBMigration: Migration { .joined(separator: "-") } - try threads.forEach { thread in + // Sort by id just so we can make the migration process more determinstic + try threads.sorted(by: { lhs, rhs in (lhs.uniqueId ?? "") < (rhs.uniqueId ?? "") }).forEach { thread in guard let legacyThreadId: String = thread.uniqueId, let threadId: String = legacyThreadIdToIdMap[legacyThreadId] @@ -423,7 +424,7 @@ enum _003_YDBToGRDBMigration: Migration { let currentUserPublicKey: String = getUserHexEncodedPublicKey(db) try interactions[legacyThreadId]? - .sorted(by: { lhs, rhs in lhs.sortId < rhs.sortId }) // Maintain sort order + .sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order .forEach { legacyInteraction in let serverHash: String? let variant: Interaction.Variant diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index 5450bf902..444008f2e 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -579,10 +579,17 @@ public extension Attachment { ) -> (isValid: Bool, duration: TimeInterval?) { guard let originalFilePath: String = originalFilePath else { return (false, nil) } + let constructedFilePath: String? = localRelativeFilePath.map { + URL(fileURLWithPath: Attachment.attachmentsFolder) + .appendingPathComponent($0) + .path + } + let targetPath: String = (constructedFilePath ?? originalFilePath) + // Process audio attachments if MIMETypeUtil.isAudio(contentType) { do { - let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: originalFilePath)) + let audioPlayer: AVAudioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: targetPath)) return ((audioPlayer.duration > 0), audioPlayer.duration) } @@ -590,7 +597,7 @@ public extension Attachment { switch (error as NSError).code { case Int(kAudioFileInvalidFileError), Int(kAudioFileStreamError_InvalidFile): // Ignore "invalid audio file" errors - return (false, nil) // TODO: Confirm this behaviour (previously returned 0) + return (false, nil) default: return (false, nil) } @@ -599,51 +606,23 @@ public extension Attachment { // Process image attachments if MIMETypeUtil.isImage(contentType) { - let specificFilePathIsValid: Bool = ( - localRelativeFilePath != nil && - localRelativeFilePath.map { - NSData.ows_isValidImage( - atPath: URL(fileURLWithPath: Attachment.attachmentsFolder) - .appendingPathComponent($0) - .path, - mimeType: contentType - ) - } == true - ) - return ( - ( - specificFilePathIsValid || - NSData.ows_isValidImage(atPath: originalFilePath, mimeType: contentType) - ), + NSData.ows_isValidImage(atPath: targetPath, mimeType: contentType), nil ) } // Process video attachments if MIMETypeUtil.isVideo(contentType) { - let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: originalFilePath)) + let videoPlayer: AVPlayer = AVPlayer(url: URL(fileURLWithPath: targetPath)) let durationSeconds: TimeInterval? = videoPlayer.currentItem .map { item -> TimeInterval in // Accorting to the CMTime docs "value/timescale = seconds" (TimeInterval(item.duration.value) / TimeInterval(item.duration.timescale)) } - let specificFilePathIsValid: Bool = ( - localRelativeFilePath != nil && - localRelativeFilePath.map { - OWSMediaUtils.isValidVideo( - path: URL(fileURLWithPath: Attachment.attachmentsFolder) - .appendingPathComponent($0) - .path - ) - } == true - ) return ( - ( - specificFilePathIsValid || - OWSMediaUtils.isValidVideo(path: originalFilePath) - ), + OWSMediaUtils.isValidVideo(path: targetPath), durationSeconds ) } @@ -720,6 +699,8 @@ extension Attachment { public var isVideo: Bool { MIMETypeUtil.isVideo(contentType) } public var isAnimated: Bool { MIMETypeUtil.isAnimated(contentType) } public var isAudio: Bool { MIMETypeUtil.isAudio(contentType) } + public var isText: Bool { MIMETypeUtil.isText(contentType) } + public var isMicrosoftDoc: Bool { MIMETypeUtil.isMicrosoftDoc(contentType) } public var isVisualMedia: Bool { isImage || isVideo || isAnimated } @@ -793,22 +774,6 @@ extension Attachment { // MARK: - Upload extension Attachment { - internal enum UploadError: LocalizedError { - case invalidStartState - case noAttachment - case notUploaded - case encryptionFailed - - public var errorDescription: String? { - switch self { - case .invalidStartState: return "Cannot upload an attachment in this state." - case .noAttachment: return "No such attachment." - case .notUploaded: return "Attachment not uploaded." - case .encryptionFailed: return "Couldn't encrypt file." - } - } - } - internal func upload( using upload: (Data) -> Promise, encrypt: Bool, @@ -817,14 +782,14 @@ extension Attachment { ) { guard state != .uploaded else { SNLog("Attempted to upload an already uploaded/downloaded attachment.") - failure?(UploadError.invalidStartState) + failure?(AttachmentError.invalidStartState) return } // Get the attachment guard var data = try? readDataFromFile() else { SNLog("Couldn't read attachment from disk.") - failure?(UploadError.noAttachment) + failure?(AttachmentError.noAttachment) return } @@ -868,7 +833,7 @@ extension Attachment { guard let ciphertext = Cryptography.encryptAttachmentData(data, shouldPad: true, outKey: &encryptionKey, outDigest: &digest) else { SNLog("Couldn't encrypt attachment.") - failure?(UploadError.encryptionFailed) + failure?(AttachmentError.encryptionFailed) return } diff --git a/SessionMessagingKit/Database/Models/Contact.swift b/SessionMessagingKit/Database/Models/Contact.swift index 6b047e354..dca421b0a 100644 --- a/SessionMessagingKit/Database/Models/Contact.swift +++ b/SessionMessagingKit/Database/Models/Contact.swift @@ -64,20 +64,6 @@ public struct Contact: Codable, Identifiable, Equatable, FetchableRecord, Persis self.didApproveMe = didApproveMe self.hasBeenBlocked = (isBlocked || hasBeenBlocked) } - - // MARK: - PersistableRecord - - public func save(_ db: Database) throws { - let oldContact: Contact? = try? Contact.fetchOne(db, id: id) - - try performSave(db) - - db.afterNextTransactionCommit { db in - if isBlocked != oldContact?.isBlocked { - NotificationCenter.default.post(name: .contactBlockedStateChanged, object: id) - } - } - } } // MARK: - Convenience diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 74555c658..b3a42eca8 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -474,6 +474,9 @@ public extension Interaction { ) } + /// This method flags sent messages as read for the specified recipients + /// + /// **Note:** This method won't update the 'wasRead' flag (it will be updated via the above method) static func markAsRead(_ db: Database, recipientId: String, timestampMsValues: [Double], readTimestampMs: Double) throws { guard db[.areReadReceiptsEnabled] == true else { return } diff --git a/SessionMessagingKit/Database/Models/LinkPreview.swift b/SessionMessagingKit/Database/Models/LinkPreview.swift index 54b4726e9..953b9b9bc 100644 --- a/SessionMessagingKit/Database/Models/LinkPreview.swift +++ b/SessionMessagingKit/Database/Models/LinkPreview.swift @@ -95,7 +95,7 @@ public struct LinkPreview: Codable, Equatable, FetchableRecord, PersistableRecor // MARK: - Protobuf public extension LinkPreview { - init?(_ db: Database, proto: SNProtoDataMessage, body: String?) throws { + init?(_ db: Database, proto: SNProtoDataMessage, body: String?, sentTimestampMs: TimeInterval) throws { guard OWSLinkPreview.featureEnabled else { throw LinkPreviewError.noPreview } guard let previewProto = proto.preview.first else { throw LinkPreviewError.noPreview } guard proto.attachments.count < 1 else { throw LinkPreviewError.invalidInput } @@ -107,7 +107,7 @@ public extension LinkPreview { } // Try to get an existing link preview first - let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: Double(proto.timestamp)) + let timestamp: TimeInterval = LinkPreview.timestampFor(sentTimestampMs: sentTimestampMs) let maybeLinkPreview: LinkPreview? = try? LinkPreview .filter(LinkPreview.Columns.url == previewProto.url) .filter(LinkPreview.Columns.timestamp == LinkPreview.timestampFor( diff --git a/SessionMessagingKit/Database/Models/OpenGroup.swift b/SessionMessagingKit/Database/Models/OpenGroup.swift index 73454bf00..ddb12ab03 100644 --- a/SessionMessagingKit/Database/Models/OpenGroup.swift +++ b/SessionMessagingKit/Database/Models/OpenGroup.swift @@ -129,9 +129,9 @@ public extension OpenGroup { @objc(SMKOpenGroup) public class SMKOpenGroup: NSObject { @objc(inviteUsers:toOpenGroupFor:) - public static func invite(selectedUsers: Set, threadId: String) { + public static func invite(selectedUsers: Set, openGroupThreadId: String) { GRDBStorage.shared.write { db in - guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return } + guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: openGroupThreadId) else { return } let urlString: String = "\(openGroup.server)/\(openGroup.room)?public_key=\(openGroup.publicKey)" @@ -146,7 +146,7 @@ public class SMKOpenGroup: NSObject { .save(db) let interaction: Interaction = try Interaction( - threadId: threadId, + threadId: thread.id, authorId: userId, variant: .standardOutgoing, timestampMs: Int64(floor(Date().timeIntervalSince1970 * 1000)), diff --git a/SessionMessagingKit/Database/Notification+Contacts.swift b/SessionMessagingKit/Database/Notification+Contacts.swift index 2b96b37ba..857d88cb4 100644 --- a/SessionMessagingKit/Database/Notification+Contacts.swift +++ b/SessionMessagingKit/Database/Notification+Contacts.swift @@ -7,7 +7,6 @@ public extension Notification.Name { static let profileUpdated = Notification.Name("profileUpdated") static let localProfileDidChange = Notification.Name("localProfileDidChange") static let otherUsersProfileDidChange = Notification.Name("otherUsersProfileDidChange") - static let contactBlockedStateChanged = Notification.Name("contactBlockedStateChanged") } @objc public extension NSNotification { @@ -15,7 +14,6 @@ public extension Notification.Name { @objc static let profileUpdated = Notification.Name.profileUpdated.rawValue as NSString @objc static let localProfileDidChange = Notification.Name.localProfileDidChange.rawValue as NSString @objc static let otherUsersProfileDidChange = Notification.Name.otherUsersProfileDidChange.rawValue as NSString - @objc static let contactBlockedStateChanged = Notification.Name.contactBlockedStateChanged.rawValue as NSString } extension Notification.Key { diff --git a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift index 4e3b9a7d2..57fc04a9e 100644 --- a/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift +++ b/SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift @@ -57,16 +57,4 @@ extension AttachmentUploadJob { self.attachmentId = attachmentId } } - - public enum AttachmentUploadError: LocalizedError { - case noAttachment - case encryptionFailed - - public var errorDescription: String? { - switch self { - case .noAttachment: return "No such attachment." - case .encryptionFailed: return "Couldn't encrypt file." - } - } - } } diff --git a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift index 335bcb73c..fc8076d96 100644 --- a/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift @@ -56,10 +56,11 @@ public enum MessageReceiveJob: JobExecutor { catch { switch error { // Note: This is the same as the 'MessageReceiverError.duplicateMessage' - // which is not retryable so just skip to the next message to process - case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: - SNLog("MessageReceiveJob skipping duplicate message.") - continue + // which is not retryable so just skip to the next message to process (no + // longer logging this because all de-duping happens here now rather than + // when parsing as it did previously - this change results in excessive + // logging which isn't useful) + case DatabaseError.SQLITE_CONSTRAINT_UNIQUE: continue default: break } @@ -67,6 +68,11 @@ public enum MessageReceiveJob: JobExecutor { // If the current message is a permanent failure then override it with the // new error (we want to retry if there is a single non-permanent error) switch error { + // Ignore self-send errors (they will be permanently failed but no need + // to log since we are going to have a lot of the due to the change to the + // de-duping logic) + case MessageReceiverError.selfSend: continue + case let receiverError as MessageReceiverError where !receiverError.isRetryable: SNLog("MessageReceiveJob permanently failed message due to error: \(error)") continue diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index 6783489e4..c2066c47f 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -101,7 +101,7 @@ public enum MessageSendJob: JobExecutor { // Note: If we have gotten to this point then any dependant attachment upload // jobs will have permanently failed so this message send should also do so guard attachmentState?.shouldFail == false else { - failure(job, Attachment.UploadError.notUploaded, true) + failure(job, AttachmentError.notUploaded, true) return } @@ -117,7 +117,7 @@ public enum MessageSendJob: JobExecutor { // Perform the actual message sending GRDBStorage.shared.write { db -> Promise in - try MessageSender.send( + try MessageSender.sendImmediate( db, message: details.message, to: details.destination, diff --git a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift index d2f7bd871..c1194a920 100644 --- a/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift +++ b/SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift @@ -36,7 +36,7 @@ public enum SendReadReceiptsJob: JobExecutor { GRDBStorage.shared .write { db in - try MessageSender.send( + try MessageSender.sendImmediate( db, message: ReadReceipt( timestamps: details.timestampMsValues.map { UInt64($0) } diff --git a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift index b1b982764..100edbefe 100644 --- a/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift +++ b/SessionMessagingKit/Messages/Control Messages/UnsendRequest.swift @@ -25,7 +25,7 @@ public final class UnsendRequest: ControlMessage { // MARK: - Initialization - internal init(timestamp: UInt64, author: String) { + public init(timestamp: UInt64, author: String) { super.init() self.timestamp = timestamp diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 085596c5c..698baa732 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -161,7 +161,12 @@ public final class VisibleMessage: Message { dataMessage.setAttachments(attachmentProtos) // Open group invitation - if let openGroupInvitation = openGroupInvitation, let openGroupInvitationProto = openGroupInvitation.toProto() { dataMessage.setOpenGroupInvitation(openGroupInvitationProto) } + if + let openGroupInvitation = openGroupInvitation, + let openGroupInvitationProto = openGroupInvitation.toProto() + { + dataMessage.setOpenGroupInvitation(openGroupInvitationProto) + } // Group context do { diff --git a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift b/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift deleted file mode 100644 index 2e0ce41cf..000000000 --- a/SessionMessagingKit/Sending & Receiving/Data Extraction/DataExtractionNotificationInfoMessage.swift +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. - -import Foundation -import GRDB -import SessionUtilitiesKit - -@objc(SNDataExtractionNotificationInfoMessage) -final class DataExtractionNotificationInfoMessage : TSInfoMessage { - - init(type: TSInfoMessageType, sentTimestamp: UInt64, thread: TSThread, referencedAttachmentTimestamp: UInt64?) { - super.init(timestamp: sentTimestamp, in: thread, messageType: type) - } - - required init(coder: NSCoder) { - super.init(coder: coder) - } - - required init(dictionary dictionaryValue: [String: Any]!) throws { - try super.init(dictionary: dictionaryValue) - } - - override func previewText(with transaction: YapDatabaseReadTransaction) -> String { - guard let thread = thread as? TSContactThread else { return "" } // Should never occur - - let displayName = Profile.displayName(for: thread.contactSessionID()) - - switch messageType { - case .screenshotNotification: - return String(format: NSLocalizedString("screenshot_taken", comment: ""), displayName) - - case .mediaSavedNotification: - // TODO: Use referencedAttachmentTimestamp to tell the user * which * media was saved - return String(format: NSLocalizedString("meida_saved", comment: ""), displayName) - - default: preconditionFailure() - } - } -} diff --git a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift index 0a8ab0dac..5f708431e 100644 --- a/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift +++ b/SessionMessagingKit/Sending & Receiving/Errors/AttachmentError.swift @@ -1,3 +1,21 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import Foundation + +public enum AttachmentError: LocalizedError { + case invalidStartState + case noAttachment + case notUploaded + case invalidData + case encryptionFailed + + public var errorDescription: String? { + switch self { + case .invalidStartState: return "Cannot upload an attachment in this state." + case .noAttachment: return "No such attachment." + case .notUploaded: return "Attachment not uploaded." + case .invalidData: return "Invalid attachment data." + case .encryptionFailed: return "Couldn't encrypt file." + } + } +} diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift index 0cd322bb8..522ec5184 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver+Handling.swift @@ -356,20 +356,21 @@ extension MessageReceiver { UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: interaction.notificationIdentifiers) } - if author == message.sender { - if let serverHash: String = interaction.serverHash { - SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() - } - - _ = try interaction - .markingAsDeleted() - .saved(db) - - _ = try interaction.attachments - .deleteAll(db) + if author == message.sender, let serverHash: String = interaction.serverHash { + SnodeAPI.deleteMessage(publicKey: author, serverHashes: [serverHash]).retainUntilComplete() } - else { - _ = try interaction.delete(db) + + switch (interaction.variant, (author == message.sender)) { + case (.standardOutgoing, _), (_, false): + _ = try interaction.delete(db) + + case (_, true): + _ = try interaction + .markingAsDeleted() + .saved(db) + + _ = try interaction.attachments + .deleteAll(db) } } @@ -511,7 +512,15 @@ extension MessageReceiver { return (attachment.downloadUrl != nil ? attachment : nil) } .map { attachment in - try attachment.saved(db) + let savedAttachment: Attachment = try attachment.saved(db) + + // Link the attachment to the interaction and add to the id lookup + try InteractionAttachment( + interactionId: interactionId, + attachmentId: savedAttachment.id + ).insert(db) + + return savedAttachment } message.attachmentIds = attachments.map { $0.id } @@ -528,7 +537,8 @@ extension MessageReceiver { let linkPreview: LinkPreview? = try? LinkPreview( db, proto: dataMessage, - body: message.text + body: message.text, + sentTimestampMs: (messageSentTimestamp * 1000) )?.saved(db) // Open group invitations are stored as LinkPreview values so create one if needed diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index d8615a5ea..312448d0a 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -35,7 +35,7 @@ public enum MessageReceiver { else { switch envelope.type { case .sessionMessage: - guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair() else { + guard let userX25519KeyPair: Box.KeyPair = Identity.fetchUserKeyPair(db) else { throw MessageReceiverError.noUserX25519KeyPair } diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift index bfbf9a481..8bbb9a081 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender+Convenience.swift @@ -13,11 +13,12 @@ extension MessageSender { guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } // TODO: Is the 'prep' method needed anymore? // prep(db, attachments, for: message) - try send( + send( db, message: VisibleMessage.from(db, interaction: interaction), + threadId: thread.id, interactionId: interactionId, - in: thread + to: try Message.Destination.from(db, thread: thread) ) } @@ -26,18 +27,34 @@ extension MessageSender { guard interaction.variant == .standardOutgoing else { throw MessageSenderError.invalidMessage } guard let interactionId: Int64 = interaction.id else { throw GRDBStorageError.objectNotSaved } - return try send(db, message: VisibleMessage.from(db, interaction: interaction), interactionId: interactionId, in: thread) + send( + db, + message: VisibleMessage.from(db, interaction: interaction), + threadId: thread.id, + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) } public static func send(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws { + send( + db, + message: message, + threadId: thread.id, + interactionId: interactionId, + to: try Message.Destination.from(db, thread: thread) + ) + } + + public static func send(_ db: Database, message: Message, threadId: String?, interactionId: Int64?, to destination: Message.Destination) { JobRunner.add( db, job: Job( variant: .messageSend, - threadId: thread.id, + threadId: threadId, interactionId: interactionId, details: MessageSendJob.Details( - destination: try Message.Destination.from(db, thread: thread), + destination: destination, message: message ) ) @@ -98,7 +115,7 @@ extension MessageSender { public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, in thread: SessionThread) throws -> Promise { - return try MessageSender.send( + return try MessageSender.sendImmediate( db, message: message, to: try Message.Destination.from(db, thread: thread), @@ -110,7 +127,7 @@ extension MessageSender { } public static func sendNonDurably(_ db: Database, message: Message, interactionId: Int64?, to destination: Message.Destination) throws -> Promise { - return try MessageSender.send( + return try MessageSender.sendImmediate( db, message: message, to: destination, @@ -136,7 +153,7 @@ extension MessageSender { if forceSyncNow { try MessageSender - .send(db, message: configurationMessage, to: destination, interactionId: nil) + .sendImmediate(db, message: configurationMessage, to: destination, interactionId: nil) .done { seal.fulfill(()) } .catch { _ in seal.reject(GRDBStorageError.generic) } .retainUntilComplete() diff --git a/SessionMessagingKit/Sending & Receiving/MessageSender.swift b/SessionMessagingKit/Sending & Receiving/MessageSender.swift index fc20a055b..51a059a4d 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageSender.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageSender.swift @@ -67,7 +67,7 @@ public final class MessageSender : NSObject { // MARK: - Convenience - public static func send(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise { + public static func sendImmediate(_ db: Database, message: Message, to destination: Message.Destination, interactionId: Int64?) throws -> Promise { switch destination { case .contact(_), .closedGroup(_): return try sendToSnodeDestination(db, message: message, to: destination, interactionId: interactionId) @@ -88,15 +88,13 @@ public final class MessageSender : NSObject { ) throws -> Promise { let (promise, seal) = Promise.pending() let userPublicKey: String = getUserHexEncodedPublicKey(db) - let isMainAppActive: Bool = (UserDefaults.sharedLokiProject?[.isMainAppActive]).defaulting(to: false) // Set the timestamp, sender and recipient - if message.sentTimestamp == nil { // Visible messages will already have their sent timestamp set - } - message.sentTimestamp = UInt64(floor(Date().timeIntervalSince1970 * 1000)) - - let isSelfSend: Bool = (message.recipient == userPublicKey) + message.sentTimestamp = ( + message.sentTimestamp ?? // Visible messages will already have their sent timestamp set + UInt64(floor(Date().timeIntervalSince1970 * 1000)) + ) message.sender = userPublicKey message.recipient = { switch destination { @@ -123,6 +121,7 @@ public final class MessageSender : NSObject { // • a sync message // • a closed group control message of type `new` // • an unsend request + let isSelfSend: Bool = (message.recipient == userPublicKey) let isNewClosedGroupControlMessage: Bool = { switch (message as? ClosedGroupControlMessage)?.kind { case .new: return true @@ -147,14 +146,14 @@ public final class MessageSender : NSObject { let profile: Profile = Profile.fetchOrCreateCurrentUser(db) if let profileKey: Data = profile.profileEncryptionKey?.keyData, let profilePictureUrl: String = profile.profilePictureUrl { - message.profile = VisibleMessage.Profile( + message.profile = VisibleMessage.VMProfile( displayName: profile.name, profileKey: profileKey, profilePictureUrl: profilePictureUrl ) } else { - message.profile = VisibleMessage.Profile(displayName: profile.name) + message.profile = VisibleMessage.VMProfile(displayName: profile.name) } } @@ -371,7 +370,7 @@ public final class MessageSender : NSObject { } // Attach the user's profile - message.profile = VisibleMessage.Profile( + message.profile = VisibleMessage.VMProfile( profile: Profile.fetchOrCreateCurrentUser() ) @@ -471,8 +470,6 @@ public final class MessageSender : NSObject { try interaction.recipientStates .updateAll(db, RecipientState.Columns.state.set(to: RecipientState.State.sent)) - NotificationCenter.default.post(name: .messageSentStatusDidChange, object: nil, userInfo: nil) - // Start the disappearing messages timer if needed JobRunner.upsert( db, diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift index a2b65c1f8..07d594dae 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift @@ -7,7 +7,7 @@ import SessionSnodeKit @objc(LKClosedGroupPoller) public final class ClosedGroupPoller: NSObject { - private var isPolling: [String: Bool] = [:] + private var isPolling: Atomic<[String: Bool]> = Atomic([:]) private var timers: [String: Timer] = [:] private let internalQueue: DispatchQueue = DispatchQueue(label: "isPollingQueue") @@ -63,11 +63,12 @@ public final class ClosedGroupPoller: NSObject { } public func startPolling(for groupPublicKey: String) { - guard !isPolling(for: groupPublicKey) else { return } + guard isPolling.wrappedValue[groupPublicKey] != true else { return } + // Might be a race condition that the setUpPolling finishes too soon, // and the timer is not created, if we mark the group as is polling // after setUpPolling. So the poller may not work, thus misses messages. - internalQueue.sync{ isPolling[groupPublicKey] = true } + isPolling.mutate { $0[groupPublicKey] = true } setUpPolling(for: groupPublicKey) } @@ -86,7 +87,7 @@ public final class ClosedGroupPoller: NSObject { } public func stopPolling(for groupPublicKey: String) { - internalQueue.sync{ isPolling[groupPublicKey] = false } + isPolling.mutate { $0[groupPublicKey] = false } timers[groupPublicKey]?.invalidate() } @@ -107,7 +108,7 @@ public final class ClosedGroupPoller: NSObject { private func pollRecursively(_ groupPublicKey: String) { guard - isPolling(for: groupPublicKey), + isPolling.wrappedValue[groupPublicKey] == true, let thread: SessionThread = GRDBStorage.shared.read({ db in try SessionThread.fetchOne(db, id: groupPublicKey) }) else { return } @@ -149,78 +150,82 @@ public final class ClosedGroupPoller: NSObject { } private func poll(_ groupPublicKey: String) -> Promise { - guard isPolling(for: groupPublicKey) else { return Promise.value(()) } + guard isPolling.wrappedValue[groupPublicKey] == true else { return Promise.value(()) } - let promise = SnodeAPI.getSwarm(for: groupPublicKey) + let promise: Promise = SnodeAPI.getSwarm(for: groupPublicKey) .then2 { [weak self] swarm -> Promise<(Snode, [SnodeReceivedMessage])> in // randomElement() uses the system's default random generator, which is cryptographically secure guard let snode = swarm.randomElement() else { return Promise(error: Error.insufficientSnodes) } - guard let self = self, self.isPolling(for: groupPublicKey) else { + guard self?.isPolling.wrappedValue[groupPublicKey] == true else { return Promise(error: Error.pollingCanceled) } return SnodeAPI.getMessages(from: snode, associatedWith: groupPublicKey) .map2 { messages in (snode, messages) } } - - promise.done2 { [weak self] snode, messages in - guard self?.isPolling(for: groupPublicKey) == true else { return } - - if !messages.isEmpty { - SNLog("Received \(messages.count) message(s) in closed group with public key: \(groupPublicKey).") + .done2 { [weak self] snode, messages in + guard self?.isPolling.wrappedValue[groupPublicKey] == true else { return } - GRDBStorage.shared.write { db in - var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] + if !messages.isEmpty { + var messageCount: Int = 0 - messages.forEach { message in - guard let envelope = SNProtoEnvelope.from(message) else { return } + GRDBStorage.shared.write { db in + var jobDetailMessages: [MessageReceiveJob.Details.MessageInfo] = [] - do { - jobDetailMessages.append( - MessageReceiveJob.Details.MessageInfo( - data: try envelope.serializedData(), - serverHash: message.info.hash, - serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) - ) - ) + messages.forEach { message in + guard let envelope = SNProtoEnvelope.from(message) else { return } - // Persist the received message after the MessageReceiveJob is created - _ = try message.info.saved(db) - } - catch { - switch error { - // Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob) - case .SQLITE_CONSTRAINT_UNIQUE: break - - default: - SNLog("Failed to deserialize envelope due to error: \(error).") + do { + let serialisedData: Data = try envelope.serializedData() + _ = try message.info.inserted(db) + + // Ignore hashes for messages we have previously handled + guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else { + throw MessageReceiverError.duplicateMessage + } + + jobDetailMessages.append( + MessageReceiveJob.Details.MessageInfo( + data: serialisedData, + serverHash: message.info.hash, + serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) + ) + ) + } + catch { + switch error { + // Ignore duplicate messages + case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break + default: SNLog("Failed to deserialize envelope due to error: \(error).") + } } } - } - - JobRunner.add( - db, - job: Job( - variant: .messageReceive, - behaviour: .runOnce, - threadId: groupPublicKey, - details: MessageReceiveJob.Details( - messages: jobDetailMessages, - isBackgroundPoll: false + + messageCount = jobDetailMessages.count + + JobRunner.add( + db, + job: Job( + variant: .messageReceive, + behaviour: .runOnce, + threadId: groupPublicKey, + details: MessageReceiveJob.Details( + messages: jobDetailMessages, + isBackgroundPoll: false + ) ) ) - ) + } + + SNLog("Received \(messageCount) message(s) in closed group with public key: \(groupPublicKey).") } } - } + .map { _ in } + promise.catch2 { error in SNLog("Polling failed for closed group with public key: \(groupPublicKey) due to error: \(error).") } - return promise.map { _ in } - } - - // MARK: Convenience - private func isPolling(for groupPublicKey: String) -> Bool { - return internalQueue.sync{ isPolling[groupPublicKey] ?? false } + + return promise } } diff --git a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift index a58ea941d..6ef9cb70e 100644 --- a/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift +++ b/SessionMessagingKit/Sending & Receiving/Pollers/Poller.swift @@ -9,11 +9,12 @@ import SessionSnodeKit @objc(LKPoller) public final class Poller : NSObject { private let storage = OWSPrimaryStorage.shared() - private var isPolling = false + private var isPolling: Atomic = Atomic(false) private var usedSnodes = Set() private var pollCount = 0 - // MARK: Settings + // MARK: - Settings + private static let pollInterval: TimeInterval = 1.5 private static let retryInterval: TimeInterval = 0.25 /// After polling a given snode this many times we always switch to a new one. @@ -22,89 +23,103 @@ public final class Poller : NSObject { /// it isn't actually getting messages from other snodes. private static let maxPollCount: UInt = 6 - // MARK: Error + // MARK: - Error + private enum Error : LocalizedError { case pollLimitReached var localizedDescription: String { switch self { - case .pollLimitReached: return "Poll limit reached for current snode." + case .pollLimitReached: return "Poll limit reached for current snode." } } } - // MARK: Public API + // MARK: - Public API + @objc public func startIfNeeded() { - guard !isPolling else { return } + guard !isPolling.wrappedValue else { return } + SNLog("Started polling.") - isPolling = true + isPolling.mutate { $0 = true } setUpPolling() } @objc public func stop() { SNLog("Stopped polling.") - isPolling = false + isPolling.mutate { $0 = false } usedSnodes.removeAll() } - // MARK: Private API + // MARK: - Private API + private func setUpPolling() { - guard isPolling else { return } - Threading.pollerQueue.async { - let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey()).then(on: Threading.pollerQueue) { [weak self] _ -> Promise in - guard let strongSelf = self else { return Promise { $0.fulfill(()) } } - strongSelf.usedSnodes.removeAll() - let (promise, seal) = Promise.pending() - strongSelf.pollNextSnode(seal: seal) - return promise - }.ensure(on: Threading.pollerQueue) { [weak self] in // Timers don't do well on background queues - guard let strongSelf = self, strongSelf.isPolling else { return } - Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in - guard let strongSelf = self else { return } - strongSelf.setUpPolling() - } - } - } + guard isPolling.wrappedValue else { return } + Threading.pollerQueue.async { + let _ = SnodeAPI.getSwarm(for: getUserHexEncodedPublicKey()) + .then(on: Threading.pollerQueue) { [weak self] _ -> Promise in + let (promise, seal) = Promise.pending() + + self?.usedSnodes.removeAll() + self?.pollNextSnode(seal: seal) + + return promise + } + .ensure(on: Threading.pollerQueue) { [weak self] in // Timers don't do well on background queues + guard self?.isPolling.wrappedValue == true else { return } + + Timer.scheduledTimerOnMainThread(withTimeInterval: Poller.retryInterval, repeats: false) { _ in + self?.setUpPolling() + } + } + } } private func pollNextSnode(seal: Resolver) { let userPublicKey = getUserHexEncodedPublicKey() let swarm = SnodeAPI.swarmCache[userPublicKey] ?? [] let unusedSnodes = swarm.subtracting(usedSnodes) - if !unusedSnodes.isEmpty { - // randomElement() uses the system's default random generator, which is cryptographically secure - let nextSnode = unusedSnodes.randomElement()! - usedSnodes.insert(nextSnode) - poll(nextSnode, seal: seal).done2 { + + guard !unusedSnodes.isEmpty else { + seal.fulfill(()) + return + } + + // randomElement() uses the system's default random generator, which is cryptographically secure + let nextSnode = unusedSnodes.randomElement()! + usedSnodes.insert(nextSnode) + + poll(nextSnode, seal: seal) + .done2 { seal.fulfill(()) - }.catch2 { [weak self] error in + } + .catch2 { [weak self] error in if let error = error as? Error, error == .pollLimitReached { self?.pollCount = 0 - } else { + } + else { SNLog("Polling \(nextSnode) failed; dropping it and switching to next snode.") SnodeAPI.dropSnodeFromSwarmIfNeeded(nextSnode, publicKey: userPublicKey) } + Threading.pollerQueue.async { self?.pollNextSnode(seal: seal) } } - } else { - seal.fulfill(()) - } } private func poll(_ snode: Snode, seal longTermSeal: Resolver) -> Promise { - guard isPolling else { return Promise { $0.fulfill(()) } } + guard isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } let userPublicKey = getUserHexEncodedPublicKey() return SnodeAPI.getMessages(from: snode, associatedWith: userPublicKey) .then(on: Threading.pollerQueue) { [weak self] messages -> Promise in - guard self?.isPolling == true else { return Promise { $0.fulfill(()) } } + guard self?.isPolling.wrappedValue == true else { return Promise { $0.fulfill(()) } } if !messages.isEmpty { - SNLog("Received \(messages.count) message(s).") + var messageCount: Int = 0 GRDBStorage.shared.write { db in var threadMessages: [String: [MessageReceiveJob.Details.MessageInfo]] = [:] @@ -117,25 +132,33 @@ public final class Poller : NSObject { let threadId: String? = MessageReceiver.extractSenderPublicKey(db, from: envelope) if threadId == nil { + // TODO: I assume a configuration message doesn't need a 'threadId' (confirm this and set the 'requiresThreadId' requirement accordingly) + // TODO: Does the configuration message come through here???? + print("RAWR WHAT CASES LETS THIS BE NIL????") } do { + let serialisedData: Data = try envelope.serializedData() + _ = try message.info.inserted(db) + + // Ignore hashes for messages we have previously handled + guard try SnodeReceivedMessageInfo.filter(SnodeReceivedMessageInfo.Columns.hash == message.info.hash).fetchCount(db) == 1 else { + throw MessageReceiverError.duplicateMessage + } + threadMessages[threadId ?? ""] = (threadMessages[threadId ?? ""] ?? []) .appending( MessageReceiveJob.Details.MessageInfo( - data: try envelope.serializedData(), + data: serialisedData, serverHash: message.info.hash, serverExpirationTimestamp: (TimeInterval(message.info.expirationDateMs) / 1000) ) ) - - // Persist the received message after the MessageReceiveJob is created - _ = try message.info.saved(db) } catch { switch error { - // Ignore unique constraint violations here (they will be hanled in the MessageReceiveJob) - case .SQLITE_CONSTRAINT_UNIQUE: break + // Ignore duplicate messages + case .SQLITE_CONSTRAINT_UNIQUE, MessageReceiverError.duplicateMessage: break default: SNLog("Failed to deserialize envelope due to error: \(error).") @@ -143,6 +166,10 @@ public final class Poller : NSObject { } } + messageCount = threadMessages + .values + .reduce(into: 0) { prev, next in prev += next.count } + threadMessages.forEach { threadId, threadMessages in JobRunner.add( db, @@ -158,6 +185,8 @@ public final class Poller : NSObject { ) } } + + SNLog("Received \(messageCount) message(s).") } self?.pollCount += 1 @@ -167,7 +196,8 @@ public final class Poller : NSObject { } return withDelay(Poller.pollInterval, completionQueue: Threading.pollerQueue) { - guard let strongSelf = self, strongSelf.isPolling else { return Promise { $0.fulfill(()) } } + guard let strongSelf = self, strongSelf.isPolling.wrappedValue else { return Promise { $0.fulfill(()) } } + return strongSelf.poll(snode, seal: longTermSeal) } } diff --git a/SessionMessagingKit/Threads/Notification+Thread.swift b/SessionMessagingKit/Threads/Notification+Thread.swift index 4b61f8f1b..77b74d0ef 100644 --- a/SessionMessagingKit/Threads/Notification+Thread.swift +++ b/SessionMessagingKit/Threads/Notification+Thread.swift @@ -3,12 +3,10 @@ public extension Notification.Name { static let groupThreadUpdated = Notification.Name("groupThreadUpdated") static let muteSettingUpdated = Notification.Name("muteSettingUpdated") - static let messageSentStatusDidChange = Notification.Name("messageSentStatusDidChange") } @objc public extension NSNotification { @objc static let groupThreadUpdated = Notification.Name.groupThreadUpdated.rawValue as NSString @objc static let muteSettingUpdated = Notification.Name.muteSettingUpdated.rawValue as NSString - @objc static let messageSentStatusDidChange = Notification.Name.messageSentStatusDidChange.rawValue as NSString } diff --git a/SessionMessagingKit/Utilities/SSKEnvironment.swift b/SessionMessagingKit/Utilities/SSKEnvironment.swift index f599d7347..89a0912da 100644 --- a/SessionMessagingKit/Utilities/SSKEnvironment.swift +++ b/SessionMessagingKit/Utilities/SSKEnvironment.swift @@ -8,7 +8,6 @@ public class SSKEnvironment: NSObject { @objc public let primaryStorage: OWSPrimaryStorage public let tsAccountManager: TSAccountManager public let reachabilityManager: SSKReachabilityManager - @objc public let typingIndicators: TypingIndicators // Note: This property is configured after Environment is created. public let notificationsManager: Atomic = Atomic(nil) @@ -29,13 +28,11 @@ public class SSKEnvironment: NSObject { @objc public init( primaryStorage: OWSPrimaryStorage, tsAccountManager: TSAccountManager, - reachabilityManager: SSKReachabilityManager, - typingIndicators: TypingIndicators + reachabilityManager: SSKReachabilityManager ) { self.primaryStorage = primaryStorage self.tsAccountManager = tsAccountManager self.reachabilityManager = reachabilityManager - self.typingIndicators = typingIndicators self.objectReadWriteConnection = primaryStorage.newDatabaseConnection() self.sessionStoreDBConnection = primaryStorage.newDatabaseConnection() diff --git a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift index 42cb1e425..7ee5082d7 100644 --- a/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_001_InitialSetupMigration.swift @@ -39,7 +39,9 @@ enum _001_InitialSetupMigration: Migration { t.column(.key, .text) .notNull() .indexed() // Quicker querying - t.column(.hash, .text).notNull() + t.column(.hash, .text) + .notNull() + .indexed() // Quicker querying t.column(.expirationDateMs, .integer) .notNull() .indexed() // Quicker querying diff --git a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift index bc760e87e..09cf59825 100644 --- a/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift +++ b/SessionSnodeKit/Database/Migrations/_003_YDBToGRDBMigration.swift @@ -122,8 +122,8 @@ enum _003_YDBToGRDBMigration: Migration { guard let lastMessageJson = object as? JSON else { return } guard let lastMessageHash: String = lastMessageJson["hash"] as? String else { return } - // Note: We remove the value from 'receivedMessageResults' as we don't want to default it's - // expiration value to 0 + // Note: We remove the value from 'receivedMessageResults' as we want to try and use + // it's actual 'expirationDate' value lastMessageResults[key] = (lastMessageHash, lastMessageJson) receivedMessageResults[key] = receivedMessageResults[key]?.removing(lastMessageHash) } @@ -135,16 +135,21 @@ enum _003_YDBToGRDBMigration: Migration { _ = try SnodeReceivedMessageInfo( key: key, hash: hash, - expirationDateMs: 0 + expirationDateMs: SnodeReceivedMessage.defaultExpirationSeconds ).inserted(db) } } try lastMessageResults.forEach { key, data in + let expirationDateMs: Int64 = ((data.json["expirationDate"] as? Int64) ?? 0) + _ = try SnodeReceivedMessageInfo( key: key, hash: data.hash, - expirationDateMs: ((data.json["expirationDate"] as? Int64) ?? 0) + expirationDateMs: (expirationDateMs > 0 ? + expirationDateMs : + SnodeReceivedMessage.defaultExpirationSeconds + ) ).inserted(db) } } diff --git a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift index 78df09302..4d1b8cf96 100644 --- a/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift +++ b/SessionSnodeKit/Database/Models/SnodeReceivedMessageInfo.swift @@ -29,7 +29,8 @@ public struct SnodeReceivedMessageInfo: Codable, FetchableRecord, MutablePersist /// This is the timestamp (in milliseconds since epoch) when the message hash should expire /// - /// **Note:** A value of `0` means this hash should not expire + /// **Note:** If no value exists this will default to 15 days from now (since the service node caches messages for + /// 14 days) public let expirationDateMs: Int64 // MARK: - Custom Database Interaction @@ -62,9 +63,17 @@ public extension SnodeReceivedMessageInfo { public extension SnodeReceivedMessageInfo { static func pruneExpiredMessageHashInfo(for snode: Snode, associatedWith publicKey: String) { - // Delete any expired (but non-0) SnodeReceivedMessageInfo values associated to a specific node + // Delete any expired SnodeReceivedMessageInfo values associated to a specific node GRDBStorage.shared.write { db in - try? SnodeReceivedMessageInfo + // Only prune the hashes if new hashes exist for this Snode (if they don't then we don't want + // to clear out the legacy hashes) + let hasNonLegacyHash: Bool = try SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) + .isNotEmpty(db) + + guard hasNonLegacyHash else { return } + + try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs <= (Date().timeIntervalSince1970 * 1000)) .deleteAll(db) @@ -78,11 +87,20 @@ public extension SnodeReceivedMessageInfo { /// pointless fetch for data the app has already received static func fetchLastNotExpired(for snode: Snode, associatedWith publicKey: String) -> SnodeReceivedMessageInfo? { return GRDBStorage.shared.write { db in - try SnodeReceivedMessageInfo + let nonLegacyHash: SnodeReceivedMessageInfo? = try SnodeReceivedMessageInfo .filter(SnodeReceivedMessageInfo.Columns.key == key(for: snode, publicKey: publicKey)) .filter(SnodeReceivedMessageInfo.Columns.expirationDateMs > (Date().timeIntervalSince1970 * 1000)) .order(SnodeReceivedMessageInfo.Columns.id.desc) .fetchOne(db) + + // If we have a non-legacy hash then return it immediately (legacy hashes had a different + // 'key' structure) + if nonLegacyHash != nil { return nonLegacyHash } + + return try SnodeReceivedMessageInfo + .filter(SnodeReceivedMessageInfo.Columns.key == publicKey) + .order(SnodeReceivedMessageInfo.Columns.id.desc) + .fetchOne(db) } } } diff --git a/SessionSnodeKit/Models/SnodeReceivedMessage.swift b/SessionSnodeKit/Models/SnodeReceivedMessage.swift index 36bd94cc9..85d1b0d9e 100644 --- a/SessionSnodeKit/Models/SnodeReceivedMessage.swift +++ b/SessionSnodeKit/Models/SnodeReceivedMessage.swift @@ -4,6 +4,10 @@ import Foundation import SessionUtilitiesKit public struct SnodeReceivedMessage: CustomDebugStringConvertible { + /// Service nodes cache messages for 14 days so default the expiration for message hashes to '15' days + /// so we don't end up indefinitely storing records which will never be used + public static let defaultExpirationSeconds: Int64 = ((15 * 24 * 60 * 60) * 1000) + public let info: SnodeReceivedMessageInfo public let data: Data @@ -18,11 +22,12 @@ public struct SnodeReceivedMessage: CustomDebugStringConvertible { return nil } + let expirationDateMs: Int64? = (rawMessage["expiration"] as? Int64) self.info = SnodeReceivedMessageInfo( snode: snode, publicKey: publicKey, hash: hash, - expirationDateMs: rawMessage["expiration"] as? Int64 + expirationDateMs: (expirationDateMs ?? SnodeReceivedMessage.defaultExpirationSeconds) ) self.data = data } diff --git a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift index e643e3633..676eb1252 100644 --- a/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/BlockingManagerRemovalMigration.swift @@ -28,10 +28,10 @@ public class BlockingManagerRemovalMigration: OWSDatabaseMigration { Storage.write( with: { transaction in - var result: Set = [] + var result: Set = [] transaction.enumerateRows(inCollection: Legacy.contactCollection) { _, object, _, _ in - guard let contact = object as? SessionMessagingKit.Legacy.Contact else { return } + guard let contact = object as? SessionMessagingKit.Legacy._Contact else { return } result.insert(contact) } diff --git a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift index 23e3534f4..f0cbead75 100644 --- a/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/ContactsMigration.swift @@ -16,17 +16,17 @@ public class ContactsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: [SMKLegacy.Contact] = [] + var contacts: [SMKLegacy._Contact] = [] TSContactThread.enumerateCollectionObjects { object, _ in guard let thread = object as? TSContactThread else { return } let sessionID = thread.contactSessionID() - var contact: SMKLegacy.Contact? + var contact: SMKLegacy._Contact? Storage.read { transaction in - contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy.Contact + contact = transaction.object(forKey: sessionID, inCollection: SMKLegacy.contactCollection) as? SMKLegacy._Contact } - if let contact: SMKLegacy.Contact = contact { + if let contact: SMKLegacy._Contact = contact { contact.isTrusted = true contacts.append(contact) } diff --git a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift index aeb57e122..6593880a3 100644 --- a/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift +++ b/SignalUtilitiesKit/Database/Migrations/MessageRequestsMigration.swift @@ -16,7 +16,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { } private func doMigrationAsync(completion: @escaping OWSDatabaseMigrationCompletion) { - var contacts: Set = Set() + var contacts: Set = Set() var threads: [TSThread] = [] TSThread.enumerateCollectionObjects { object, _ in @@ -26,7 +26,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { if let contactThread: TSContactThread = thread as? TSContactThread { let sessionId: String = contactThread.contactSessionID() - if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) @@ -36,7 +36,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { let groupAdmins: [String] = groupThread.groupModel.groupAdminIds groupAdmins.forEach { sessionId in - if let contact: SessionMessagingKit.Legacy.Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + if let contact: SMKLegacy._Contact = transaction.object(forKey: sessionId, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { contact.isApproved = true contact.didApproveMe = true contacts.insert(contact) @@ -51,7 +51,7 @@ public class MessageRequestsMigration : OWSDatabaseMigration { let userPublicKey: String = getUserHexEncodedPublicKey() Storage.read { transaction in - if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SessionMessagingKit.Legacy.Contact { + if let user = transaction.object(forKey: userPublicKey, inCollection: Legacy.contactCollection) as? SMKLegacy._Contact { user.isApproved = true user.didApproveMe = true contacts.insert(user)