diff --git a/ConversationsClassic/AppData/Store/AttachmentsStore.swift b/ConversationsClassic/AppData/Store/AttachmentsStore.swift index e776725..c86f915 100644 --- a/ConversationsClassic/AppData/Store/AttachmentsStore.swift +++ b/ConversationsClassic/AppData/Store/AttachmentsStore.swift @@ -12,6 +12,8 @@ final class AttachmentsStore: ObservableObject { private let client: Client private let roster: Roster + private var processing: Set = [] + init(roster: Roster, client: Client) { self.client = client self.roster = roster @@ -37,9 +39,12 @@ extension AttachmentsStore { isAuthorized = (req == .authorized) || (req == .limited) } galleryAccessGranted = isAuthorized + if isAuthorized { + await fetchGalleryItems() + } } - func fetchGalleryItems() async { + private func fetchGalleryItems() async { guard galleryAccessGranted else { return } galleryItems = await GalleryItem.fetchAll() } @@ -47,139 +52,278 @@ extension AttachmentsStore { // MARK: - Save outgoing attachments for future uploadings extension AttachmentsStore { - func sendMedia(_ items: [GalleryItem]) async { - galleryItems = [] - for item in items { - Task { - var message = Message.blank - message.from = roster.bareJid - message.to = roster.contactBareJid + func sendMedia(_ items: [GalleryItem]) { + Task { + for item in items { + Task { + var message = Message.blank + message.from = roster.bareJid + message.to = roster.contactBareJid - switch item.type { - case .photo: - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } - guard let photo = try? await PHImageManager.default().getPhoto(for: asset) else { return } - guard let data = photo.jpegData(compressionQuality: 1.0) else { return } - let localName = "\(message.id)_\(UUID().uuidString).jpg" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - try? data.write(to: localUrl) - message.contentType = .attachment( - Attachment( - type: .image, - localName: localName, - thumbnailName: nil, - remotePath: nil + switch item.type { + case .photo: + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } + guard let photo = try? await PHImageManager.default().getPhoto(for: asset) else { return } + guard let data = photo.jpegData(compressionQuality: 1.0) else { return } + let localName = "\(message.id)_\(UUID().uuidString).jpg" + let localUrl = Const.fileFolder.appendingPathComponent(localName) + try? data.write(to: localUrl) + message.contentType = .attachment( + Attachment( + type: .image, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) ) - ) - try? await message.save() + try? await message.save() - case .video: - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } - guard let video = try? await PHImageManager.default().getVideo(for: asset) else { return } - // swiftlint:disable:next force_cast - let assetURL = video as! AVURLAsset - let url = assetURL.url - let localName = "\(message.id)_\(UUID().uuidString).mov" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - try? FileManager.default.copyItem(at: url, to: localUrl) - message.contentType = .attachment( - Attachment( - type: .video, - localName: localName, - thumbnailName: nil, - remotePath: nil + case .video: + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [item.id], options: nil).firstObject else { return } + guard let video = try? await PHImageManager.default().getVideo(for: asset) else { return } + // swiftlint:disable:next force_cast + let assetURL = video as! AVURLAsset + let url = assetURL.url + let localName = "\(message.id)_\(UUID().uuidString).mov" + let localUrl = Const.fileFolder.appendingPathComponent(localName) + try? FileManager.default.copyItem(at: url, to: localUrl) + message.contentType = .attachment( + Attachment( + type: .video, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) ) - ) - try? await message.save() + try? await message.save() + } } } } } - func sendCaptured(_ data: Data, _ type: GalleryMediaType) async { - galleryItems = [] - // save locally and make message - var message = Message.blank - message.from = roster.bareJid - message.to = roster.contactBareJid + func sendCaptured(_ data: Data, _ type: GalleryMediaType) { + Task { + var message = Message.blank + message.from = roster.bareJid + message.to = roster.contactBareJid - let localName: String - let msgType: AttachmentType - do { - (localName, msgType) = try await Task { - // local name - let fileId = UUID().uuidString - let localName: String - let msgType: AttachmentType - switch type { - case .photo: - localName = "\(message.id)_\(fileId).jpg" - msgType = .image + let localName: String + let msgType: AttachmentType + do { + (localName, msgType) = try await Task { + // local name + let fileId = UUID().uuidString + let localName: String + let msgType: AttachmentType + switch type { + case .photo: + localName = "\(message.id)_\(fileId).jpg" + msgType = .image - case .video: - localName = "\(message.id)_\(fileId).mov" - msgType = .video - } + case .video: + localName = "\(message.id)_\(fileId).mov" + msgType = .video + } - // save - let localUrl = Const.fileFolder.appendingPathComponent(localName) - try data.write(to: localUrl) - return (localName, msgType) - }.value - } catch { - logIt(.error, "Can't save file for uploading: \(error)") - return - } + // save + let localUrl = Const.fileFolder.appendingPathComponent(localName) + try data.write(to: localUrl) + return (localName, msgType) + }.value + } catch { + logIt(.error, "Can't save file for uploading: \(error)") + return + } - // save message - message.contentType = .attachment( - Attachment( - type: msgType, - localName: localName, - thumbnailName: nil, - remotePath: nil + // save message + message.contentType = .attachment( + Attachment( + type: msgType, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) ) - ) - do { - try await message.save() - } catch { - logIt(.error, "Can't save message: \(error)") - return + do { + try await message.save() + } catch { + logIt(.error, "Can't save message: \(error)") + return + } } } - func sendDocuments(_ data: [Data], _ extensions: [String]) async { - galleryItems = [] - for (index, data) in data.enumerated() { - Task { - let newMessageId = UUID().uuidString - let fileId = UUID().uuidString - let localName = "\(newMessageId)_\(fileId).\(extensions[index])" - let localUrl = Const.fileFolder.appendingPathComponent(localName) - do { - try data.write(to: localUrl) - } catch { - print("FileProcessing: Error writing document: \(error)") - return - } + func sendDocuments(_ data: [Data], _ extensions: [String]) { + Task { + for (index, data) in data.enumerated() { + Task { + let newMessageId = UUID().uuidString + let fileId = UUID().uuidString + let localName = "\(newMessageId)_\(fileId).\(extensions[index])" + let localUrl = Const.fileFolder.appendingPathComponent(localName) + do { + try data.write(to: localUrl) + } catch { + print("FileProcessing: Error writing document: \(error)") + return + } - var message = Message.blank - message.from = roster.bareJid - message.to = roster.contactBareJid - message.contentType = .attachment( - Attachment( - type: localName.attachmentType, - localName: localName, - thumbnailName: nil, - remotePath: nil + var message = Message.blank + message.from = roster.bareJid + message.to = roster.contactBareJid + message.contentType = .attachment( + Attachment( + type: localName.attachmentType, + localName: localName, + thumbnailName: nil, + remotePath: nil + ) ) - ) - do { - try await message.save() - } catch { - print("FileProcessing: Error saving document: \(error)") + do { + try await message.save() + } catch { + print("FileProcessing: Error saving document: \(error)") + } } } } } } + +// MARK: - Uploadings/Downloadings +extension AttachmentsStore { + func processAttachment(_ message: Message) { + // Prevent multiple processing + if processing.contains(message.id) { + return + } + + // Process in background + Task(priority: .background) { + // Do needed processing + if case .attachment(let attachment) = message.contentType { + if attachment.localPath != nil, attachment.remotePath == nil { + // Uploading + processing.insert(message.id) + await uploadAttachment(message) + processing.remove(message.id) + } else if attachment.localPath == nil, attachment.remotePath != nil { + // Downloading + processing.insert(message.id) + await downloadAttachment(message) + processing.remove(message.id) + } else if attachment.localPath != nil, attachment.remotePath != nil, attachment.thumbnailName == nil, attachment.type == .image { + // Generate thumbnail + processing.insert(message.id) + await generateThumbnail(message) + processing.remove(message.id) + } + } + } + } + + private func uploadAttachment(_ message: Message) async { + do { + try await message.setStatus(.pending) + var message = message + guard case .attachment(let attachment) = message.contentType else { + throw ClientStoreError.invalidContentType + } + guard let localName = attachment.localPath else { + throw ClientStoreError.invalidLocalName + } + let remotePath = try await client.uploadFile(localName) + message.contentType = .attachment( + Attachment( + type: attachment.type, + localName: attachment.localName, + thumbnailName: nil, + remotePath: remotePath + ) + ) + message.body = remotePath + message.oobUrl = remotePath + try await message.save() + try await client.sendMessage(message) + try await message.setStatus(.sent) + } catch { + try? await message.setStatus(.error) + } + } + + private func downloadAttachment(_ message: Message) async { + guard case .attachment(let attachment) = message.contentType else { + return + } + guard let remotePath = attachment.remotePath, let remoteUrl = URL(string: remotePath) else { + return + } + do { + let localName = "\(message.id)_\(UUID().uuidString).\(remoteUrl.lastPathComponent)" + let localUrl = Const.fileFolder.appendingPathComponent(localName) + + // Download the file + let (tempUrl, _) = try await URLSession.shared.download(from: remoteUrl) + try FileManager.default.moveItem(at: tempUrl, to: localUrl) + + var message = message + message.contentType = .attachment( + Attachment( + type: attachment.type, + localName: localName, + thumbnailName: attachment.thumbnailName, + remotePath: remotePath + ) + ) + try await message.save() + } catch { + logIt(.error, "Can't download attachment: \(error)") + } + } + + private func generateThumbnail(_ message: Message) async { + guard case .attachment(let attachment) = message.contentType else { + return + } + guard attachment.type == .image else { + return + } + guard let localName = attachment.localName, let localPath = attachment.localPath else { + return + } + let thumbnailFileName = "thumb_\(localName)" + let thumbnailUrl = Const.fileFolder.appendingPathComponent(thumbnailFileName) + + // + if !FileManager.default.fileExists(atPath: thumbnailUrl.path) { + guard let image = UIImage(contentsOfFile: localPath.path) else { + return + } + let targetSize = CGSize(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) + guard let thumbnail = try? await image.scaleAndCropImage(targetSize) else { + return + } + guard let data = thumbnail.jpegData(compressionQuality: 0.5) else { + return + } + do { + try data.write(to: thumbnailUrl) + } catch { + return + } + } + + // + var message = message + message.contentType = .attachment( + Attachment( + type: attachment.type, + localName: attachment.localName, + thumbnailName: thumbnailFileName, + remotePath: attachment.remotePath + ) + ) + try? await message.save() + } +} diff --git a/ConversationsClassic/AppData/Store/ConversationStore.swift b/ConversationsClassic/AppData/Store/ConversationStore.swift index 54c8046..d8a4b56 100644 --- a/ConversationsClassic/AppData/Store/ConversationStore.swift +++ b/ConversationsClassic/AppData/Store/ConversationStore.swift @@ -22,87 +22,30 @@ final class ConversationStore: ObservableObject { } extension ConversationStore { - func sendMessage(_ message: String) async { - var msg = Message.blank - msg.from = roster.bareJid - msg.to = roster.contactBareJid - msg.body = message + func sendMessage(_ message: String) { + Task { + var msg = Message.blank + msg.from = roster.bareJid + msg.to = roster.contactBareJid + msg.body = message - // store as pending on db, and send - do { - try await msg.save() - try await client.sendMessage(msg) - try await msg.setStatus(.sent) - } catch { - try? await msg.setStatus(.error) - } - } - - func sendContact(_ jidStr: String) async { - await sendMessage("contact:\(jidStr)") - } - - func sendLocation(_ lat: Double, _ lon: Double) async { - await sendMessage("geo:\(lat),\(lon)") - } - - private func upload(_ message: Message) async { - do { - try await message.setStatus(.pending) - var message = message - guard case .attachment(let attachment) = message.contentType else { - throw ClientStoreError.invalidContentType + // store as pending on db, and send + do { + try await msg.save() + try await client.sendMessage(msg) + try await msg.setStatus(.sent) + } catch { + try? await msg.setStatus(.error) } - guard let localName = attachment.localPath else { - throw ClientStoreError.invalidLocalName - } - let remotePath = try await client.uploadFile(localName) - message.contentType = .attachment( - Attachment( - type: attachment.type, - localName: attachment.localName, - thumbnailName: nil, - remotePath: remotePath - ) - ) - message.body = remotePath - message.oobUrl = remotePath - try await message.save() - try await client.sendMessage(message) - try await message.setStatus(.sent) - } catch { - try? await message.setStatus(.error) } } - func downloadAttachment(_ message: Message) async { - guard case .attachment(let attachment) = message.contentType else { - return - } - guard let remotePath = attachment.remotePath, let remoteUrl = URL(string: remotePath) else { - return - } - do { - let localName = "\(message.id)_\(UUID().uuidString).\(remoteUrl.lastPathComponent)" - let localUrl = Const.fileFolder.appendingPathComponent(localName) + func sendContact(_ jidStr: String) { + sendMessage("contact:\(jidStr)") + } - // Download the file - let (tempUrl, _) = try await URLSession.shared.download(from: remoteUrl) - try FileManager.default.moveItem(at: tempUrl, to: localUrl) - - var message = message - message.contentType = .attachment( - Attachment( - type: attachment.type, - localName: localName, - thumbnailName: attachment.thumbnailName, - remotePath: remotePath - ) - ) - try await message.save() - } catch { - logIt(.error, "Can't download attachment: \(error)") - } + func sendLocation(_ lat: Double, _ lon: Double) { + sendMessage("geo:\(lat),\(lon)") } } diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraCellPreview.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraCellPreview.swift index de3fb50..f2c2191 100644 --- a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraCellPreview.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/CameraCellPreview.swift @@ -26,9 +26,7 @@ struct CameraCellPreview: View { .onTapGesture { router.showScreen(.fullScreenCover) { _ in CameraPicker { data, type in - Task { - await attachments.sendCaptured(data, type) - } + attachments.sendCaptured(data, type) router.dismissEnvironment() } .ignoresSafeArea(.all) diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift index 25aed9b..c3fa23c 100644 --- a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/GalleryView.swift @@ -35,13 +35,6 @@ struct GalleryView: View { .task { await attachments.checkGalleryAuthorization() } - .onChange(of: attachments.galleryAccessGranted) { flag in - if flag { - Task { - await attachments.fetchGalleryItems() - } - } - } } } diff --git a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift index fc7541b..cba4b34 100644 --- a/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift +++ b/ConversationsClassic/View/Main/Conversation/Attachments/MediaPicker/MediaPickerView.swift @@ -44,10 +44,8 @@ struct MediaPickerView: View { } .clipped() .onTapGesture { - Task { - let items = attachments.galleryItems.filter { selectedItems.contains($0.id) } - await attachments.sendMedia(items) - } + let items = attachments.galleryItems.filter { selectedItems.contains($0.id) } + attachments.sendMedia(items) router.dismissEnvironment() } } diff --git a/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift b/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift index b0ba4a0..3ed2753 100644 --- a/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationMessageContainer.swift @@ -99,6 +99,8 @@ private struct ContactView: View { } private struct AttachmentView: View { + @EnvironmentObject var attachments: AttachmentsStore + let message: Message let attachment: Attachment @@ -154,6 +156,9 @@ private struct AttachmentView: View { .foregroundColor(.Material.Elements.active) } } + .onAppear { + attachments.processAttachment(message) + } } @ViewBuilder private var failed: some View { @@ -173,11 +178,7 @@ private struct AttachmentView: View { } } .onTapGesture { - // if let url = message.attachmentRemotePath { - // store.dispatch(.fileAction(.downloadAttachmentFile(messageId: message.id, attachmentRemotePath: url))) - // } else if message.attachmentLocalName != nil && message.sentError { - // store.dispatch(.sharingAction(.retrySharing(messageId: message.id))) - // } + attachments.processAttachment(message) } } diff --git a/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift b/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift index 4c3f429..de63dbb 100644 --- a/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift +++ b/ConversationsClassic/View/Main/Conversation/ConversationTextInput.swift @@ -74,11 +74,9 @@ struct ConversationTextInput: View { .padding(.trailing, 8) .tappablePadding(.symmetric(8)) { if !messageStr.isEmpty { - Task(priority: .userInitiated) { - await conversation.sendMessage(composedMessage) - messageStr = "" - autoScroll = true - } + conversation.sendMessage(composedMessage) + messageStr = "" + autoScroll = true } } }