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 */,