diff --git a/ConversationsClassic/AppCore/Actions/AppActions.swift b/ConversationsClassic/AppCore/Actions/AppActions.swift index 5484839..917f518 100644 --- a/ConversationsClassic/AppCore/Actions/AppActions.swift +++ b/ConversationsClassic/AppCore/Actions/AppActions.swift @@ -11,4 +11,5 @@ enum AppAction: Codable { case chatsAction(_ action: ChatsAction) case conversationAction(_ action: ConversationAction) case sharingAction(_ action: SharingAction) + case fileAction(_ action: FileAction) } diff --git a/ConversationsClassic/AppCore/Actions/FileActions.swift b/ConversationsClassic/AppCore/Actions/FileActions.swift new file mode 100644 index 0000000..4acada7 --- /dev/null +++ b/ConversationsClassic/AppCore/Actions/FileActions.swift @@ -0,0 +1,6 @@ +import Foundation + +enum FileAction: Stateable { + case attachmentFileDownloaded(id: String, localUrl: URL) + case attachmentThumbnailCreated(id: String, thumbnailUrl: URL) +} diff --git a/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift index 42def9f..62b1d2c 100644 --- a/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift +++ b/ConversationsClassic/AppCore/Middlewares/DatabaseMiddleware.swift @@ -271,8 +271,28 @@ final class DatabaseMiddleware { } promise(.success(.empty)) } catch { - promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription))) - ) + promise(.success(.databaseAction(.storeMessageFailed(reason: error.localizedDescription)))) + } + } + } + .eraseToAnyPublisher() + + case .fileAction(.attachmentFileDownloaded(let id, let localUrl)): + return Future { promise in + Task(priority: .background) { [weak self] in + guard let database = self?.database else { + promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) + return + } + do { + _ = try database._db.write { db in + try Attachment + .filter(Column("id") == id) + .updateAll(db, Column("localPath").set(to: localUrl)) + } + promise(.success(.empty)) + } catch { + promise(.success(.databaseAction(.storeMessageFailed(reason: L10n.Global.Error.genericDbError)))) } } } diff --git a/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift b/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift index e18a458..fcac91b 100644 --- a/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift +++ b/ConversationsClassic/AppCore/Middlewares/FileMiddleware.swift @@ -1,15 +1,57 @@ - import Combine +import Foundation +import UIKit final class FileMiddleware { - static let shared = AccountsMiddleware() + static let shared = FileMiddleware() + private var downloader = DownloadManager() func middleware(state _: AppState, action: AppAction) -> AnyPublisher { switch action { - case .conversationAction(.messagesUpdated(let messages)): - for msg in messages { - if msg.attachment != nil { - print("Attachment found") + case .conversationAction(.attachmentsUpdated(let attachments)): + DispatchQueue.global(qos: .background).async { + for attachment in attachments where attachment.localPath == nil { + if let remotePath = attachment.remotePath { + let localUrl = self.fileFolder.appendingPathComponent(attachment.id) + self.downloader.download(from: remotePath, to: localUrl) { error in + if error == nil { + DispatchQueue.main.async { + store.dispatch(.fileAction(.attachmentFileDownloaded(id: attachment.id, localUrl: localUrl))) + } + } + } + } + } + } + return Empty().eraseToAnyPublisher() + + case .fileAction(.attachmentFileDownloaded(let id, let localUrl)): + DispatchQueue.global(qos: .background).async { + guard let attachment = store.state.conversationsState.currentAttachments.first(where: { $0.id == id }) else { return } + switch attachment.type { + case .image: + if let data = try? Data(contentsOf: localUrl), let image = UIImage(data: data) { + image.scaleAndCropImage(toExampleSize: CGSizeMake(Const.attachmentPreviewSize, Const.attachmentPreviewSize)) { img in + if let img = img, let data = img.jpegData(compressionQuality: 1.0) { + let thumbnailUrl = self.fileFolder.appendingPathComponent("\(id)_thumbnail.jpg") + do { + try data.write(to: thumbnailUrl) + DispatchQueue.main.async { + store.dispatch(.fileAction(.attachmentThumbnailCreated(id: id, thumbnailUrl: thumbnailUrl))) + } + } catch { + print("Error writing thumbnail: \(error)") + } + } + } + } + + case .movie: + // self.downloadAndMakeThumbnail(for: attachment) + break + + default: + break } } return Empty().eraseToAnyPublisher() @@ -19,3 +61,36 @@ final class FileMiddleware { } } } + +private extension FileMiddleware { + var fileFolder: URL { + // swiftlint:disable:next force_unwrapping + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return documentsURL.appendingPathComponent(Const.fileFolder) + } +} + +private final class DownloadManager { + private let urlSession: URLSession + + init() { + let configuration = URLSessionConfiguration.default + urlSession = URLSession(configuration: configuration) + } + + func download(from url: URL, to localUrl: URL, completion: @escaping (Error?) -> Void) { + let task = urlSession.downloadTask(with: url) { tempLocalUrl, _, error in + if let tempLocalUrl = tempLocalUrl, error == nil { + do { + try FileManager.default.copyItem(at: tempLocalUrl, to: localUrl) + completion(nil) + } catch let writeError { + completion(writeError) + } + } else { + completion(error) + } + } + task.resume() + } +} diff --git a/ConversationsClassic/AppCore/Reducers/AppReducer.swift b/ConversationsClassic/AppCore/Reducers/AppReducer.swift index 35fcd93..2696f89 100644 --- a/ConversationsClassic/AppCore/Reducers/AppReducer.swift +++ b/ConversationsClassic/AppCore/Reducers/AppReducer.swift @@ -13,8 +13,8 @@ extension AppState { case .startAction(let action): StartState.reducer(state: &state.startState, action: action) - case .databaseAction, .xmppAction, .empty: - break // database and xmpp actions are processed by other middlewares + case .databaseAction, .xmppAction, .fileAction, .empty: + break // this actions are processed by other middlewares case .accountsAction(let action): AccountsState.reducer(state: &state.accountsState, action: action) diff --git a/ConversationsClassic/Helpers/Const.swift b/ConversationsClassic/Helpers/Const.swift index 0c42f7a..d1cf5a7 100644 --- a/ConversationsClassic/Helpers/Const.swift +++ b/ConversationsClassic/Helpers/Const.swift @@ -33,7 +33,7 @@ enum Const { static let videoDurationLimit = 60.0 // Upload/download file folder - static let fileFolder = "ConversationsClassic" + static let fileFolder = "Downloads" // Grid size for gallery preview (3 in a row) static let galleryGridSize = UIScreen.main.bounds.width / 3 diff --git a/ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift b/ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift index 16b84c1..cdd51d1 100644 --- a/ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift +++ b/ConversationsClassic/View/Screens/Conversation/ConversationMessageContainer.swift @@ -1,3 +1,4 @@ +import AVKit import MapKit import SwiftUI @@ -71,21 +72,59 @@ private struct AttachmentView: View { let attachmentId: String var body: some View { - if let attachment = store.state.conversationsState.currentAttachments.first(where: { $0.id == attachmentId }) { - if let localPath = attachment.localPath { - Image(systemName: "questionmark.square") - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) - .cornerRadius(10) - } else { - AttachmentPlaceholderView(placeholderImageName: progressImageName(attachment.type)) + if let attachment { + switch attachment.type { + case .image: + if let thumbnail = thumbnail() { + thumbnail + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) + } else { + placeholder + } + + case .movie: + if let file = attachment.localPath { + VideoPlayerView(url: file) + .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) + .cornerRadius(Const.attachmentPreviewSize / 10) + .overlay(RoundedRectangle(cornerRadius: Const.attachmentPreviewSize / 10).stroke(Color.Material.Shape.separator, lineWidth: 1)) + } else { + placeholder + } + + default: + placeholder } } else { - AttachmentPlaceholderView(placeholderImageName: nil) + placeholder } } + @ViewBuilder private var placeholder: some View { + Rectangle() + .foregroundColor(.Material.Background.dark) + .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) + .overlay { + ZStack { + ProgressView() + .scaleEffect(1.5) + .progressViewStyle(CircularProgressViewStyle(tint: .Material.Elements.active)) + if let attachment { + let imageName = progressImageName(attachment.type) + Image(systemName: imageName) + .font(.body1) + .foregroundColor(.Material.Elements.active) + } + } + } + } + + private var attachment: Attachment? { + store.state.conversationsState.currentAttachments.first(where: { $0.id == attachmentId }) + } + private func progressImageName(_ type: AttachmentType) -> String { switch type { case .image: @@ -98,26 +137,25 @@ private struct AttachmentView: View { return "doc" } } -} -private struct AttachmentPlaceholderView: View { - let placeholderImageName: String? - - var body: some View { - Rectangle() - .foregroundColor(.Material.Background.dark) - .frame(width: Const.attachmentPreviewSize, height: Const.attachmentPreviewSize) - .overlay { - ZStack { - ProgressView() - .scaleEffect(1.5) - .progressViewStyle(CircularProgressViewStyle(tint: .Material.Elements.active)) - if let imageName = placeholderImageName { - Image(systemName: imageName) - .font(.body1) - .foregroundColor(.Material.Elements.active) - } - } - } + private func thumbnail() -> Image? { + guard let attachment = attachment else { return nil } + guard let thumbnailPath = attachment.localThumbnailPath else { return nil } + guard let uiImage = UIImage(contentsOfFile: thumbnailPath.path()) else { return nil } + return Image(uiImage: uiImage) + } +} + +private struct VideoPlayerView: UIViewControllerRepresentable { + let url: URL + + func makeUIViewController(context _: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = AVPlayer(url: url) + return controller + } + + func updateUIViewController(_: AVPlayerViewController, context _: Context) { + // Update the controller if needed. } }