diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index a32ba4123..af7b53f88 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 2155E9EF26E3628C008FB033 /* Refreshable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2155E9EE26E3628C008FB033 /* Refreshable.swift */; }; 215897E8267A553300423694 /* FilesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 215897E7267A553200423694 /* FilesManager.swift */; }; 21594C9626F1DBA900BE654C /* data.txt in Resources */ = {isa = PBXBuildFile; fileRef = 21594C9526F1DBA900BE654C /* data.txt */; }; + 21623D1826FA860700A11B9A /* PhotosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21623D1726FA860600A11B9A /* PhotosManager.swift */; }; 21750D7D26C6AFA6007E6A6F /* SetupEKMKeyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21750D7C26C6AFA6007E6A6F /* SetupEKMKeyViewController.swift */; }; 21750D7F26C6C1E3007E6A6F /* SetupCreatePassphraseAbstractViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21750D7E26C6C1E3007E6A6F /* SetupCreatePassphraseAbstractViewController.swift */; }; 2196A2202684B9BE001B9E00 /* URLExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2196A21F2684B9BE001B9E00 /* URLExtension.swift */; }; @@ -36,6 +37,7 @@ 21EA3B592656611D00691848 /* OrganisationalRule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21EA3B15265647C400691848 /* OrganisationalRule.swift */; }; 21EFF61F265A5C6700AB0B71 /* WKDURLsApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21EFF61E265A5C6700AB0B71 /* WKDURLsApi.swift */; }; 21F836B62652A26B00B2448C /* DataExntensions+ZBase32Encoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F836B52652A26B00B2448C /* DataExntensions+ZBase32Encoding.swift */; }; + 21FEE26626FDD91A00E3783F /* ComposeMessageAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FEE26526FDD91A00E3783F /* ComposeMessageAttachment.swift */; }; 32DCA00224982EDA88D69C6E /* AppErr.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DCA4B11D4531B3B04D01D1 /* AppErr.swift */; }; 32DCA04CA0DAB79C39514782 /* CoreTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DCAC732B988D9704658812 /* CoreTypes.swift */; }; 32DCA1414EEA727B86C337D5 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32DCA0C3D34A69851A238E87 /* Core.swift */; }; @@ -407,6 +409,7 @@ 2155E9EE26E3628C008FB033 /* Refreshable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Refreshable.swift; sourceTree = ""; }; 215897E7267A553200423694 /* FilesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilesManager.swift; sourceTree = ""; }; 21594C9526F1DBA900BE654C /* data.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = data.txt; sourceTree = ""; }; + 21623D1726FA860600A11B9A /* PhotosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotosManager.swift; sourceTree = ""; }; 21750D7C26C6AFA6007E6A6F /* SetupEKMKeyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupEKMKeyViewController.swift; sourceTree = ""; }; 21750D7E26C6C1E3007E6A6F /* SetupCreatePassphraseAbstractViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupCreatePassphraseAbstractViewController.swift; sourceTree = ""; }; 2196A21F2684B9BE001B9E00 /* URLExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLExtension.swift; sourceTree = ""; }; @@ -425,6 +428,7 @@ 21F836B52652A26B00B2448C /* DataExntensions+ZBase32Encoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataExntensions+ZBase32Encoding.swift"; sourceTree = ""; }; 21F836CB2652A38700B2448C /* ZBase32EncodingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZBase32EncodingTests.swift; sourceTree = ""; }; 21F836D22652A46E00B2448C /* WKDURLsConstructorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WKDURLsConstructorTests.swift; sourceTree = ""; }; + 21FEE26526FDD91A00E3783F /* ComposeMessageAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeMessageAttachment.swift; sourceTree = ""; }; 27D857C43583281B45F427F8 /* Pods-FlowCryptUI.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlowCryptUI.release.xcconfig"; path = "Target Support Files/Pods-FlowCryptUI/Pods-FlowCryptUI.release.xcconfig"; sourceTree = ""; }; 2B4C2647A021692455F9DFAD /* Pods-FlowCryptAppTests.enterprise.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FlowCryptAppTests.enterprise.xcconfig"; path = "Target Support Files/Pods-FlowCryptAppTests/Pods-FlowCryptAppTests.enterprise.xcconfig"; sourceTree = ""; }; 32DCA058652FD4616FB04FB6 /* SequenceExtensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SequenceExtensions.swift; sourceTree = ""; }; @@ -868,6 +872,14 @@ path = FilesManager; sourceTree = ""; }; + 21623D1926FA860E00A11B9A /* PhotosManager */ = { + isa = PBXGroup; + children = ( + 21623D1726FA860600A11B9A /* PhotosManager.swift */, + ); + path = PhotosManager; + sourceTree = ""; + }; 21CE25D32650034500ADFF4B /* WKDURLs */ = { isa = PBXGroup; children = ( @@ -1300,6 +1312,7 @@ children = ( 9F6F3BEC26ADF5DE005BD9C6 /* ComposeMessageService.swift */, 9F6F3BED26ADF5DE005BD9C6 /* ComposeMessageError.swift */, + 21FEE26526FDD91A00E3783F /* ComposeMessageAttachment.swift */, ); path = "Compose Message Service"; sourceTree = ""; @@ -1624,6 +1637,7 @@ C132B9C61EC2DCC000763715 /* Functionality */ = { isa = PBXGroup; children = ( + 21623D1926FA860E00A11B9A /* PhotosManager */, 215897E6267A551300423694 /* FilesManager */, 21CE25D32650034500ADFF4B /* WKDURLs */, A370EAB6238697E000685215 /* Pgp */, @@ -2540,6 +2554,7 @@ 9FB22CE425715D3E0026EE64 /* GmailServiceErrorHandler.swift in Sources */, 9F4163E6266520B600106194 /* CommonNodesInputs.swift in Sources */, 04B472951ECE29F600B8266F /* MyMenuViewController.swift in Sources */, + 21623D1826FA860700A11B9A /* PhotosManager.swift in Sources */, C132B9B41EC2DBD800763715 /* AppDelegate.swift in Sources */, 21489B7C267CBA0E00BDE4AC /* ClientConfiguration.swift in Sources */, 9F976592267E19880058419D /* TestData.swift in Sources */, @@ -2681,6 +2696,7 @@ 32DCAF9DA9EC47798DF8BB73 /* SignInViewController.swift in Sources */, 21489B78267CB42400BDE4AC /* ClientConfigurationProvider.swift in Sources */, 9FF0671C25520D9D00FCC9E6 /* MailProvider.swift in Sources */, + 21FEE26626FDD91A00E3783F /* ComposeMessageAttachment.swift in Sources */, 9FE743072347AA54005E2DBB /* MainNavigationController.swift in Sources */, 9F5C2A8B257E6C4900DE9B4B /* ImapError.swift in Sources */, 32DCA594BD65DE3AF94569F3 /* ComposeViewController.swift in Sources */, diff --git a/FlowCrypt/Controllers/Compose/ComposeViewController.swift b/FlowCrypt/Controllers/Compose/ComposeViewController.swift index 9aad4828c..02512c0f2 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewController.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewController.swift @@ -34,6 +34,8 @@ final class ComposeViewController: TableNodeViewController { private let notificationCenter: NotificationCenter private let decorator: ComposeViewDecorator private let contactsService: ContactsServiceType + private let filesManager: FilesManagerType + private let photosManager: PhotosManagerType private let searchThrottler = Throttler(seconds: 1) private let cloudContactProvider: CloudContactsProvider @@ -55,7 +57,9 @@ final class ComposeViewController: TableNodeViewController { cloudContactProvider: CloudContactsProvider = UserContactsProvider(), userDefaults: UserDefaults = .standard, contactsService: ContactsServiceType = ContactsService(), - composeMessageService: ComposeMessageService = ComposeMessageService() + composeMessageService: ComposeMessageService = ComposeMessageService(), + filesManager: FilesManagerType = FilesManager(), + photosManager: PhotosManagerType = PhotosManager() ) { self.email = email self.notificationCenter = notificationCenter @@ -65,6 +69,8 @@ final class ComposeViewController: TableNodeViewController { self.userDefaults = userDefaults self.contactsService = contactsService self.composeMessageService = composeMessageService + self.filesManager = filesManager + self.photosManager = photosManager self.contextToSend.subject = input.subject super.init(node: TableNode()) } @@ -184,8 +190,7 @@ extension ComposeViewController { } @objc private func handleAttachTap() { - #warning("ToDo") - showToast("Attachments not implemented yet") + openAttachmentsInputSourcesSheet() } @objc private func handleSendTap() { @@ -198,15 +203,13 @@ extension ComposeViewController { extension ComposeViewController { private func sendMessage() { view.endEditing(true) - showSpinner("sending_title".localized) navigationItem.rightBarButtonItem?.isEnabled = false composeMessageService.validateMessage( input: input, contextToSend: contextToSend, - email: email, - atts: [] + email: email ) .publisher .flatMap(composeMessageService.encryptAndSend) @@ -243,7 +246,7 @@ extension ComposeViewController { extension ComposeViewController: ASTableDelegate, ASTableDataSource { func numberOfSections(in _: ASTableNode) -> Int { - 2 + 3 } func tableNode(_: ASTableNode, numberOfRowsInSection section: Int) -> Int { @@ -252,6 +255,8 @@ extension ComposeViewController: ASTableDelegate, ASTableDataSource { return RecipientParts.allCases.count case (.main, 1): return ComposeParts.allCases.count + case (.main, 2): + return contextToSend.attachments.count case (.searchEmails, 0): return RecipientParts.allCases.count case let (.searchEmails(emails), 1): @@ -285,6 +290,11 @@ extension ComposeViewController: ASTableDelegate, ASTableDataSource { case .text: return self.textNode(with: nodeHeight) case .subjectDivider: return DividerCellNode() } + case (.main, 2): + guard !self.contextToSend.attachments.isEmpty else { + return ASCellNode() + } + return self.attachmentNode(for: indexPath.row) case let (.searchEmails(emails), 1): return InfoCellNode(input: self.decorator.styledRecipientInfo(with: emails[indexPath.row])) default: @@ -328,21 +338,17 @@ extension ComposeViewController { } private func textNode(with nodeHeight: CGFloat) -> ASCellNode { - let textFieldHeight = decorator.styledTextFieldInput(with: "").height - let dividerHeight: CGFloat = 1 - let preferredHeight = nodeHeight - 2 * (textFieldHeight + dividerHeight) - return TextViewCellNode( - decorator.styledTextViewInput(with: preferredHeight) - ) { [weak self] event in - guard case let .didEndEditing(text) = event else { return } - self?.contextToSend.message = text?.string - } - .then { - guard self.input.isReply else { return } - $0.textView.attributedText = self.decorator.styledReplyQuote(with: self.input) - $0.becomeFirstResponder() - } + decorator.styledTextViewInput(with: 40), + action: { [weak self] event in + guard case let .didEndEditing(text) = event else { return } + self?.contextToSend.message = text?.string + }) + .then { + guard self.input.isReply else { return } + $0.textView.attributedText = self.decorator.styledReplyQuote(with: self.input) + $0.becomeFirstResponder() + } } private func recipientsNode() -> RecipientEmailsCellNode { @@ -357,7 +363,7 @@ extension ComposeViewController { private func recipientInput() -> TextFieldCellNode { TextFieldCellNode( - input: decorator.styledTextFieldInput(with: "compose_recipient".localized) + input: decorator.styledTextFieldInput(with: "compose_recipient".localized, keyboardType: .emailAddress) ) { [weak self] action in self?.handleTextFieldAction(with: action) } @@ -375,6 +381,14 @@ extension ComposeViewController { } } } + + private func attachmentNode(for index: Int) -> ASCellNode { + AttachmentNode( + input: .init( + composeAttachment: contextToSend.attachments[index] + ) + ) + } } // MARK: - Recipients Input @@ -633,6 +647,132 @@ extension ComposeViewController { } } +// MARK: - UIDocumentPickerDelegate +extension ComposeViewController: UIDocumentPickerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let fileUrl = urls.first, + let attachment = ComposeMessageAttachment(fileURL: fileUrl) + else { + showAlert(message: "files_picking_files_error_message".localized) + return + } + appendAttachmentIfAllowed(attachment) + node.reloadSections(IndexSet(integer: 2), with: .automatic) + } +} + +// MARK: - UIImagePickerControllerDelegate & UINavigationControllerDelegate +extension ComposeViewController: UIImagePickerControllerDelegate, UINavigationControllerDelegate { + func imagePickerController( + _ picker: UIImagePickerController, + didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any] + ) { + picker.dismiss(animated: true, completion: nil) + + let attachment: ComposeMessageAttachment? + switch picker.sourceType { + case .camera: + attachment = ComposeMessageAttachment(cameraSourceMediaInfo: info) + case .photoLibrary: + attachment = ComposeMessageAttachment(librarySourceMediaInfo: info) + default: fatalError("No other image picker's sources should be used") + } + guard let attachment = attachment else { + showAlert(message: "files_picking_photos_error_message".localized) + return + } + appendAttachmentIfAllowed(attachment) + node.reloadSections(IndexSet(integer: 2), with: .automatic) + } + + private func appendAttachmentIfAllowed(_ attachment: ComposeMessageAttachment) { + let totalSize = contextToSend.attachments.reduce(0, { $0 + $1.size }) + if totalSize > GeneralConstants.Global.attachmentSizeLimit { + showToast("files_picking_size_error_message".localized) + } else { + contextToSend.attachments.append(attachment) + } + } +} + +// MARK: - Attachments sheet handling +extension ComposeViewController { + private func openAttachmentsInputSourcesSheet() { + let alert = UIAlertController( + title: "files_picking_select_input_source_title".localized, + message: nil, preferredStyle: .actionSheet + ) + alert.addAction( + UIAlertAction( + title: "files_picking_camera_input_source".localized, + style: .default, + handler: { [weak self] _ in + guard let self = self else { return } + self.photosManager.selectPhoto(source: .camera, from: self) + .sinkFuture( + receiveValue: {}, + receiveError: { _ in + self.showNoAccessToPhotosAlert() + } + ) + .store(in: &self.cancellable) + } + ) + ) + alert.addAction( + UIAlertAction( + title: "files_picking_photo_library_source".localized, + style: .default, + handler: { [weak self] _ in + guard let self = self else { return } + self.photosManager.selectPhoto(source: .photoLibrary, from: self) + .sinkFuture( + receiveValue: {}, + receiveError: { _ in + self.showNoAccessToPhotosAlert() + } + ) + .store(in: &self.cancellable) + } + ) + ) + alert.addAction( + UIAlertAction( + title: "files_picking_files_source".localized, + style: .default, + handler: { [weak self] _ in + guard let self = self else { return } + self.filesManager.selectFromFilesApp(from: self) + } + ) + ) + alert.addAction(UIAlertAction(title: "cancel".localized, style: .cancel)) + present(alert, animated: true, completion: nil) + } + + private func showNoAccessToPhotosAlert() { + let alert = UIAlertController( + title: "files_picking_no_library_access_error_title".localized, + message: "files_picking_no_library_access_error_message".localized, + preferredStyle: .alert + ) + let okAction = UIAlertAction( + title: "OK", + style: .cancel + ) { _ in } + let settingsAction = UIAlertAction( + title: "setttings".localized, + style: .default + ) { _ in + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + alert.addAction(okAction) + alert.addAction(settingsAction) + + present(alert, animated: true, completion: nil) + } +} + // temporary disable search contacts // https://github.com/FlowCrypt/flowcrypt-ios/issues/217 extension ComposeViewController { @@ -650,7 +790,7 @@ extension ComposeViewController { style: .default ) { _ in } let cancelAction = UIAlertAction( - title: "Cancel", + title: "cancel".localized, style: .destructive ) { _ in } alert.addAction(okAction) diff --git a/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift b/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift index 00d6c23c6..3cb0b7305 100644 --- a/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift +++ b/FlowCrypt/Controllers/Compose/ComposeViewDecorator.swift @@ -32,7 +32,7 @@ struct ComposeViewDecorator { ) } - func styledTextFieldInput(with text: String) -> TextFieldCellNode.Input { + func styledTextFieldInput(with text: String, keyboardType: UIKeyboardType = .default) -> TextFieldCellNode.Input { TextFieldCellNode.Input( placeholder: text.localized.attributed( .regular(17), @@ -43,7 +43,8 @@ struct ComposeViewDecorator { textInsets: -8, textAlignment: .left, height: 40, - width: UIScreen.main.bounds.width + width: UIScreen.main.bounds.width, + keyboardType: keyboardType ) } @@ -190,3 +191,15 @@ extension RecipientEmailsCellNode.Input { ) } } + +// MARK: - AttachmentNode.Input +extension AttachmentNode.Input { + init(composeAttachment: ComposeMessageAttachment) { + self.init( + name: composeAttachment.name + .attributed(.regular(18), color: .textColor, alignment: .left), + size: "\(composeAttachment.size)" + .attributed(.medium(12), color: .textColor, alignment: .left) + ) + } +} diff --git a/FlowCrypt/Controllers/Msg/MessageViewController.swift b/FlowCrypt/Controllers/Msg/MessageViewController.swift index 25b9c5c12..9ccc2a3b8 100644 --- a/FlowCrypt/Controllers/Msg/MessageViewController.swift +++ b/FlowCrypt/Controllers/Msg/MessageViewController.swift @@ -6,6 +6,7 @@ import AsyncDisplayKit import FlowCryptCommon import FlowCryptUI import Promises +import Combine /** * View controller to render an email message (sender, subject, message body, attachments) @@ -55,6 +56,8 @@ final class MessageViewController: TableNodeViewController { typealias MsgViewControllerCompletion = (MessageAction, Message) -> Void private let onCompletion: MsgViewControllerCompletion? + private var cancellable = Set() + private var input: MessageViewController.Input? private let decorator: MessageViewDecorator private let messageService: MessageService @@ -439,11 +442,15 @@ extension MessageViewController: ASTableDelegate, ASTableDataSource { onDownloadTap: { [weak self] in guard let self = self else { return } self.filesManager.saveToFilesApp(file: self.processedMessage.attachments[index], from: self) - .catch { error in - self.showToast( - "\("message_attachment_saved_with_error".localized) \(error.localizedDescription)" - ) - } + .sinkFuture( + receiveValue: {}, + receiveError: { error in + self.showToast( + "\("message_attachment_saved_with_error".localized) \(error.localizedDescription)" + ) + } + ) + .store(in: &self.cancellable) } ) } diff --git a/FlowCrypt/Core/Core.swift b/FlowCrypt/Core/Core.swift index 90cfe8770..3a5d6a27f 100644 --- a/FlowCrypt/Core/Core.swift +++ b/FlowCrypt/Core/Core.swift @@ -4,6 +4,7 @@ import FlowCryptCommon import JavaScriptCore +import Combine enum CoreError: Error, Equatable { case exception(String) @@ -39,6 +40,8 @@ final class Core: KeyDecrypter, CoreComposeMessageType { private var context: JSContext? private dynamic var started = false private dynamic var ready = false + + private let queue = DispatchQueue(label: "com.flowcrypt.core", qos: .background) private lazy var logger = Logger.nested(in: Self.self, with: "Js") @@ -128,21 +131,30 @@ final class Core: KeyDecrypter, CoreComposeMessageType { ) } - func composeEmail(msg: SendableMsg, fmt: MsgFmt, pubKeys: [String]?) throws -> CoreRes.ComposeEmail { - let r = try call("composeEmail", jsonDict: [ - "text": msg.text, - "to": msg.to, - "cc": msg.cc, - "bcc": msg.bcc, - "from": msg.from, - "subject": msg.subject, - "replyToMimeMsg": msg.replyToMimeMsg, - "atts": msg.atts.map { att in ["name": att.name, "type": att.type, "base64": att.base64] }, - "format": fmt.rawValue, - "pubKeys": pubKeys, - ], data: nil) - // this call returned no useful json data, only bytes - return CoreRes.ComposeEmail(mimeEncoded: r.data) + func composeEmail(msg: SendableMsg, fmt: MsgFmt, pubKeys: [String]?) -> Future { + Future { [weak self] promise in + guard let self = self else { return } + self.queue.async { + do { + let r = try self.call("composeEmail", jsonDict: [ + "text": msg.text, + "to": msg.to, + "cc": msg.cc, + "bcc": msg.bcc, + "from": msg.from, + "subject": msg.subject, + "replyToMimeMsg": msg.replyToMimeMsg, + "atts": msg.atts.map { att in ["name": att.name, "type": att.type, "base64": att.base64] }, + "format": fmt.rawValue, + "pubKeys": pubKeys, + ], data: nil) + // this call returned no useful json data, only bytes + promise(.success(CoreRes.ComposeEmail(mimeEncoded: r.data))) + } catch { + promise(.failure(error)) + } + } + } } func zxcvbnStrengthBar(passPhrase: String) throws -> CoreRes.ZxcvbnStrengthBar { diff --git a/FlowCrypt/Functionality/FilesManager/FilesManager.swift b/FlowCrypt/Functionality/FilesManager/FilesManager.swift index 7e70fcafb..545a1c100 100644 --- a/FlowCrypt/Functionality/FilesManager/FilesManager.swift +++ b/FlowCrypt/Functionality/FilesManager/FilesManager.swift @@ -6,7 +6,7 @@ // Copyright © 2021 FlowCrypt Limited. All rights reserved. // -import Promises +import Combine import UIKit protocol FileType { @@ -16,8 +16,11 @@ protocol FileType { } protocol FilesManagerType { - func save(file: FileType) -> Promise - func saveToFilesApp(file: FileType, from viewController: UIViewController & UIDocumentPickerDelegate) -> Promise + func save(file: FileType) -> Future + func saveToFilesApp(file: FileType, from viewController: UIViewController & UIDocumentPickerDelegate) -> AnyPublisher + + @discardableResult + func selectFromFilesApp(from viewController: UIViewController & UIDocumentPickerDelegate) -> Future } class FilesManager: FilesManagerType { @@ -25,13 +28,15 @@ class FilesManager: FilesManagerType { private let documentsDirectoryURL: URL = { FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! }() + private var cancellable = Set() private let queue: DispatchQueue = DispatchQueue.global(qos: .background) - func save(file: FileType) -> Promise { - Promise { [weak self] resolve, reject in + func save(file: FileType) -> Future { + Future { [weak self] promise in guard let self = self else { - throw AppErr.nilSelf + promise(.failure(AppErr.nilSelf)) + return } let url = self.documentsDirectoryURL.appendingPathComponent(file.name) @@ -39,9 +44,9 @@ class FilesManager: FilesManagerType { do { try file.data.write(to: url) - resolve(url) + promise(.success(url)) } catch { - reject(error) + promise(.failure(error)) } } } @@ -50,17 +55,33 @@ class FilesManager: FilesManagerType { func saveToFilesApp( file: FileType, from viewController: UIViewController & UIDocumentPickerDelegate - ) -> Promise { - Promise { [weak self] resolve, _ in - guard let self = self else { - throw AppErr.nilSelf + ) -> AnyPublisher { + return self.save(file: file) + .flatMap { url in + Future { promise in + DispatchQueue.main.async { + let documentController = UIDocumentPickerViewController(url: url, in: .exportToService) + documentController.delegate = viewController + viewController.present(documentController, animated: true) + promise(.success(())) + } + } } - let url = try? awaitPromise(self.save(file: file)) + .eraseToAnyPublisher() + } + + @discardableResult + func selectFromFilesApp( + from viewController: UIViewController & UIDocumentPickerDelegate + ) -> Future { + Future { promise in DispatchQueue.main.async { - let documentController = UIDocumentPickerViewController(url: url!, in: .exportToService) + let documentController = UIDocumentPickerViewController( + documentTypes: ["public.data"], in: .import + ) documentController.delegate = viewController viewController.present(documentController, animated: true) - resolve(()) + promise(.success(())) } } } diff --git a/FlowCrypt/Functionality/PhotosManager/PhotosManager.swift b/FlowCrypt/Functionality/PhotosManager/PhotosManager.swift new file mode 100644 index 000000000..7656cd841 --- /dev/null +++ b/FlowCrypt/Functionality/PhotosManager/PhotosManager.swift @@ -0,0 +1,47 @@ +// +// PhotosManager.swift +// FlowCrypt +// +// Created by Yevhen Kyivskyi on 22.09.2021. +// Copyright © 2021 FlowCrypt Limited. All rights reserved. +// + +import Foundation +import UIKit +import Photos +import Combine + +protocol PhotosManagerType { + func selectPhoto(source: UIImagePickerController.SourceType, from viewController: UIViewController & UIImagePickerControllerDelegate & UINavigationControllerDelegate) -> Future +} + +enum PhotosManagerError: Error { + case noAccessToLibrary +} + +class PhotosManager: PhotosManagerType { + func selectPhoto( + source: UIImagePickerController.SourceType, + from viewController: UIViewController & UIImagePickerControllerDelegate & UINavigationControllerDelegate + ) -> Future { + Future { promise in + DispatchQueue.main.async { + let imagePicker = UIImagePickerController() + imagePicker.delegate = viewController + imagePicker.sourceType = source + PHPhotoLibrary.requestAuthorization { status in + switch status { + case .authorized: + DispatchQueue.main.async { + viewController.present(imagePicker, animated: true, completion: nil) + } + promise(.success(())) + case .denied, .restricted, .notDetermined, .limited: + promise(.failure(PhotosManagerError.noAccessToLibrary)) + @unknown default: fatalError() + } + } + } + } + } +} diff --git a/FlowCrypt/Functionality/Services/Backup Services/BackupService.swift b/FlowCrypt/Functionality/Services/Backup Services/BackupService.swift index a33f1b61c..d83e71adf 100644 --- a/FlowCrypt/Functionality/Services/Backup Services/BackupService.swift +++ b/FlowCrypt/Functionality/Services/Backup Services/BackupService.swift @@ -74,10 +74,10 @@ extension BackupService: BackupServiceType { atts: attachments, pubKeys: nil ) - let backupEmail = try self.core.composeEmail(msg: message, fmt: .plain, pubKeys: message.pubKeys) - self.messageSender - .sendMail(mime: backupEmail.mimeEncoded) + self.core.composeEmail(msg: message, fmt: .plain, pubKeys: message.pubKeys) + .map(\.mimeEncoded) + .flatMap(self.messageSender.sendMail) .sink( receiveCompletion: { result in switch result { @@ -89,7 +89,8 @@ extension BackupService: BackupServiceType { }, receiveValue: { resolve(()) - }) + } + ) .store(in: &self.cancellable) } } diff --git a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageAttachment.swift b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageAttachment.swift new file mode 100644 index 000000000..04e72c269 --- /dev/null +++ b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageAttachment.swift @@ -0,0 +1,60 @@ +// +// ComposeMessageAttachment.swift +// FlowCrypt +// +// Created by Yevhen Kyivskyi on 24.09.2021. +// Copyright © 2021 FlowCrypt Limited. All rights reserved. +// + +import UIKit +import Photos + +struct ComposeMessageAttachment { + let name: String + let size: Int + let data: Data + let type: String +} + +extension ComposeMessageAttachment { + init?(librarySourceMediaInfo: [UIImagePickerController.InfoKey: Any]) { + guard let image = librarySourceMediaInfo[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 1), + let asset = librarySourceMediaInfo[.phAsset] as? PHAsset else { + return nil + } + let assetResources = PHAssetResource.assetResources(for: asset) + + guard let fileName = assetResources.first?.originalFilename else { + return nil + } + + self.name = "\(fileName).pgp" + self.data = data + self.size = data.count + self.type = fileName.mimeType + } + + init?(cameraSourceMediaInfo: [UIImagePickerController.InfoKey: Any]) { + guard let image = cameraSourceMediaInfo[.originalImage] as? UIImage, + let data = image.jpegData(compressionQuality: 1) else { + return nil + } + + self.name = "\(UUID().uuidString).jpg.pgp" + self.data = data + self.size = data.count + self.type = "image/jpg" + } + + init?(fileURL: URL) { + guard let data = try? Data(contentsOf: fileURL) else { + return nil + } + + self.name = "\(fileURL.lastPathComponent).pgp" + self.data = data + self.size = data.count + self.type = fileURL.mimeType + } +} diff --git a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift index 85a540bb8..77e3fd9dc 100644 --- a/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift +++ b/FlowCrypt/Functionality/Services/Compose Message Service/ComposeMessageService.swift @@ -14,6 +14,7 @@ struct ComposeMessageContext { var message: String? var recipients: [ComposeMessageRecipient] = [] var subject: String? + var attachments: [ComposeMessageAttachment] = [] } struct ComposeMessageRecipient { @@ -22,7 +23,7 @@ struct ComposeMessageRecipient { } protocol CoreComposeMessageType { - func composeEmail(msg: SendableMsg, fmt: MsgFmt, pubKeys: [String]?) throws -> CoreRes.ComposeEmail + func composeEmail(msg: SendableMsg, fmt: MsgFmt, pubKeys: [String]?) -> Future } final class ComposeMessageService { @@ -47,11 +48,9 @@ final class ComposeMessageService { func validateMessage( input: ComposeMessageInput, contextToSend: ComposeMessageContext, - email: String, - atts: [SendableMsg.Attachment] + email: String ) -> Result { let recipients = contextToSend.recipients - guard recipients.isNotEmpty else { return .failure(.validationError(.emptyRecipient)) } @@ -83,6 +82,15 @@ final class ComposeMessageService { return .failure(.validationError(.missedPublicKey)) } + let sendableAttachments: [SendableMsg.Attachment] = contextToSend.attachments + .map { composeAttachment in + return SendableMsg.Attachment( + name: composeAttachment.name, + type: composeAttachment.type, + base64: composeAttachment.data.base64EncodedString() + ) + } + return getPubKeys(for: recipients) .mapError { ComposeMessageError.validationError($0) } .map { allRecipientPubs in @@ -97,7 +105,7 @@ final class ComposeMessageService { from: email, subject: subject, replyToMimeMsg: replyToMimeMsg, - atts: atts, + atts: sendableAttachments, pubKeys: allRecipientPubs + [myPubKey] ) } @@ -119,16 +127,19 @@ final class ComposeMessageService { // MARK: - Encrypt and Send func encryptAndSend(message: SendableMsg) -> AnyPublisher { - messageGateway.sendMail(mime: encryptMessage(with: message)) + return encryptMessage(with: message) + .flatMap(messageGateway.sendMail) .mapError { ComposeMessageError.gatewayError($0) } .eraseToAnyPublisher() } - private func encryptMessage(with msg: SendableMsg) -> Data { - do { - return try core.composeEmail(msg: msg, fmt: MsgFmt.encryptInline, pubKeys: msg.pubKeys).mimeEncoded - } catch { - fatalError() - } + private func encryptMessage(with msg: SendableMsg) -> AnyPublisher { + return core.composeEmail( + msg: msg, + fmt: MsgFmt.encryptInline, + pubKeys: msg.pubKeys + ) + .map(\.mimeEncoded) + .eraseToAnyPublisher() } } diff --git a/FlowCrypt/Functionality/Services/GeneralConstants.swift b/FlowCrypt/Functionality/Services/GeneralConstants.swift index b9b08082e..4da5daf99 100644 --- a/FlowCrypt/Functionality/Services/GeneralConstants.swift +++ b/FlowCrypt/Functionality/Services/GeneralConstants.swift @@ -16,6 +16,7 @@ enum GeneralConstants { enum Global { static let generalError = -1 static let messageSizeLimit: Int = 5_000_000 + static let attachmentSizeLimit: Int = 10_000_000 } enum EmailConstant { diff --git a/FlowCrypt/Info.plist b/FlowCrypt/Info.plist index 641956612..cff7e16a9 100644 --- a/FlowCrypt/Info.plist +++ b/FlowCrypt/Info.plist @@ -56,6 +56,10 @@ LSSupportsOpeningDocumentsInPlace + NSCameraUsageDescription + Camera is used to take a photo for emails attachments + NSPhotoLibraryUsageDescription + Photo library is used to select a photo for emails attachments UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities diff --git a/FlowCrypt/Resources/en.lproj/Localizable.strings b/FlowCrypt/Resources/en.lproj/Localizable.strings index 2b54453af..e55aa351e 100644 --- a/FlowCrypt/Resources/en.lproj/Localizable.strings +++ b/FlowCrypt/Resources/en.lproj/Localizable.strings @@ -6,6 +6,7 @@ "ok" = "Ok"; "cancel" = "Cancel"; "open" = "Open"; +"setttings" = "Settings"; // EMAIL "email_removed" = "Email moved to Trash"; @@ -224,3 +225,15 @@ "gmail_service_missing_message_info_error_message" = "Missing message info: %@"; "gmail_service_provider_error_error_message" = "Gmail provider error: %@"; "gmail_service_missing_back_query_error_message" = "Missed backup query: %@"; + +// Files picking +"files_picking_select_input_source_title" = "Please select input source"; +"files_picking_camera_input_source" = "Camera"; +"files_picking_photo_library_source" = "Photo Library"; +"files_picking_files_source" = "Files"; +"files_picking_no_library_access_error_title" = "No access to library"; +"files_picking_no_library_access_error_message" = "You may open Settings and give the full access to photo library"; +"files_picking_files_error_message" = "Could not add an attachment"; +"files_picking_photos_error_message" = "Could not add a photo"; +"files_picking_size_error_message" = "Total attachments size can not exceed 10 MB"; + diff --git a/FlowCryptAppTests/Core/FlowCryptCoreTests.swift b/FlowCryptAppTests/Core/FlowCryptCoreTests.swift index a80a2b858..410051896 100644 --- a/FlowCryptAppTests/Core/FlowCryptCoreTests.swift +++ b/FlowCryptAppTests/Core/FlowCryptCoreTests.swift @@ -7,10 +7,12 @@ // import XCTest +import Combine @testable import FlowCrypt class FlowCryptCoreTests: XCTestCase { var core: Core! = .shared + private var cancellable = Set() override func setUp() { let expectation = XCTestExpectation() @@ -90,8 +92,18 @@ class FlowCryptCoreTests: XCTestCase { func testComposeEmailPlain() throws { let msg = SendableMsg(text: "this is the message", to: ["email@hello.com"], cc: [], bcc: [], from: "sender@hello.com", subject: "subj", replyToMimeMsg: nil, atts: [], pubKeys: nil) - let composeEmailRes = try core.composeEmail(msg: msg, fmt: MsgFmt.plain, pubKeys: nil) - let mime = String(data: composeEmailRes.mimeEncoded, encoding: .utf8)! + let expectation = XCTestExpectation() + + var mime: String = "" + core.composeEmail(msg: msg, fmt: .plain, pubKeys: nil) + .sinkFuture( + receiveValue: { composeEmailRes in + mime = String(data: composeEmailRes.mimeEncoded, encoding: .utf8)! + expectation.fulfill() + }, receiveError: {_ in } + ) + .store(in: &cancellable) + wait(for: [expectation], timeout: 3) XCTAssertNil(mime.range(of: "-----BEGIN PGP MESSAGE-----")) // not encrypted XCTAssertNotNil(mime.range(of: msg.text)) // plain text visible XCTAssertNotNil(mime.range(of: "Subject: \(msg.subject)")) // has mime Subject header @@ -100,8 +112,18 @@ class FlowCryptCoreTests: XCTestCase { func testComposeEmailEncryptInline() throws { let msg = SendableMsg(text: "this is the message", to: ["email@hello.com"], cc: [], bcc: [], from: "sender@hello.com", subject: "subj", replyToMimeMsg: nil, atts: [], pubKeys: nil) - let composeEmailRes = try core.composeEmail(msg: msg, fmt: MsgFmt.encryptInline, pubKeys: [TestData.k0.pub, TestData.k1.pub]) - let mime = String(data: composeEmailRes.mimeEncoded, encoding: .utf8)! + let expectation = XCTestExpectation() + + var mime: String = "" + core.composeEmail(msg: msg, fmt: .encryptInline, pubKeys: [TestData.k0.pub, TestData.k1.pub]) + .sinkFuture( + receiveValue: { composeEmailRes in + mime = String(data: composeEmailRes.mimeEncoded, encoding: .utf8)! + expectation.fulfill() + }, receiveError: {_ in } + ) + .store(in: &cancellable) + wait(for: [expectation], timeout: 3) XCTAssertNotNil(mime.range(of: "-----BEGIN PGP MESSAGE-----")) // encrypted XCTAssertNil(mime.range(of: msg.text)) // plain text not visible XCTAssertNotNil(mime.range(of: "Subject: \(msg.subject)")) // has mime Subject header @@ -115,9 +137,20 @@ class FlowCryptCoreTests: XCTestCase { let generateKeyRes = try core.generateKey(passphrase: passphrase, variant: KeyVariant.curve25519, userIds: [UserId(email: email, name: "End to end")]) let k = generateKeyRes.key let msg = SendableMsg(text: text, to: [email], cc: [], bcc: [], from: email, subject: "e2e subj", replyToMimeMsg: nil, atts: [], pubKeys: nil) - let mime = try core.composeEmail(msg: msg, fmt: MsgFmt.encryptInline, pubKeys: [k.public]) + let expectation = XCTestExpectation() + + var mime: CoreRes.ComposeEmail? + core.composeEmail(msg: msg, fmt: .encryptInline, pubKeys: [k.public]) + .sinkFuture( + receiveValue: { composeEmailRes in + mime = composeEmailRes + expectation.fulfill() + }, receiveError: {_ in } + ) + .store(in: &cancellable) + wait(for: [expectation], timeout: 3) let keys = [PrvKeyInfo(private: k.private!, longid: k.ids[0].longid, passphrase: passphrase, fingerprints: k.fingerprints)] - let decrypted = try core.parseDecryptMsg(encrypted: mime.mimeEncoded, keys: keys, msgPwd: nil, isEmail: true) + let decrypted = try core.parseDecryptMsg(encrypted: mime?.mimeEncoded ?? Data(), keys: keys, msgPwd: nil, isEmail: true) XCTAssertEqual(decrypted.text, text) XCTAssertEqual(decrypted.replyType, CoreRes.ReplyType.encrypted) XCTAssertEqual(decrypted.blocks.count, 1) diff --git a/FlowCryptAppTests/Functionallity/Services/ComposeMessageServiceTests.swift b/FlowCryptAppTests/Functionallity/Services/ComposeMessageServiceTests.swift index 57f6c15ae..bd339a98a 100644 --- a/FlowCryptAppTests/Functionallity/Services/ComposeMessageServiceTests.swift +++ b/FlowCryptAppTests/Functionallity/Services/ComposeMessageServiceTests.swift @@ -43,8 +43,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: [], subject: nil ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) var thrownError: Error? @@ -67,8 +66,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: nil ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) var thrownError: Error? @@ -94,8 +92,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: nil ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) test() @@ -107,8 +104,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) test() @@ -120,8 +116,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: " " ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) } @@ -140,8 +135,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "Some subject" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) test() @@ -153,8 +147,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "Some subject" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) test() @@ -166,8 +159,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "Some subject" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) test() @@ -185,8 +177,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "Some subject" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) var thrownError: Error? @@ -213,8 +204,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "Some subject" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) var thrownError: Error? @@ -245,8 +235,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: "Some subject" ), - email: "some@gmail.com", - atts: [] + email: "some@gmail.com" ) var thrownError: Error? @@ -278,8 +267,7 @@ class ComposeMessageServiceTests: XCTestCase { recipients: recipients, subject: subject ), - email: email, - atts: [] + email: email ).get() let expected = SendableMsg( diff --git a/FlowCryptAppTests/Mocks/CoreComposeMessageMock.swift b/FlowCryptAppTests/Mocks/CoreComposeMessageMock.swift index 7be4129cd..55cadf5bd 100644 --- a/FlowCryptAppTests/Mocks/CoreComposeMessageMock.swift +++ b/FlowCryptAppTests/Mocks/CoreComposeMessageMock.swift @@ -7,11 +7,16 @@ // import Foundation +import Combine @testable import FlowCrypt class CoreComposeMessageMock: CoreComposeMessageType { + var composeEmailResult: ((SendableMsg, MsgFmt, [String]?) -> (CoreRes.ComposeEmail))! - func composeEmail(msg: SendableMsg, fmt: MsgFmt, pubKeys: [String]?) throws -> CoreRes.ComposeEmail { - composeEmailResult(msg, fmt, pubKeys) + func composeEmail(msg: SendableMsg, fmt: MsgFmt, pubKeys: [String]?) -> Future { + Future { [weak self] promise in + guard let self = self else { return } + promise(.success(self.composeEmailResult(msg, fmt, pubKeys))) + } } } diff --git a/FlowCryptCommon/Extensions/URLExtension.swift b/FlowCryptCommon/Extensions/URLExtension.swift index b5b75dca9..361a0d708 100644 --- a/FlowCryptCommon/Extensions/URLExtension.swift +++ b/FlowCryptCommon/Extensions/URLExtension.swift @@ -21,3 +21,127 @@ public extension URL { return URL(string: sharedDocumentUrlString) } } + +public extension URL { + var mimeType: String { + return Self.mimeTypes[pathExtension] ?? "text/plain" + } +} + +public extension String { + var mimeType: String { + let parts = self.split(separator: ".") + guard let ext = parts.last else { return "text/plain" } + return URL.mimeTypes[String(ext)] ?? "text/plain" + } +} + +private extension URL { + static var mimeTypes: [String: String] { + [ + "html": "text/html", + "htm": "text/html", + "shtml": "text/html", + "css": "text/css", + "xml": "text/xml", + "gif": "image/gif", + "jpeg": "image/jpeg", + "jpg": "image/jpeg", + "js": "application/javascript", + "atom": "application/atom+xml", + "rss": "application/rss+xml", + "mml": "text/mathml", + "txt": "text/plain", + "jad": "text/vnd.sun.j2me.app-descriptor", + "wml": "text/vnd.wap.wml", + "htc": "text/x-component", + "png": "image/png", + "tif": "image/tiff", + "tiff": "image/tiff", + "wbmp": "image/vnd.wap.wbmp", + "ico": "image/x-icon", + "jng": "image/x-jng", + "bmp": "image/x-ms-bmp", + "svg": "image/svg+xml", + "svgz": "image/svg+xml", + "webp": "image/webp", + "woff": "application/font-woff", + "jar": "application/java-archive", + "war": "application/java-archive", + "ear": "application/java-archive", + "json": "application/json", + "hqx": "application/mac-binhex40", + "doc": "application/msword", + "pdf": "application/pdf", + "ps": "application/postscript", + "eps": "application/postscript", + "ai": "application/postscript", + "rtf": "application/rtf", + "m3u8": "application/vnd.apple.mpegurl", + "xls": "application/vnd.ms-excel", + "eot": "application/vnd.ms-fontobject", + "ppt": "application/vnd.ms-powerpoint", + "wmlc": "application/vnd.wap.wmlc", + "kml": "application/vnd.google-earth.kml+xml", + "kmz": "application/vnd.google-earth.kmz", + "7z": "application/x-7z-compressed", + "cco": "application/x-cocoa", + "jardiff": "application/x-java-archive-diff", + "jnlp": "application/x-java-jnlp-file", + "run": "application/x-makeself", + "pl": "application/x-perl", + "pm": "application/x-perl", + "prc": "application/x-pilot", + "pdb": "application/x-pilot", + "rar": "application/x-rar-compressed", + "rpm": "application/x-redhat-package-manager", + "sea": "application/x-sea", + "swf": "application/x-shockwave-flash", + "sit": "application/x-stuffit", + "tcl": "application/x-tcl", + "tk": "application/x-tcl", + "der": "application/x-x509-ca-cert", + "pem": "application/x-x509-ca-cert", + "crt": "application/x-x509-ca-cert", + "xpi": "application/x-xpinstall", + "xhtml": "application/xhtml+xml", + "xspf": "application/xspf+xml", + "zip": "application/zip", + "bin": "application/octet-stream", + "exe": "application/octet-stream", + "dll": "application/octet-stream", + "deb": "application/octet-stream", + "dmg": "application/octet-stream", + "iso": "application/octet-stream", + "img": "application/octet-stream", + "msi": "application/octet-stream", + "msp": "application/octet-stream", + "msm": "application/octet-stream", + "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "mid": "audio/midi", + "midi": "audio/midi", + "kar": "audio/midi", + "mp3": "audio/mpeg", + "ogg": "audio/ogg", + "m4a": "audio/x-m4a", + "ra": "audio/x-realaudio", + "3gpp": "video/3gpp", + "3gp": "video/3gpp", + "ts": "video/mp2t", + "mp4": "video/mp4", + "mpeg": "video/mpeg", + "mpg": "video/mpeg", + "mov": "video/quicktime", + "webm": "video/webm", + "flv": "video/x-flv", + "m4v": "video/x-m4v", + "mng": "video/x-mng", + "asx": "video/x-ms-asf", + "asf": "video/x-ms-asf", + "wmv": "video/x-ms-wmv", + "avi": "video/x-msvideo" + ] + } +} diff --git a/FlowCryptUI/Cell Nodes/TextViewCellNode.swift b/FlowCryptUI/Cell Nodes/TextViewCellNode.swift index 74299a31e..fc0c935bb 100644 --- a/FlowCryptUI/Cell Nodes/TextViewCellNode.swift +++ b/FlowCryptUI/Cell Nodes/TextViewCellNode.swift @@ -35,9 +35,12 @@ public final class TextViewCellNode: CellNode { public let textView = ASEditableTextNode() private let action: TextViewAction? - private let height: CGFloat + public var height: CGFloat - public init(_ input: Input, action: TextViewAction? = nil) { + public init( + _ input: Input, + action: TextViewAction? = nil + ) { self.action = action height = input.preferredHeight super.init() @@ -48,6 +51,11 @@ public final class TextViewCellNode: CellNode { NSAttributedString.Key.foregroundColor.rawValue: input.textColor, ] } + + private func setHeight(_ height: CGFloat) { + self.height = height + setNeedsLayout() + } public override func layoutSpecThatFits(_: ASSizeRange) -> ASLayoutSpec { textView.style.preferredSize.height = height @@ -71,4 +79,9 @@ extension TextViewCellNode: ASEditableTextNodeDelegate { public func editableTextNodeDidFinishEditing(_ editableTextNode: ASEditableTextNode) { action?(.didEndEditing(editableTextNode.attributedText)) } + + public func editableTextNodeDidUpdateText(_ editableTextNode: ASEditableTextNode) { + let calculatedHeight = editableTextNode.textView.sizeThatFits(textView.frame.size).height + setHeight(calculatedHeight) + } } diff --git a/FlowCryptUI/Nodes/AttachmentNode.swift b/FlowCryptUI/Nodes/AttachmentNode.swift index 4492019c6..6a71dd7a4 100644 --- a/FlowCryptUI/Nodes/AttachmentNode.swift +++ b/FlowCryptUI/Nodes/AttachmentNode.swift @@ -26,7 +26,7 @@ public final class AttachmentNode: CellNode { public init( input: Input, - onDownloadTap: (() -> Void)? + onDownloadTap: (() -> Void)? = nil ) { self.onDownloadTap = onDownloadTap super.init() @@ -45,6 +45,7 @@ public final class AttachmentNode: CellNode { subtitleNode.attributedText = input.size buttonNode.addTarget(self, action: #selector(onDownloadButtonTap), forControlEvents: .touchUpInside) + buttonNode.isHidden = onDownloadTap == nil } @objc private func onDownloadButtonTap() {