From 73d6f805d37cc9946abe26228b468a21066bca33 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 5 Mar 2024 14:40:14 +0100 Subject: [PATCH] Test Table view for messages list --- Linphone.xcodeproj/project.pbxproj | 12 + .../Fragments/ChatBubbleView.swift | 60 ++- .../Fragments/ConversationFragment.swift | 347 +++++-------- .../Fragments/ConversationsListFragment.swift | 9 +- .../Conversations/Fragments/MessageMenu.swift | 30 ++ .../Main/Conversations/Fragments/UIList.swift | 487 ++++++++++++++++++ .../Main/Conversations/Model/Attachment.swift | 61 +++ .../Model/ConversationModel.swift | 12 +- .../UI/Main/Conversations/Model/Message.swift | 296 +++++++++++ .../ViewModel/ConversationViewModel.swift | 115 ++++- .../ConversationsListViewModel.swift | 2 +- 11 files changed, 1188 insertions(+), 243 deletions(-) create mode 100644 Linphone/UI/Main/Conversations/Fragments/MessageMenu.swift create mode 100644 Linphone/UI/Main/Conversations/Fragments/UIList.swift create mode 100644 Linphone/UI/Main/Conversations/Model/Attachment.swift create mode 100644 Linphone/UI/Main/Conversations/Model/Message.swift diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 43bab449b..329a09722 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */; }; D726E43F2B19E56F0083C415 /* StartCallViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */; }; D72992392ADD7F68003AF125 /* HistoryContactFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */; }; + D72A9A052B9750A1000DC093 /* UIList.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9A042B9750A1000DC093 /* UIList.swift */; }; D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732A9082AFD235500DB42BA /* ShareSheetController.swift */; }; D732A90C2B0376F500DB42BA /* linphonerc-default in Resources */ = {isa = PBXBuildFile; fileRef = D732A90A2B0376F500DB42BA /* linphonerc-default */; }; D732A90D2B0376F500DB42BA /* linphonerc-factory in Resources */ = {isa = PBXBuildFile; fileRef = D732A90B2B0376F500DB42BA /* linphonerc-factory */; }; @@ -99,6 +100,8 @@ D7D24D182AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */; }; D7DA67622ACCB2FA00E95002 /* LoginFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */; }; D7DA67642ACCB31700E95002 /* ProfileModeFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */; }; + D7E6ADF32B9875C20009A2BC /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6ADF22B9875C20009A2BC /* Message.swift */; }; + D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6ADF42B9876ED0009A2BC /* Attachment.swift */; }; D7E6D0492AE933AD00A57AAF /* FavoriteContactsListFragment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */; }; D7E6D04B2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */; }; D7E6D04D2AEBD77600A57AAF /* CustomBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */; }; @@ -151,6 +154,7 @@ D726E43C2B19E4FE0083C415 /* StartCallFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartCallFragment.swift; sourceTree = ""; }; D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartCallViewModel.swift; sourceTree = ""; }; D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.swift; sourceTree = ""; }; + D72A9A042B9750A1000DC093 /* UIList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIList.swift; sourceTree = ""; }; D732A9082AFD235500DB42BA /* ShareSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetController.swift; sourceTree = ""; }; D732A90A2B0376F500DB42BA /* linphonerc-default */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "linphonerc-default"; sourceTree = ""; }; D732A90B2B0376F500DB42BA /* linphonerc-factory */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "linphonerc-factory"; sourceTree = ""; }; @@ -205,6 +209,8 @@ D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-ExtraBold.ttf"; sourceTree = ""; }; D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFragment.swift; sourceTree = ""; }; D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModeFragment.swift; sourceTree = ""; }; + D7E6ADF22B9875C20009A2BC /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; + D7E6ADF42B9876ED0009A2BC /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = ""; }; D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListFragment.swift; sourceTree = ""; }; D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListViewModel.swift; sourceTree = ""; }; D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomBottomSheet.swift; sourceTree = ""; }; @@ -258,6 +264,8 @@ isa = PBXGroup; children = ( D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */, + D7E6ADF22B9875C20009A2BC /* Message.swift */, + D7E6ADF42B9876ED0009A2BC /* Attachment.swift */, ); path = Model; sourceTree = ""; @@ -576,6 +584,7 @@ D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */, D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */, D71968912B86369D00DF4459 /* ChatBubbleView.swift */, + D72A9A042B9750A1000DC093 /* UIList.swift */, ); path = Fragments; sourceTree = ""; @@ -786,6 +795,7 @@ D75759322B56D40900E7AC10 /* ZRTPPopup.swift in Sources */, D7A2EDD62AC18115005D90FC /* SharedMainViewModel.swift in Sources */, D7A03FC62ACC458A0081A588 /* SplashScreen.swift in Sources */, + D7E6ADF52B9876ED0009A2BC /* Attachment.swift in Sources */, D7A03FC02ACC2E390081A588 /* HistoryView.swift in Sources */, D748BF2E2ACD82E7004844EB /* ThirdPartySipAccountWarningFragment.swift in Sources */, D7173EBE2B7A5C0A00BCC481 /* LinphoneUtils.swift in Sources */, @@ -797,11 +807,13 @@ D732A9092AFD235500DB42BA /* ShareSheetController.swift in Sources */, D72343362AD037AF009AA24E /* ToastView.swift in Sources */, D7FB55112AD447FD00A5AB15 /* RegisterFragment.swift in Sources */, + D7E6ADF32B9875C20009A2BC /* Message.swift in Sources */, D7C48DF62AFCDF4700D938CB /* ContactInnerActionsFragment.swift in Sources */, D72343322ACEFF58009AA24E /* QRScannerController.swift in Sources */, 662B69D92B25DE18007118BF /* TelecomManager.swift in Sources */, D72343342ACEFFC3009AA24E /* QRScanner.swift in Sources */, 66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */, + D72A9A052B9750A1000DC093 /* UIList.swift in Sources */, D726E43D2B19E4FE0083C415 /* StartCallFragment.swift in Sources */, D7B99E9B2B29F7C300BE7BF2 /* ActivityIndicator.swift in Sources */, D72343302ACEFEF8009AA24E /* QrCodeScannerFragment.swift in Sources */, diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 0c029a7dc..e1e8ef98b 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -23,13 +23,17 @@ struct ChatBubbleView: View { @ObservedObject var conversationViewModel: ConversationViewModel - let index: Int + //let index: IndexPath + + let message: Message var body: some View { - if index < conversationViewModel.conversationMessagesList.count + /* + if index < conversationViewModel.conversationMessagesList.count && conversationViewModel.conversationMessagesList[index].eventLog.chatMessage != nil { VStack { if index == 0 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { + //if index % 30 == 29 && conversationViewModel.displayedConversationHistorySize > conversationViewModel.conversationMessagesList.count { ProgressView() .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) .id(UUID()) @@ -57,9 +61,61 @@ struct ChatBubbleView: View { .padding(.trailing, !conversationViewModel.conversationMessagesList[index].eventLog.chatMessage!.isOutgoing ? 40 : 0) } } + if conversationViewModel.conversationMessagesSection.count > index.section && conversationViewModel.conversationMessagesSection[index.section].rows.count > index.row { + VStack { + HStack { + if message.isOutgoing { + Spacer() + } + + VStack { + Text(message.text + ) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + .padding(.all, 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !message.isOutgoing { + Spacer() + } + } + .padding(.leading, message.isOutgoing ? 40 : 0) + .padding(.trailing, !message.isOutgoing ? 40 : 0) + } + } + */ + + VStack { + HStack { + if message.isOutgoing { + Spacer() + } + + VStack { + Text(message.text + ) + .foregroundStyle(Color.grayMain2c700) + .default_text_style(styleSize: 16) + } + .padding(.all, 15) + .background(message.isOutgoing ? Color.orangeMain100 : Color.grayMain2c100) + .clipShape(RoundedRectangle(cornerRadius: 16)) + + if !message.isOutgoing { + Spacer() + } + } + .padding(.leading, message.isOutgoing ? 40 : 0) + .padding(.trailing, !message.isOutgoing ? 40 : 0) + } } } +/* #Preview { ChatBubbleView(conversationViewModel: ConversationViewModel(), index: 0) } +*/ diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index adf475fbd..46dba158c 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -34,6 +34,14 @@ struct ConversationFragment: View { @State var offset: CGPoint = .zero + private let ids: [String] = [] + + @State private var isScrolledToBottom: Bool = true + var showMessageMenuOnLongPress: Bool = true + + @StateObject private var viewModel = ChatViewModel() + @StateObject private var paginationState = PaginationState() + var body: some View { NavigationView { GeometryReader { geometry in @@ -142,223 +150,106 @@ struct ConversationFragment: View { .padding(.bottom, 4) .background(.white) - /* - List { - ForEach(0.. conversationViewModel.conversationMessagesList.count { - //DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - conversationViewModel.getOldMessages() - //} + if #available(iOS 16.0, *) { + ZStack(alignment: .bottomTrailing) { + list + + if !isScrolledToBottom { + Button { + NotificationCenter.default.post(name: .onScrollToBottom, object: nil) + } label: { + ZStack { + + Image("caret-down") + .renderingMode(.template) + .foregroundStyle(.white) + .padding() + .background(Color.orangeMain500) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.2), radius: 4) + + if conversationViewModel.displayedConversationUnreadMessagesCount > 0 { + VStack { + HStack { + Spacer() + + HStack { + Text( + conversationViewModel.displayedConversationUnreadMessagesCount < 99 + ? String(conversationViewModel.displayedConversationUnreadMessagesCount) + : "99+" + ) + .foregroundStyle(.white) + .default_text_style(styleSize: 10) + .lineLimit(1) + + } + .frame(width: 18, height: 18) + .background(Color.redDanger500) + .cornerRadius(50) + } + + Spacer() + } } } + + } + .frame(width: 50, height: 50) + .padding() } } - .listStyle(.plain) .onTapGesture { UIApplication.shared.endEditing() } .onAppear { conversationViewModel.getMessages() } - .onChange(of: conversationViewModel.conversationMessagesList) { _ in - if conversationViewModel.conversationMessagesList.count <= 30 { - proxy.scrollTo( - conversationViewModel.conversationMessagesList.last, anchor: .top - ) - } else if conversationViewModel.conversationMessagesList.count >= conversationViewModel.displayedConversationHistorySize { - print("ChatBubbleViewChatBubbleView 1 " - + "\(conversationViewModel.conversationMessagesList.count) " - + "\(conversationViewModel.displayedConversationHistorySize - 30) " - + "\(conversationViewModel.conversationMessagesList.first?.eventLog.chatMessage!.utf8Text ?? "") " - + "\(conversationViewModel.conversationMessagesList[29].eventLog.chatMessage!.utf8Text ?? "")" - ) - - proxy.scrollTo( - conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top - ) - } else { - print("ChatBubbleViewChatBubbleView 2 " - + "\(conversationViewModel.conversationMessagesList.count) " - + "\(conversationViewModel.displayedConversationHistorySize - 30) " - + "\(conversationViewModel.conversationMessagesList.first?.eventLog.chatMessage!.utf8Text ?? "") " - + "\(conversationViewModel.conversationMessagesList[29].eventLog.chatMessage!.utf8Text ?? "")" - ) - - proxy.scrollTo(30, anchor: .top) - } - } .onDisappear { conversationViewModel.resetMessage() } - } - - - /* - GeometryReader { reader in + } else { ScrollViewReader { proxy in - if #available(iOS 17.0, *) { - ScrollView(.vertical) { - VStack(spacing: 4) { - Spacer() - ForEach(0.. Color in - DispatchQueue.main.async { - //self.offset = -geometry.frame(in: .named("scroll")).origin.y - let offsetMax = geometry.size.height - reader.size.height - //print("ScrollOffsetPreferenceKey >> \(self.offset) \(offsetMax)") - if -geometry.frame(in: .named("scroll")).origin.y <= 0 && self.offset > 0 { + List { + ForEach(0.. conversationViewModel.conversationMessagesList.count { + //DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { conversationViewModel.getOldMessages() - print("ScrollOffsetPreferenceKey >> \(self.offset) \(-geometry.frame(in: .named("scroll")).origin.y) \(offsetMax)") - //proxy.scrollTo(conversationViewModel.conversationMessagesList[19], anchor: .top) + //} } - self.offset = -geometry.frame(in: .named("scroll")).origin.y } - return Color.clear - }) - /*/ - .background(GeometryReader { geometry in - Color.clear - .preference(key: ScrollOffsetPreferenceKey.self, value: (geometry.frame(in: .named("scroll")).origin)) - }) - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - //self.scrollOffset = value - print("ScrollOffsetPreferenceKey \(value)") - if value.y > 0 { - print("ScrollOffsetPreferenceKey \(value) \(conversationViewModel.conversationMessagesList.count)") - conversationViewModel.getOldMessages() - } - } - */ } - .coordinateSpace(name: "scroll") - .onTapGesture { - UIApplication.shared.endEditing() - } - .onAppear { - conversationViewModel.getMessages() - } - .onDisappear { - conversationViewModel.resetMessage() - } - .defaultScrollAnchor(.bottom) - } else { - ScrollView(.vertical) { - VStack { - ForEach(0..= conversationViewModel.displayedConversationHistorySize { + proxy.scrollTo( + conversationViewModel.conversationMessagesList[conversationViewModel.displayedConversationHistorySize%30], anchor: .top + ) + } else { + proxy.scrollTo(30, anchor: .top) } } + .onDisappear { + conversationViewModel.resetMessage() + } } } - */ - - /* - ScrollViewReader { proxy in - if #available(iOS 17.0, *) { - ScrollView { - LazyVStack { - ForEach(0... + */ + +import SwiftUI + +struct MessageMenu: View { + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + MessageMenu() +} diff --git a/Linphone/UI/Main/Conversations/Fragments/UIList.swift b/Linphone/UI/Main/Conversations/Fragments/UIList.swift new file mode 100644 index 000000000..702f0a857 --- /dev/null +++ b/Linphone/UI/Main/Conversations/Fragments/UIList.swift @@ -0,0 +1,487 @@ +/* + * 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 . + */ + +// swiftlint:disable large_tuple +import SwiftUI + +public extension Notification.Name { + static let onScrollToBottom = Notification.Name("onScrollToBottom") +} + +struct UIList: UIViewRepresentable { + + @ObservedObject var viewModel: ChatViewModel + @ObservedObject var paginationState: PaginationState + @ObservedObject var conversationViewModel: ConversationViewModel + + @Binding var isScrolledToBottom: Bool + + let showMessageMenuOnLongPress: Bool + let sections: [MessagesSection] + let ids: [String] + + @State private var isScrolledToTop = false + + private let updatesQueue = DispatchQueue(label: "updatesQueue", qos: .utility) + @State private var updateSemaphore = DispatchSemaphore(value: 1) + @State private var tableSemaphore = DispatchSemaphore(value: 0) + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .grouped) + tableView.contentInset = UIEdgeInsets(top: -10, left: 0, bottom: -20, right: 0) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.separatorStyle = .none + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") + tableView.transform = CGAffineTransformMakeScale(1, -1) + + tableView.showsVerticalScrollIndicator = true + tableView.estimatedSectionHeaderHeight = 1 + tableView.estimatedSectionFooterHeight = UITableView.automaticDimension + tableView.backgroundColor = UIColor(.white) + tableView.scrollsToTop = true + + NotificationCenter.default.addObserver(forName: .onScrollToBottom, object: nil, queue: nil) { _ in + DispatchQueue.main.async { + if !context.coordinator.sections.isEmpty { + tableView.scrollToRow(at: IndexPath(row: 0, section: 0), at: .bottom, animated: true) + } + } + } + + return tableView + } + + func updateUIView(_ tableView: UITableView, context: Context) { + if context.coordinator.sections == sections { + return + } + updatesQueue.async { + updateSemaphore.wait() + + if context.coordinator.sections == sections { + updateSemaphore.signal() + return + } + + let prevSections = context.coordinator.sections + let (appliedDeletes, appliedDeletesSwapsAndEdits, deleteOperations, swapOperations, editOperations, insertOperations) = operationsSplit(oldSections: prevSections, newSections: sections) + + // step 1 + // preapare intermediate sections and operations + //print("1 updateUIView sections:", "\n") + //print("whole previous:\n", formatSections(prevSections), "\n") + //print("whole appliedDeletes:\n", formatSections(appliedDeletes), "\n") + //print("whole appliedDeletesSwapsAndEdits:\n", formatSections(appliedDeletesSwapsAndEdits), "\n") + //print("whole final sections:\n", formatSections(sections), "\n") + + //print("operations delete:\n", deleteOperations) + //print("operations swap:\n", swapOperations) + //print("operations edit:\n", editOperations) + //print("operations insert:\n", insertOperations) + + DispatchQueue.main.async { + tableView.performBatchUpdates { + // step 2 + // delete sections and rows if necessary + //print("2 apply delete") + context.coordinator.sections = appliedDeletes + for operation in deleteOperations { + applyOperation(operation, tableView: tableView) + } + } completion: { _ in + tableSemaphore.signal() + //print("2 finished delete") + } + } + tableSemaphore.wait() + + DispatchQueue.main.async { + tableView.performBatchUpdates { + // step 3 + // swap places for rows that moved inside the table + // (example of how this happens. send two messages: first m1, then m2. if m2 is delivered to server faster, then it should jump above m1 even though it was sent later) + //print("3 apply swaps") + context.coordinator.sections = appliedDeletesSwapsAndEdits // NOTE: this array already contains necessary edits, but won't be a problem for appplying swaps + for operation in swapOperations { + applyOperation(operation, tableView: tableView) + } + } completion: { _ in + tableSemaphore.signal() + //print("3 finished swaps") + } + } + tableSemaphore.wait() + + DispatchQueue.main.async { + tableView.performBatchUpdates { + // step 4 + // check only sections that are already in the table for existing rows that changed and apply only them to table's dataSource without animation + //print("4 apply edits") + context.coordinator.sections = appliedDeletesSwapsAndEdits + for operation in editOperations { + applyOperation(operation, tableView: tableView) + } + } completion: { _ in + tableSemaphore.signal() + //print("4 finished edits") + } + } + tableSemaphore.wait() + + if isScrolledToBottom || isScrolledToTop { + DispatchQueue.main.sync { + // step 5 + // apply the rest of the changes to table's dataSource, i.e. inserts + //print("5 apply inserts") + context.coordinator.sections = sections + context.coordinator.ids = ids + + tableView.beginUpdates() + for operation in insertOperations { + applyOperation(operation, tableView: tableView) + } + tableView.endUpdates() + + updateSemaphore.signal() + } + } else { + context.coordinator.ids = ids + updateSemaphore.signal() + } + } + } + + // MARK: - Operations + + enum Operation { + case deleteSection(Int) + case insertSection(Int) + + case delete(Int, Int) // delete with animation + case insert(Int, Int) // insert with animation + case swap(Int, Int, Int) // delete first with animation, then insert it into new position with animation. do not do anything with the second for now + case edit(Int, Int) // reload the element without animation + } + + func applyOperation(_ operation: Operation, tableView: UITableView) { + switch operation { + case .deleteSection(let section): + tableView.deleteSections([section], with: .top) + case .insertSection(let section): + tableView.insertSections([section], with: .top) + + case .delete(let section, let row): + tableView.deleteRows(at: [IndexPath(row: row, section: section)], with: .top) + case .insert(let section, let row): + tableView.insertRows(at: [IndexPath(row: row, section: section)], with: .top) + case .edit(let section, let row): + tableView.reloadRows(at: [IndexPath(row: row, section: section)], with: .none) + case .swap(let section, let rowFrom, let rowTo): + tableView.deleteRows(at: [IndexPath(row: rowFrom, section: section)], with: .top) + tableView.insertRows(at: [IndexPath(row: rowTo, section: section)], with: .top) + } + } + + func operationsSplit(oldSections: [MessagesSection], newSections: [MessagesSection]) -> ([MessagesSection], [MessagesSection], [Operation], [Operation], [Operation], [Operation]) { + var appliedDeletes = oldSections // start with old sections, remove rows that need to be deleted + var appliedDeletesSwapsAndEdits = newSections // take new sections and remove rows that need to be inserted for now, then we'll get array with all the changes except for inserts + // appliedDeletesSwapsEditsAndInserts == newSection + + var deleteOperations = [Operation]() + var swapOperations = [Operation]() + var editOperations = [Operation]() + var insertOperations = [Operation]() + + // 1 compare sections + + let oldDates = oldSections.map { $0.date } + let newDates = newSections.map { $0.date } + let commonDates = Array(Set(oldDates + newDates)).sorted(by: >) + for date in commonDates { + let oldIndex = appliedDeletes.firstIndex(where: { $0.date == date } ) + let newIndex = appliedDeletesSwapsAndEdits.firstIndex(where: { $0.date == date } ) + if oldIndex == nil, let newIndex { + // operationIndex is not the same as newIndex because appliedDeletesSwapsAndEdits is being changed as we go, but to apply changes to UITableView we should have initial index + if let operationIndex = newSections.firstIndex(where: { $0.date == date } ) { + appliedDeletesSwapsAndEdits.remove(at: newIndex) + insertOperations.append(.insertSection(operationIndex)) + } + continue + } + if newIndex == nil, let oldIndex { + if let operationIndex = oldSections.firstIndex(where: { $0.date == date } ) { + appliedDeletes.remove(at: oldIndex) + deleteOperations.append(.deleteSection(operationIndex)) + } + continue + } + guard let newIndex, let oldIndex else { continue } + + // 2 compare section rows + // isolate deletes and inserts, and remove them from row arrays, leaving only rows that are in both arrays: 'duplicates' + // this will allow to compare relative position changes of rows - swaps + + var oldRows = appliedDeletes[oldIndex].rows + var newRows = appliedDeletesSwapsAndEdits[newIndex].rows + let oldRowIDs = Set(oldRows.map { $0.id }) + let newRowIDs = Set(newRows.map { $0.id }) + let rowIDsToDelete = oldRowIDs.subtracting(newRowIDs) + let rowIDsToInsert = newRowIDs.subtracting(oldRowIDs) // TODO is order important? + for rowId in rowIDsToDelete { + if let index = oldRows.firstIndex(where: { $0.id == rowId }) { + oldRows.remove(at: index) + deleteOperations.append(.delete(oldIndex, index)) // this row was in old section, should not be in final result + } + } + for rowId in rowIDsToInsert { + if let index = newRows.firstIndex(where: { $0.id == rowId }) { + // this row was not in old section, should add it to final result + insertOperations.append(.insert(newIndex, index)) + } + } + + for rowId in rowIDsToInsert { + if let index = newRows.firstIndex(where: { $0.id == rowId }) { + // remove for now, leaving only 'duplicates' + newRows.remove(at: index) + } + } + + // 3 isolate swaps and edits + + for row in 0.. Bool { + !swaps.filter { + if case let .swap(section, rowFrom, rowTo) = $0 { + return section == section && (rowFrom == index || rowTo == index) + } + return false + }.isEmpty + } + + // MARK: - Coordinator + + func makeCoordinator() -> Coordinator { + Coordinator( + conversationViewModel: conversationViewModel, + viewModel: viewModel, + paginationState: paginationState, + isScrolledToBottom: $isScrolledToBottom, + isScrolledToTop: $isScrolledToTop, + showMessageMenuOnLongPress: showMessageMenuOnLongPress, + sections: sections, + ids: ids + ) + } + + class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + + @ObservedObject var viewModel: ChatViewModel + @ObservedObject var paginationState: PaginationState + @ObservedObject var conversationViewModel: ConversationViewModel + + @Binding var isScrolledToBottom: Bool + @Binding var isScrolledToTop: Bool + + let showMessageMenuOnLongPress: Bool + var sections: [MessagesSection] + var ids: [String] + + init(conversationViewModel: ConversationViewModel, viewModel: ChatViewModel, paginationState: PaginationState, isScrolledToBottom: Binding, isScrolledToTop: Binding, showMessageMenuOnLongPress: Bool, sections: [MessagesSection], ids: [String]) { + self.conversationViewModel = conversationViewModel + self.viewModel = viewModel + self.paginationState = paginationState + self._isScrolledToBottom = isScrolledToBottom + self._isScrolledToTop = isScrolledToTop + self.showMessageMenuOnLongPress = showMessageMenuOnLongPress + self.sections = sections + self.ids = ids + } + + func numberOfSections(in tableView: UITableView) -> Int { + sections.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + sections[section].rows.count + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + return progressView(section) + } + + func progressView(_ section: Int) -> UIView? { + if section > conversationViewModel.conversationMessagesSection.count + && conversationViewModel.conversationMessagesSection[section].rows.count < conversationViewModel.displayedConversationHistorySize { + let header = UIHostingController(rootView: + ProgressView() + .frame(idealWidth: .infinity, maxWidth: .infinity, alignment: .center) + ).view + header?.backgroundColor = UIColor(.white) + return header + } + return nil + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + let tableViewCell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) + tableViewCell.selectionStyle = .none + tableViewCell.backgroundColor = UIColor(.white) + + let row = sections[indexPath.section].rows[indexPath.row] + if #available(iOS 16.0, *) { + tableViewCell.contentConfiguration = UIHostingConfiguration { + ChatBubbleView(conversationViewModel: conversationViewModel, message: row) + .padding(.vertical, 1) + .padding(.horizontal, 10) + .onTapGesture { } + } + .minSize(width: 0, height: 0) + .margins(.all, 0) + } else { + // Fallback on earlier versions + } + + tableViewCell.transform = CGAffineTransformMakeScale(1, -1) + + return tableViewCell + } + + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + let row = sections[indexPath.section].rows[indexPath.row] + paginationState.handle(row, ids: ids) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + isScrolledToBottom = scrollView.contentOffset.y <= 10 + + if isScrolledToBottom && conversationViewModel.displayedConversationUnreadMessagesCount > 0 { + conversationViewModel.markAsRead() + } + + if !isScrolledToTop && scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 { + self.conversationViewModel.getOldMessages() + } + isScrolledToTop = scrollView.contentOffset.y >= scrollView.contentSize.height - scrollView.frame.height - 200 + } + } +} + +struct MessagesSection: Equatable { + + let date: Date + var rows: [Message] + + static var formatter = { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d" + return formatter + }() + + init(date: Date, rows: [Message]) { + self.date = date + self.rows = rows + } + + var formattedDate: String { + MessagesSection.formatter.string(from: date) + } + + static func == (lhs: MessagesSection, rhs: MessagesSection) -> Bool { + lhs.date == rhs.date && lhs.rows == rhs.rows + } + +} + +final class PaginationState: ObservableObject { + var onEvent: ChatPaginationClosure? + var offset: Int + + var shouldHandlePagination: Bool { + onEvent != nil + } + + init(onEvent: ChatPaginationClosure? = nil, offset: Int = 0) { + self.onEvent = onEvent + self.offset = offset + } + + func handle(_ message: Message, ids: [String]) { + guard shouldHandlePagination else { + return + } + if ids.prefix(offset + 1).contains(message.id) { + onEvent?(message) + } + } +} + +public typealias ChatPaginationClosure = (Message) -> Void + +final class ChatViewModel: ObservableObject { + + @Published private(set) var fullscreenAttachmentItem: Optional = nil + @Published var fullscreenAttachmentPresented = false + + @Published var messageMenuRow: Message? + + public var didSendMessage: (DraftMessage) -> Void = {_ in} + + func presentAttachmentFullScreen(_ attachment: Attachment) { + fullscreenAttachmentItem = attachment + fullscreenAttachmentPresented = true + } + + func dismissAttachmentFullScreen() { + fullscreenAttachmentPresented = false + fullscreenAttachmentItem = nil + } + + func sendMessage(_ message: DraftMessage) { + didSendMessage(message) + } +} + +// swiftlint:enable large_tuple diff --git a/Linphone/UI/Main/Conversations/Model/Attachment.swift b/Linphone/UI/Main/Conversations/Model/Attachment.swift new file mode 100644 index 000000000..e9973cbaf --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/Attachment.swift @@ -0,0 +1,61 @@ +/* + * 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 + +public enum AttachmentType: String, Codable { + case image + case video + + public var title: String { + switch self { + case .image: + return "Image" + default: + return "Video" + } + } + + public init(mediaType: MediaType) { + switch mediaType { + case .image: + self = .image + default: + self = .video + } + } +} + +public struct Attachment: Codable, Identifiable, Hashable { + public let id: String + public let thumbnail: URL + public let full: URL + public let type: AttachmentType + + public init(id: String, thumbnail: URL, full: URL, type: AttachmentType) { + self.id = id + self.thumbnail = thumbnail + self.full = full + self.type = type + } + + public init(id: String, url: URL, type: AttachmentType) { + self.init(id: id, thumbnail: url, full: url, type: type) + } +} diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 9044b7a1f..22fb9ede6 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -88,7 +88,7 @@ class ConversationModel: ObservableObject { //self.dateTime = chatRoom.date - self.unreadMessagesCount = chatRoom.unreadMessagesCount + self.unreadMessagesCount = 0 self.avatarModel = ContactAvatarModel(friend: nil, name: "", withPresence: false) @@ -98,9 +98,10 @@ class ConversationModel: ObservableObject { getContentTextMessage() getChatRoomSubject() + getUnreadMessagesCount() } - func leave(){ + func leave() { coreContext.doOnCoreQueue { _ in self.chatRoom.leave() } @@ -214,6 +215,13 @@ class ConversationModel: ObservableObject { } } + + func getUnreadMessagesCount() { + coreContext.doOnCoreQueue { _ in + self.unreadMessagesCount = self.chatRoom.unreadMessagesCount + } + } + func refreshAvatarModel() { coreContext.doOnCoreQueue { _ in let addressFriend = diff --git a/Linphone/UI/Main/Conversations/Model/Message.swift b/Linphone/UI/Main/Conversations/Model/Message.swift new file mode 100644 index 000000000..3865cf26c --- /dev/null +++ b/Linphone/UI/Main/Conversations/Model/Message.swift @@ -0,0 +1,296 @@ +/* + * 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 + +public struct Message: Identifiable, Hashable { + + public enum Status: Equatable, Hashable { + case sending + case sent + case read + case error(DraftMessage) + + public func hash(into hasher: inout Hasher) { + switch self { + case .sending: + return hasher.combine("sending") + case .sent: + return hasher.combine("sent") + case .read: + return hasher.combine("read") + case .error: + return hasher.combine("error") + } + } + + public static func == (lhs: Message.Status, rhs: Message.Status) -> Bool { + switch (lhs, rhs) { + case (.sending, .sending): + return true + case (.sent, .sent): + return true + case (.read, .read): + return true + case ( .error(_), .error(_)): + return true + default: + return false + } + } + } + + public var id: String + public var status: Status? + public var createdAt: Date + public var isOutgoing: Bool + + public var text: String + public var attachments: [Attachment] + public var recording: Recording? + public var replyMessage: ReplyMessage? + + public init(id: String, + status: Status? = nil, + createdAt: Date = Date(), + isOutgoing: Bool, + text: String = "", + attachments: [Attachment] = [], + recording: Recording? = nil, + replyMessage: ReplyMessage? = nil) { + + self.id = id + self.status = status + self.createdAt = createdAt + self.isOutgoing = isOutgoing + self.text = text + self.attachments = attachments + self.recording = recording + self.replyMessage = replyMessage + } + + public static func makeMessage( + id: String, + status: Status? = nil, + draft: DraftMessage) async -> Message { + let attachments = await draft.medias.asyncCompactMap { media -> Attachment? in + guard let thumbnailURL = await media.getThumbnailURL() else { + return nil + } + + switch media.type { + case .image: + return Attachment(id: UUID().uuidString, url: thumbnailURL, type: .image) + case .video: + guard let fullURL = await media.getURL() else { + return nil + } + return Attachment(id: UUID().uuidString, thumbnail: thumbnailURL, full: fullURL, type: .video) + } + } + + return Message( + id: id, + status: status, + createdAt: draft.createdAt, + isOutgoing: draft.isOutgoing, + text: draft.text, + attachments: attachments, + recording: draft.recording, + replyMessage: draft.replyMessage + ) + } +} + +extension Message { + var time: String { + DateFormatter.timeFormatter.string(from: createdAt) + } +} + +extension Message: Equatable { + public static func == (lhs: Message, rhs: Message) -> Bool { + lhs.id == rhs.id && lhs.status == rhs.status + } +} + +public struct Recording: Codable, Hashable { + public var duration: Double + public var waveformSamples: [CGFloat] + public var url: URL? + + public init(duration: Double = 0.0, waveformSamples: [CGFloat] = [], url: URL? = nil) { + self.duration = duration + self.waveformSamples = waveformSamples + self.url = url + } +} + +public struct ReplyMessage: Codable, Identifiable, Hashable { + public static func == (lhs: ReplyMessage, rhs: ReplyMessage) -> Bool { + lhs.id == rhs.id + } + + public var id: String + + public var text: String + public var isOutgoing: Bool + public var attachments: [Attachment] + public var recording: Recording? + + public init(id: String, + text: String = "", + isOutgoing: Bool, + attachments: [Attachment] = [], + recording: Recording? = nil) { + + self.id = id + self.text = text + self.isOutgoing = isOutgoing + self.attachments = attachments + self.recording = recording + } + + func toMessage() -> Message { + Message(id: id, isOutgoing: isOutgoing, text: text, attachments: attachments, recording: recording) + } +} + +public extension Message { + + func toReplyMessage() -> ReplyMessage { + ReplyMessage(id: id, text: text, isOutgoing: isOutgoing, attachments: attachments, recording: recording) + } +} + +public struct DraftMessage { + public var id: String? + public let isOutgoing: Bool + public let text: String + public let medias: [Media] + public let recording: Recording? + public let replyMessage: ReplyMessage? + public let createdAt: Date + + public init(id: String? = nil, + isOutgoing: Bool, + text: String, + medias: [Media], + recording: Recording?, + replyMessage: ReplyMessage?, + createdAt: Date) { + self.id = id + self.isOutgoing = isOutgoing + self.text = text + self.medias = medias + self.recording = recording + self.replyMessage = replyMessage + self.createdAt = createdAt + } +} + +public enum MediaType { + case image + case video +} + +public struct Media: Identifiable, Equatable { + public var id = UUID() + internal let source: MediaModelProtocol + + public static func == (lhs: Media, rhs: Media) -> Bool { + lhs.id == rhs.id + } +} + +public extension Media { + + var type: MediaType { + source.mediaType ?? .image + } + + var duration: CGFloat? { + source.duration + } + + func getURL() async -> URL? { + await source.getURL() + } + + func getThumbnailURL() async -> URL? { + await source.getThumbnailURL() + } + + func getData() async -> Data? { + try? await source.getData() + } + + func getThumbnailData() async -> Data? { + await source.getThumbnailData() + } +} + +protocol MediaModelProtocol { + var mediaType: MediaType? { get } + var duration: CGFloat? { get } + + func getURL() async -> URL? + func getThumbnailURL() async -> URL? + + func getData() async throws -> Data? + func getThumbnailData() async -> Data? +} + +extension Sequence { + func asyncCompactMap( + _ transform: (Element) async throws -> T? + ) async rethrows -> [T] { + var values = [T]() + + for element in self { + if let el = try await transform(element) { + values.append(el) + } + } + + return values + } +} + +extension DateFormatter { + static let timeFormatter = { + let formatter = DateFormatter() + + formatter.dateStyle = .none + formatter.timeStyle = .short + + return formatter + }() + + static func timeString(_ seconds: Int) -> String { + let hour = Int(seconds) / 3600 + let minute = Int(seconds) / 60 % 60 + let second = Int(seconds) % 60 + + if hour > 0 { + return String(format: "%02i:%02i:%02i", hour, minute, second) + } + return String(format: "%02i:%02i", minute, second) + } +} diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index b4fa2f73e..50cf29a2a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -28,12 +28,16 @@ class ConversationViewModel: ObservableObject { @Published var displayedConversation: ConversationModel? @Published var displayedConversationHistorySize: Int = 0 + @Published var displayedConversationUnreadMessagesCount: Int = 0 + @Published var messageText: String = "" private var chatRoomSuscriptions = Set() @Published var conversationMessagesList: [LinphoneCustomEventLog] = [] + @Published var conversationMessagesSection: [MessagesSection] = [] + @Published var conversationMessagesIds: [String] = [] init() {} @@ -66,26 +70,60 @@ class ConversationViewModel: ObservableObject { } } + func getUnreadMessagesCount() { + coreContext.doOnCoreQueue { _ in + if self.displayedConversation != nil { + let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount + DispatchQueue.main.async { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } + } + } + } + + func markAsRead() { + coreContext.doOnCoreQueue { _ in + self.displayedConversation!.chatRoom.markAsRead() + let unreadMessagesCount = self.displayedConversation!.chatRoom.unreadMessagesCount + DispatchQueue.main.async { + self.displayedConversationUnreadMessagesCount = unreadMessagesCount + } + } + } + func getMessages() { self.getHistorySize() + self.getUnreadMessagesCount() coreContext.doOnCoreQueue { _ in if self.displayedConversation != nil { let historyEvents = self.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: self.conversationMessagesList.count, end: self.conversationMessagesList.count + 30) //For List /* - historyEvents.reversed().forEach { eventLog in - DispatchQueue.main.async { - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) - } - } + historyEvents.reversed().forEach { eventLog in + DispatchQueue.main.async { + self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + } + } */ //For ScrollView - historyEvents.forEach { eventLog in + var conversationMessage: [Message] = [] + historyEvents.enumerated().forEach { index, eventLog in DispatchQueue.main.async { self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) } + conversationMessage.append(Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "")) + + DispatchQueue.main.async { + if index == historyEvents.count - 1 { + self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessage.reversed())) + self.conversationMessagesIds.append(UUID().uuidString) + } + } } } } @@ -103,38 +141,83 @@ class ConversationViewModel: ObservableObject { self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) } } - */ + */ //For ScrollView var conversationMessagesListTmp: [LinphoneCustomEventLog] = [] + var conversationMessagesTmp: [Message] = [] historyEvents.reversed().forEach { eventLog in conversationMessagesListTmp.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + + conversationMessagesTmp.insert( + Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "" + ), at: 0 + ) } - DispatchQueue.main.async { - self.conversationMessagesList.insert(contentsOf: conversationMessagesListTmp, at: 0) + if !conversationMessagesTmp.isEmpty { + DispatchQueue.main.async { + self.conversationMessagesList.insert(contentsOf: conversationMessagesListTmp, at: 0) + //self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: conversationMessagesTmp.reversed())) + //self.conversationMessagesIds.append(UUID().uuidString) + self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) + } } } } } func getNewMessages(eventLogs: [EventLog]) { - eventLogs.forEach { eventLog in + var conversationMessage: [Message] = [] + eventLogs.enumerated().forEach { index, eventLog in DispatchQueue.main.async { - withAnimation { - //For List - //self.conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) - - //For ScrollView - self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + //withAnimation { + //For List + //self.conversationMessagesList.insert(LinphoneCustomEventLog(eventLog: eventLog), at: 0) + + //For ScrollView + self.conversationMessagesList.append(LinphoneCustomEventLog(eventLog: eventLog)) + + /* + conversationMessage.append(Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "" + ) + ) + */ + } + let message = Message( + id: UUID().uuidString, + isOutgoing: eventLog.chatMessage?.isOutgoing ?? false, + text: eventLog.chatMessage?.utf8Text ?? "" + ) + + DispatchQueue.main.async { + if self.conversationMessagesSection.isEmpty { + self.conversationMessagesSection.append(MessagesSection(date: Date(), rows: [message])) + } else { + self.conversationMessagesSection[0].rows.insert(message, at: 0) } + + if !message.isOutgoing { + self.displayedConversationUnreadMessagesCount += 1 + } + } + + if self.displayedConversation != nil { + self.displayedConversation!.markAsRead() } } } func resetMessage() { conversationMessagesList = [] + conversationMessagesSection = [] } func sendMessage() { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift index 41553ff9b..6da0a683a 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationsListViewModel.swift @@ -84,7 +84,7 @@ class ConversationsListViewModel: ObservableObject { if !self.conversationsList.isEmpty { for (index, element) in conversationsListTmp.enumerated() { - if index > 0 && element.id != self.conversationsList[index].id { + if index > 0 && index < self.conversationsList.count && element.id != self.conversationsList[index].id { DispatchQueue.main.async { self.conversationsList[index] = element }