diff --git a/Linphone/GeneratedGit.swift b/Linphone/GeneratedGit.swift index 4d7d89c1d..fea6f0b1e 100644 --- a/Linphone/GeneratedGit.swift +++ b/Linphone/GeneratedGit.swift @@ -1,7 +1,7 @@ import Foundation public enum AppGitInfo { - public static let branch = "master" - public static let commit = "efed662a3" + public static let branch = "feature/medias_and_documents_lists" + public static let commit = "4dbdf455f" public static let tag = "6.1.0-alpha" } diff --git a/Linphone/Localizable/en.lproj/Localizable.strings b/Linphone/Localizable/en.lproj/Localizable.strings index 82a102781..1eaa0e7fe 100644 --- a/Linphone/Localizable/en.lproj/Localizable.strings +++ b/Linphone/Localizable/en.lproj/Localizable.strings @@ -256,6 +256,11 @@ "conversation_menu_configure_ephemeral_messages" = "Ephemeral messages"; "conversation_menu_media_files" = "Media"; "conversation_menu_documents_files" = "Documents"; +"conversation_no_media_found" = "No media found…"; +"conversation_no_document_found" = "No document found…"; +"conversation_media_list_title" = "Shared media"; +"conversation_document_list_title" = "Shared documents"; +"conversation_details_media_documents_title" = "Media & documents"; "conversation_message_forward_cancelled_toast" = "Message forward was cancelled"; "conversation_message_forwarded_toast" = "Message was forwarded"; "conversation_message_meeting_cancelled_label" = "Meeting has been cancelled!"; diff --git a/Linphone/Localizable/fr.lproj/Localizable.strings b/Linphone/Localizable/fr.lproj/Localizable.strings index fec3bcd12..0d87159c3 100644 --- a/Linphone/Localizable/fr.lproj/Localizable.strings +++ b/Linphone/Localizable/fr.lproj/Localizable.strings @@ -256,6 +256,11 @@ "conversation_menu_configure_ephemeral_messages" = "Messages éphémères"; "conversation_menu_media_files" = "Médias"; "conversation_menu_documents_files" = "Documents"; +"conversation_no_media_found" = "Aucun média pour le moment…"; +"conversation_no_document_found" = "Aucun document pour le moment…"; +"conversation_media_list_title" = "Médias partagés"; +"conversation_document_list_title" = "Documents partagés"; +"conversation_details_media_documents_title" = "Médias & documents"; "conversation_message_forward_cancelled_toast" = "Transfert annulé"; "conversation_message_forwarded_toast" = "Message transféré"; "conversation_message_meeting_cancelled_label" = "La réunion a été annulée"; diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationDocumentsListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationDocumentsListFragment.swift new file mode 100644 index 000000000..5801b431f --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationDocumentsListFragment.swift @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import linphonesw + +struct ConversationDocumentsListFragment: View { + @EnvironmentObject var conversationViewModel: ConversationViewModel + + @StateObject private var conversationDocumentsListViewModel = ConversationDocumentsListViewModel() + + @Binding var isShowDocumentsFilesFragment: Bool + + var body: some View { + NavigationView { + GeometryReader { geometry in + ZStack { + VStack(spacing: 1) { + + Rectangle() + .foregroundStyle(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 2) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + isShowDocumentsFilesFragment = false + } + } + + Text("conversation_document_list_title") + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + VStack(spacing: 0) { + List { + ForEach(conversationDocumentsListViewModel.documentsList, id: \.path) { file in + MediaGridItemView(file: file) + .background() + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowSeparator(.hidden) + } + } + .safeAreaInset(edge: .top, content: { + Spacer() + .frame(height: 12) + }) + .listStyle(.plain) + .overlay( + VStack { + if true { + Spacer() + Text("conversation_no_document_found") + .multilineTextAlignment(.leading) + .default_text_style_800(styleSize: 16) + Spacer() + } + } + .padding(.all) + ) + } + .frame(maxWidth: .infinity) + } + .background(Color.gray100) + } + .navigationTitle("") + .navigationBarHidden(true) + .onDisappear { + withAnimation { + isShowDocumentsFilesFragment = false + } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} + +struct DocumentRow: View { + + @ObservedObject var file: FileModel + + var body: some View { + ZStack(alignment: .bottomTrailing) { + if let previewPath = file.mediaPreview, + let image = UIImage(contentsOfFile: previewPath) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(height: 110) + .clipped() + } else { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(height: 110) + } + + if let duration = file.audioVideoDuration, file.isVideoPreview { + Text(duration) + .font(.caption2) + .padding(6) + .background(Color.black.opacity(0.6)) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(6) + } + } + .cornerRadius(8) + } +} diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index d0f54e629..d2b94af5e 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -59,9 +59,11 @@ struct ConversationFragment: View { @State private var mediasIsLoading = false @State private var voiceRecordingInProgress = false - @State private var isShowConversationForwardMessageFragment = false @State private var isShowEphemeralFragment = false + @State private var isShowMediaFilesFragment = false + @State private var isShowDocumentsFilesFragment = false @State private var isShowInfoConversationFragment = false + @State private var isShowConversationForwardMessageFragment = false @Binding var isShowConversationFragment: Bool @Binding var isShowStartCallGroupPopup: Bool @@ -456,6 +458,42 @@ struct ConversationFragment: View { .padding(.all, 10) } } + + Button { + isMenuOpen = false + withAnimation { + isShowMediaFilesFragment = true + } + } label: { + HStack { + Text("conversation_menu_media_files") + Spacer() + Image("image") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } + + Button { + isMenuOpen = false + withAnimation { + isShowDocumentsFilesFragment = true + } + } label: { + HStack { + Text("conversation_menu_documents_files") + Spacer() + Image("file-pdf") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } } } label: { Image("dots-three-vertical") @@ -1519,6 +1557,8 @@ struct ConversationFragment: View { ConversationInfoFragment( isMuted: $isMuted, isShowEphemeralFragment: $isShowEphemeralFragment, + isShowMediaFilesFragment: $isShowMediaFilesFragment, + isShowDocumentsFilesFragment: $isShowDocumentsFilesFragment, isShowStartCallGroupPopup: $isShowStartCallGroupPopup, isShowInfoConversationFragment: $isShowInfoConversationFragment, isShowEditContactFragment: $isShowEditContactFragment, @@ -1543,6 +1583,24 @@ struct ConversationFragment: View { .transition(.move(edge: .trailing)) } + if isShowMediaFilesFragment { + ConversationMediaListFragment( + isShowMediaFilesFragment: $isShowMediaFilesFragment + ) + .environmentObject(conversationViewModel) + .zIndex(5) + .transition(.move(edge: .trailing)) + } + + if isShowDocumentsFilesFragment { + ConversationDocumentsListFragment( + isShowDocumentsFilesFragment: $isShowDocumentsFilesFragment + ) + .environmentObject(conversationViewModel) + .zIndex(5) + .transition(.move(edge: .trailing)) + } + if conversationViewModel.searchInProgress { PopupLoadingView() .background(.black.opacity(0.65)) diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationInfoFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationInfoFragment.swift index 8576a37f1..d1fa9cb97 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationInfoFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationInfoFragment.swift @@ -33,6 +33,8 @@ struct ConversationInfoFragment: View { @Binding var isMuted: Bool @Binding var isShowEphemeralFragment: Bool + @Binding var isShowMediaFilesFragment: Bool + @Binding var isShowDocumentsFilesFragment: Bool @Binding var isShowStartCallGroupPopup: Bool @Binding var isShowInfoConversationFragment: Bool @Binding var isShowEditContactFragment: Bool @@ -525,6 +527,69 @@ struct ConversationInfoFragment: View { } } + Text("conversation_details_media_documents_title") + .default_text_style_800(styleSize: 18) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.top, 20) + + VStack(spacing: 0) { + Button( + action: { + withAnimation { + isShowMediaFilesFragment = true + } + }, + label: { + HStack { + Image("image") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + + Text("conversation_menu_media_files") + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + } + } + ) + .frame(height: 60) + + Divider() + + Button( + action: { + withAnimation { + isShowDocumentsFilesFragment = true + } + }, + label: { + HStack { + Image("file-pdf") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c600) + .frame(width: 25, height: 25) + + Text("conversation_menu_documents_files") + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + + } + } + ) + .frame(height: 60) + } + .padding(.horizontal, 20) + .padding(.vertical, 4) + .background(.white) + .cornerRadius(15) + .padding(.all) + Text("contact_details_actions_title") .default_text_style_800(styleSize: 18) .frame(maxWidth: .infinity, alignment: .leading) @@ -710,6 +775,8 @@ struct ConversationInfoFragment: View { ConversationInfoFragment( isMuted: .constant(false), isShowEphemeralFragment: .constant(false), + isShowMediaFilesFragment: .constant(false), + isShowDocumentsFilesFragment: .constant(false), isShowStartCallGroupPopup: .constant(false), isShowInfoConversationFragment: .constant(true), isShowEditContactFragment: .constant(false), diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationMediaListFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationMediaListFragment.swift new file mode 100644 index 000000000..3cc2868db --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationMediaListFragment.swift @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import SwiftUI +import linphonesw + +struct ConversationMediaListFragment: View { + @EnvironmentObject var conversationViewModel: ConversationViewModel + + @StateObject private var conversationMediaListViewModel = ConversationMediaListViewModel() + + @Binding var isShowMediaFilesFragment: Bool + + var body: some View { + NavigationView { + GeometryReader { geometry in + ZStack { + VStack(spacing: 1) { + + Rectangle() + .foregroundStyle(Color.orangeMain500) + .edgesIgnoringSafeArea(.top) + .frame(height: 0) + + HStack { + Image("caret-left") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 2) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + isShowMediaFilesFragment = false + } + } + + Text("conversation_media_list_title") + .multilineTextAlignment(.leading) + .default_text_style_orange_800(styleSize: 16) + + Spacer() + + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + + ConversationMediaGridView(viewModel: conversationMediaListViewModel) + } + .background(Color.gray100) + } + .navigationTitle("") + .navigationBarHidden(true) + .onDisappear { + withAnimation { + isShowMediaFilesFragment = false + } + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + } +} + +struct ConversationMediaGridView: View { + + @ObservedObject var viewModel: ConversationMediaListViewModel + + private let columns = [ + GridItem(.flexible(), spacing: 1), + GridItem(.flexible(), spacing: 1), + GridItem(.flexible(), spacing: 1) + ] + + var body: some View { + VStack(spacing: 0) { + if !viewModel.mediaList.isEmpty && !viewModel.operationInProgress { + ScrollView { + LazyVGrid(columns: columns, spacing: 1) { + ForEach(viewModel.mediaList, id: \.path) { file in + MediaGridItemView(file: file) + .onTapGesture { + //viewModel.openMediaEvent.send(file) + } + .onAppear { + if file == viewModel.mediaList.last { + viewModel.loadMoreData(totalItemsCount: viewModel.mediaList.count) + } + } + } + } + .padding(.horizontal, 2) + .padding(.top, 12) + } + } else if viewModel.mediaList.isEmpty && !viewModel.operationInProgress { + Spacer() + Text("conversation_no_media_found") + .multilineTextAlignment(.center) + .default_text_style_800(styleSize: 16) + Spacer() + } + } + } +} + +struct MediaGridItemView: View { + + @ObservedObject var file: FileModel + + var body: some View { + ZStack(alignment: .bottomTrailing) { + if let previewPath = file.mediaPreview, + let image = UIImage(contentsOfFile: previewPath) { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 120, height: 120) + .clipped() + } else { + Rectangle() + .fill(Color.gray.opacity(0.2)) + .frame(width: 120, height: 120) + } + + if file.isVideoPreview { + VStack { + Spacer() + + Image("play-fill") + .renderingMode(.template) + .resizable() + .foregroundStyle(.white) + .frame(width: 35, height: 35) + + Spacer() + } + .frame(width: 120, height: 120) + } + + if let duration = file.audioVideoDuration, file.isVideoPreview { + Text(duration) + .font(.caption2) + .padding(6) + .background(Color.black.opacity(0.6)) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .padding(6) + } + } + .cornerRadius(8) + } +} diff --git a/Linphone/UI/Main/Conversations/Model/FileModel.swift b/Linphone/UI/Main/Conversations/Model/FileModel.swift new file mode 100644 index 000000000..1837b72fd --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/FileModel.swift @@ -0,0 +1,235 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of Linphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import UIKit +import AVFoundation +import PDFKit + +final class FileModel: ObservableObject, Equatable { + + // MARK: - Inputs + let path: String + let fileName: String + let fileSize: Int64 + let fileCreationTimestamp: Int64 + let isEncrypted: Bool + let originalPath: String + let isFromEphemeralMessage: Bool + let isWaitingToBeDownloaded: Bool + let flexboxLayoutWrapBefore: Bool + private let onClicked: ((FileModel) -> Void)? + + @Published var formattedFileSize: String = "" + @Published var transferProgress: Int = -1 + @Published var transferProgressLabel: String = "" + @Published var mediaPreview: String? = nil + @Published var mediaPreviewAvailable: Bool = false + @Published var audioVideoDuration: String? = nil + + // MARK: - Computed + let mimeTypeString: String + let isMedia: Bool + let isImage: Bool + let isVideoPreview: Bool + let isPdf: Bool + let isAudio: Bool + + let month: String + let dateTime: String + + static func == (lhs: FileModel, rhs: FileModel) -> Bool { + return lhs.path == rhs.path + } + + // MARK: - Init + init( + path: String, + fileName: String, + fileSize: Int64, + fileCreationTimestamp: Int64, + isEncrypted: Bool, + originalPath: String, + isFromEphemeralMessage: Bool, + isWaitingToBeDownloaded: Bool = false, + flexboxLayoutWrapBefore: Bool = false, + onClicked: ((FileModel) -> Void)? = nil + ) { + self.path = path + self.fileName = fileName + self.fileSize = fileSize + self.fileCreationTimestamp = fileCreationTimestamp + self.isEncrypted = isEncrypted + self.originalPath = originalPath + self.isFromEphemeralMessage = isFromEphemeralMessage + self.isWaitingToBeDownloaded = isWaitingToBeDownloaded + self.flexboxLayoutWrapBefore = flexboxLayoutWrapBefore + self.onClicked = onClicked + + let ext = (path as NSString).pathExtension.lowercased() + self.isPdf = ext == "pdf" + + let mime = FileModel.mimeType(from: ext) + self.mimeTypeString = mime + + self.isImage = mime.hasPrefix("image/") + self.isVideoPreview = mime.hasPrefix("video/") + self.isAudio = mime.hasPrefix("audio/") + self.isMedia = isImage || isVideoPreview + + self.month = FileModel.month(from: fileCreationTimestamp) + self.dateTime = FileModel.formatDate(timestamp: fileCreationTimestamp) + + computeFileSize(fileSize) + updateTransferProgress(-1) + + if !isWaitingToBeDownloaded { + if isPdf { loadPdfPreview() } + if isImage { + mediaPreview = path + mediaPreviewAvailable = true + } else if isVideoPreview { + loadVideoPreview() + } + if isVideoPreview || isAudio { + getDuration() + } + } + } + + // MARK: - Actions + func onClick() { + onClicked?(self) + } + + func destroy() { + guard isEncrypted else { return } + DispatchQueue.global(qos: .background).async { + try? FileManager.default.removeItem(atPath: self.path) + } + } + + func deleteFile() async { + try? FileManager.default.removeItem(atPath: path) + } + + func computeFileSize(_ size: Int64) { + formattedFileSize = FileModel.bytesToReadable(size) + } + + func updateTransferProgress(_ percent: Int) { + transferProgress = percent + transferProgressLabel = (percent < 0 || percent > 100) ? "" : "\(percent)%" + } + + // MARK: - Preview + private func loadPdfPreview() { + DispatchQueue.global(qos: .utility).async { + guard let pdf = PDFDocument(url: URL(fileURLWithPath: self.path)), + let page = pdf.page(at: 0) else { return } + + let pageRect = page.bounds(for: .mediaBox) + let renderer = UIGraphicsImageRenderer(size: pageRect.size) + let image = renderer.image { ctx in + UIColor.white.set() + ctx.fill(pageRect) + page.draw(with: .mediaBox, to: ctx.cgContext) + } + + if let data = image.jpegData(compressionQuality: 0.8) { + let url = FileModel.cacheFileURL(name: "\(self.fileName).jpg") + try? data.write(to: url) + DispatchQueue.main.async { + self.mediaPreview = url.path + self.mediaPreviewAvailable = true + } + } + } + } + + private func loadVideoPreview() { + DispatchQueue.global(qos: .utility).async { + let asset = AVAsset(url: URL(fileURLWithPath: self.path)) + let generator = AVAssetImageGenerator(asset: asset) + generator.appliesPreferredTrackTransform = true + + let time = CMTime(seconds: 1, preferredTimescale: 600) + if let cgImage = try? generator.copyCGImage(at: time, actualTime: nil) { + let image = UIImage(cgImage: cgImage) + if let data = image.jpegData(compressionQuality: 0.8) { + let url = FileModel.cacheFileURL(name: "\(self.fileName).jpg") + try? data.write(to: url) + DispatchQueue.main.async { + self.mediaPreview = url.path + self.mediaPreviewAvailable = true + } + } + } + } + } + + private func getDuration() { + let asset = AVAsset(url: URL(fileURLWithPath: path)) + let seconds = Int(CMTimeGetSeconds(asset.duration)) + audioVideoDuration = FileModel.formatDuration(seconds) + } + + // MARK: - Utils + private static func cacheFileURL(name: String) -> URL { + let dir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + return dir.appendingPathComponent(name) + } + + private static func bytesToReadable(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } + + private static func mimeType(from ext: String) -> String { + switch ext { + case "jpg", "jpeg", "png", "heic": return "image/jpeg" + case "mp4", "mov": return "video/mp4" + case "mp3", "wav", "m4a": return "audio/mpeg" + case "pdf": return "application/pdf" + default: return "application/octet-stream" + } + } + + private static func formatDuration(_ seconds: Int) -> String { + let min = seconds / 60 + let sec = seconds % 60 + return String(format: "%02d:%02d", min, sec) + } + + private static func formatDate(timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private static func month(from timestamp: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + let formatter = DateFormatter() + formatter.dateFormat = "MMMM" + return formatter.string(from: date) + } +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationDocumentsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationDocumentsListViewModel.swift new file mode 100644 index 000000000..8edc6713b --- /dev/null +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationDocumentsListViewModel.swift @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import linphonesw + +final class ConversationDocumentsListViewModel: ObservableObject { + + private static let TAG = "[ConversationDocumentsListViewModel]" + private static let CONTENTS_PER_PAGE = 20 + + @Published var documentsList: [FileModel] = [] + @Published var operationInProgress: Bool = false + + private var totalDocumentsCount: Int = -1 + + private var conversationModel: ConversationModel! + + init() { + if let conversationModelTmp = SharedMainViewModel.shared.displayedConversation { + self.conversationModel = conversationModelTmp + loadDocumentsList() + } + } + + // MARK: - Loading + private func loadDocumentsList() { + operationInProgress = true + totalDocumentsCount = self.conversationModel.chatRoom.documentContentsSize + + let contentsToLoad = min(totalDocumentsCount, Self.CONTENTS_PER_PAGE) + let contents = self.conversationModel.chatRoom.getDocumentContentsRange(begin: 0, end: contentsToLoad) + + documentsList = getFileModelsList(from: contents) + operationInProgress = false + } + + func loadMoreData(totalItemsCount: Int) { + guard totalItemsCount < totalDocumentsCount else { return } + + var upperBound = totalItemsCount + Self.CONTENTS_PER_PAGE + if upperBound > totalDocumentsCount { + upperBound = totalDocumentsCount + } + + let contents = self.conversationModel.chatRoom.getDocumentContentsRange(begin: totalItemsCount, end: upperBound) + let newModels = getFileModelsList(from: contents) + + DispatchQueue.main.async { + self.documentsList.append(contentsOf: newModels) + } + } + + // MARK: - Mapping Content -> FileModel + private func getFileModelsList(from contents: [Content]) -> [FileModel] { + var list: [FileModel] = [] + + for documentContent in contents { + let isEncrypted = documentContent.isFileEncrypted + let originalPath = documentContent.filePath ?? "" + let path = isEncrypted ? documentContent.exportPlainFile() : originalPath + let name = documentContent.name ?? "" + let size = Int64(documentContent.size) + let timestamp = documentContent.creationTimestamp + + if path.isEmpty || name.isEmpty { continue } + + let ephemeral: Bool + if let messageId = documentContent.relatedChatMessageId { + if let chatMessage = self.conversationModel.chatRoom.findMessage(messageId: messageId) { + ephemeral = chatMessage.isEphemeral + } else { + ephemeral = self.conversationModel.chatRoom.ephemeralEnabled + } + } else { + ephemeral = self.conversationModel.chatRoom.ephemeralEnabled + } + + let model = FileModel( + path: path, + fileName: name, + fileSize: size, + fileCreationTimestamp: Int64(timestamp), + isEncrypted: isEncrypted, + originalPath: originalPath, + isFromEphemeralMessage: ephemeral + ) + + list.append(model) + } + + return list + } +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationMediaListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationMediaListViewModel.swift new file mode 100644 index 000000000..db9576dc0 --- /dev/null +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationMediaListViewModel.swift @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2010-2023 Belledonne Communications SARL. + * + * This file is part of linphone-iphone + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation +import linphonesw + +final class ConversationMediaListViewModel: ObservableObject { + + private static let TAG = "[ConversationMediaListViewModel]" + private static let CONTENTS_PER_PAGE = 50 + + @Published var mediaList: [FileModel] = [] + @Published var operationInProgress: Bool = false + + private var totalMediaCount: Int = -1 + + private var conversationModel: ConversationModel! + + init() { + if let conversationModelTmp = SharedMainViewModel.shared.displayedConversation { + self.conversationModel = conversationModelTmp + loadMediaList() + } + } + + // MARK: - Loading + + private func loadMediaList() { + operationInProgress = true + Log.info("\(Self.TAG) Loading media contents for conversation \(conversationModel.chatRoom.identifier ?? "No ID")") + + totalMediaCount = conversationModel.chatRoom.mediaContentsSize + Log.info("\(Self.TAG) Media contents size is [\(totalMediaCount)]") + + let contentsToLoad = min(totalMediaCount, Self.CONTENTS_PER_PAGE) + let contents = conversationModel.chatRoom.getMediaContentsRange(begin: 0, end: contentsToLoad) + + Log.info("\(Self.TAG) \(contents.count) media have been fetched") + + DispatchQueue.main.async { + self.mediaList = self.getFileModelsList(from: contents) + self.operationInProgress = false + } + } + + func loadMoreData(totalItemsCount: Int) { + CoreContext.shared.doOnCoreQueue { core in + Log.info("\(Self.TAG) Loading more data, current total is \(totalItemsCount), max size is \(self.totalMediaCount)") + + guard totalItemsCount < self.totalMediaCount else { return } + + var upperBound = totalItemsCount + Self.CONTENTS_PER_PAGE + if upperBound > self.totalMediaCount { + upperBound = self.totalMediaCount + } + + let contents = self.conversationModel.chatRoom.getMediaContentsRange(begin: totalItemsCount, end: upperBound) + Log.info("\(Self.TAG) \(contents.count) contents loaded, adding them to list") + + let newModels = self.getFileModelsList(from: contents) + + DispatchQueue.main.async { + self.mediaList.append(contentsOf: newModels) + } + } + } + + // MARK: - Mapping Content -> FileModel + + private func getFileModelsList(from contents: [Content]) -> [FileModel] { + var list: [FileModel] = [] + + for mediaContent in contents { + + if mediaContent.isVoiceRecording { continue } + + let isEncrypted = mediaContent.isFileEncrypted + let originalPath = mediaContent.filePath ?? "" + + let path: String + if isEncrypted { + Log.info("\(Self.TAG) [VFS] Content is encrypted, requesting plain file path for file \(originalPath)") + path = mediaContent.exportPlainFile() + } else { + path = originalPath + } + + let name = mediaContent.name ?? "" + let size = Int64(mediaContent.size) + let timestamp = mediaContent.creationTimestamp + + if !path.isEmpty && !name.isEmpty { + let model = FileModel( + path: path, + fileName: name, + fileSize: size, + fileCreationTimestamp: Int64(timestamp), + isEncrypted: isEncrypted, + originalPath: originalPath, + isFromEphemeralMessage: conversationModel.chatRoom.ephemeralEnabled + ) + list.append(model) + } + } + + return list + } +} diff --git a/LinphoneApp.xcodeproj/project.pbxproj b/LinphoneApp.xcodeproj/project.pbxproj index 533f0a4cf..f59a49f66 100644 --- a/LinphoneApp.xcodeproj/project.pbxproj +++ b/LinphoneApp.xcodeproj/project.pbxproj @@ -163,6 +163,11 @@ D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7A2EDD52AC18115005D90FC /* SharedMainViewModel.swift */; }; D7ADF6002AFE356400212231 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADF5FF2AFE356400212231 /* Avatar.swift */; }; D7AEB9472F29128500298546 /* Shared.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = D7AEB9462F29128500298546 /* Shared.xcconfig */; }; + D7AEB9722F324A5E00298546 /* ConversationMediaListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AEB9712F324A5C00298546 /* ConversationMediaListFragment.swift */; }; + D7AEB9742F324A6F00298546 /* ConversationDocumentsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AEB9732F324A6E00298546 /* ConversationDocumentsListFragment.swift */; }; + D7AEB9762F39E2A400298546 /* ConversationMediaListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AEB9752F39E2A300298546 /* ConversationMediaListViewModel.swift */; }; + D7AEB9782F39E2C300298546 /* ConversationDocumentsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AEB9772F39E2C100298546 /* ConversationDocumentsListViewModel.swift */; }; + D7AEB97A2F39E83600298546 /* FileModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AEB9792F39E83500298546 /* FileModel.swift */; }; D7B5066D2AEFA9B900CEB4E9 /* ContactInnerFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */; }; D7B5678E2B28888F00DE63EB /* CallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B5678D2B28888F00DE63EB /* CallView.swift */; }; D7B99E992B29B39000BE7BF2 /* CallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */; }; @@ -423,6 +428,11 @@ D7A2EDDA2AC19EEC005D90FC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D7ADF5FF2AFE356400212231 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; }; D7AEB9462F29128500298546 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = ""; }; + D7AEB9712F324A5C00298546 /* ConversationMediaListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMediaListFragment.swift; sourceTree = ""; }; + D7AEB9732F324A6E00298546 /* ConversationDocumentsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDocumentsListFragment.swift; sourceTree = ""; }; + D7AEB9752F39E2A300298546 /* ConversationMediaListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMediaListViewModel.swift; sourceTree = ""; }; + D7AEB9772F39E2C100298546 /* ConversationDocumentsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDocumentsListViewModel.swift; sourceTree = ""; }; + D7AEB9792F39E83500298546 /* FileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileModel.swift; sourceTree = ""; }; D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.swift; sourceTree = ""; }; D7B5678D2B28888F00DE63EB /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = ""; }; D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewModel.swift; sourceTree = ""; }; @@ -638,6 +648,7 @@ D70959EF2B8DF33B0014AC0B /* Model */ = { isa = PBXGroup; children = ( + D7AEB9792F39E83500298546 /* FileModel.swift */, D77A080D2CB6BCA10095D589 /* MessageConferenceInfo.swift */, D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */, D7E6ADF22B9875C20009A2BC /* Message.swift */, @@ -1068,6 +1079,8 @@ D7CEE0362B7A212C00FD79B7 /* ViewModel */ = { isa = PBXGroup; children = ( + D7AEB9772F39E2C100298546 /* ConversationDocumentsListViewModel.swift */, + D7AEB9752F39E2A300298546 /* ConversationMediaListViewModel.swift */, D7CEE0372B7A214F00FD79B7 /* ConversationsListViewModel.swift */, D70A26EF2B7D02E6006CC8FC /* ConversationViewModel.swift */, D759CB652C3FBE1D00AC35E8 /* StartConversationViewModel.swift */, @@ -1079,6 +1092,8 @@ D7CEE0392B7A232200FD79B7 /* Fragments */ = { isa = PBXGroup; children = ( + D7AEB9732F324A6E00298546 /* ConversationDocumentsListFragment.swift */, + D7AEB9712F324A5C00298546 /* ConversationMediaListFragment.swift */, D7EFD1E32CD11F53005E67CD /* EphemeralFragment.swift */, D7CEE03A2B7A234200FD79B7 /* ConversationsFragment.swift */, D7CEE03C2B7A23B200FD79B7 /* ConversationsListFragment.swift */, @@ -1453,6 +1468,7 @@ D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */, 662B69DB2B25DE25007118BF /* ProviderDelegate.swift in Sources */, D706BA822ADD72D100278F45 /* DeviceRotationViewModifier.swift in Sources */, + D7AEB9742F324A6F00298546 /* ConversationDocumentsListFragment.swift in Sources */, D732A9132B04C7A300DB42BA /* HistoryListFragment.swift in Sources */, D7C2DA1D2CA44DE400A2441B /* EventModel.swift in Sources */, D719ABC92ABC6FD700B41C10 /* CoreContext.swift in Sources */, @@ -1464,6 +1480,7 @@ 66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */, D738ACEE2E857BF10039F7D1 /* KeyboardResponder.swift in Sources */, D70959F12B8DF3EC0014AC0B /* ConversationModel.swift in Sources */, + D7AEB9762F39E2A400298546 /* ConversationMediaListViewModel.swift in Sources */, C6DC4E3D2C199C4E009096FD /* BundleExtenion.swift in Sources */, D7343FEF2D3FE16C0059D784 /* HelpViewModel.swift in Sources */, D7EAACCF2AD6ED8000AA6A8A /* PermissionsFragment.swift in Sources */, @@ -1474,6 +1491,7 @@ D7C3650A2AF001C300FE6142 /* EditContactFragment.swift in Sources */, D7A03FBD2ACC2DB60081A588 /* ContactsView.swift in Sources */, D78607712D36CB8A009E6A7E /* SettingsAdvancedFragment.swift in Sources */, + D7AEB97A2F39E83600298546 /* FileModel.swift in Sources */, D762102C2E97FDFD002E7999 /* CardDavAddressBookConfigurationFragment.swift in Sources */, 66E50A492BD12B2300AD61CA /* MeetingsView.swift in Sources */, D719ABCF2ABC779A00B41C10 /* AccountLoginViewModel.swift in Sources */, @@ -1482,6 +1500,7 @@ C6A5A9452C10B6270070FEA4 /* OIDAuthStateExtension.swift in Sources */, D732A90F2B04C3B400DB42BA /* HistoryFragment.swift in Sources */, D79F1C162CD3D6AD00FF0A05 /* ConversationInfoFragment.swift in Sources */, + D7AEB9722F324A5E00298546 /* ConversationMediaListFragment.swift in Sources */, D7D1F5262EDD91B30034EEB0 /* RecordingMediaPlayerFragment.swift in Sources */, D7C500422D2BE98100DD53EC /* AccountSettingsViewModel.swift in Sources */, D79622342B1DFE600037EACD /* DialerBottomSheet.swift in Sources */, @@ -1576,6 +1595,7 @@ D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */, D70A26EE2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift in Sources */, D7D1698C2AE66FA500109A5C /* MagicSearchSingleton.swift in Sources */, + D7AEB9782F39E2C300298546 /* ConversationDocumentsListViewModel.swift in Sources */, D714DE602C1B3B34006C1F1D /* RegisterViewModel.swift in Sources */, D70C82A72C85F5910087F43F /* ConversationForwardMessageViewModel.swift in Sources */, D74DA0122C047F0700A8561D /* HistoryModel.swift in Sources */,