Add media and documents list to the conversation

This commit is contained in:
Benoit Martins 2026-02-03 16:37:31 +01:00
parent 9ac0445347
commit 8223d20fc6
11 changed files with 944 additions and 3 deletions

View file

@ -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"
}

View file

@ -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!";

View file

@ -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";

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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))

View file

@ -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),

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -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 = "<group>"; };
D7ADF5FF2AFE356400212231 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = "<group>"; };
D7AEB9462F29128500298546 /* Shared.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Shared.xcconfig; sourceTree = "<group>"; };
D7AEB9712F324A5C00298546 /* ConversationMediaListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMediaListFragment.swift; sourceTree = "<group>"; };
D7AEB9732F324A6E00298546 /* ConversationDocumentsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDocumentsListFragment.swift; sourceTree = "<group>"; };
D7AEB9752F39E2A300298546 /* ConversationMediaListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationMediaListViewModel.swift; sourceTree = "<group>"; };
D7AEB9772F39E2C100298546 /* ConversationDocumentsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationDocumentsListViewModel.swift; sourceTree = "<group>"; };
D7AEB9792F39E83500298546 /* FileModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileModel.swift; sourceTree = "<group>"; };
D7B5066C2AEFA9B900CEB4E9 /* ContactInnerFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactInnerFragment.swift; sourceTree = "<group>"; };
D7B5678D2B28888F00DE63EB /* CallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallView.swift; sourceTree = "<group>"; };
D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallViewModel.swift; sourceTree = "<group>"; };
@ -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 */,