Test Table view for messages list

This commit is contained in:
Benoit Martins 2024-03-05 14:40:14 +01:00
parent d8d867d798
commit 73d6f805d3
11 changed files with 1188 additions and 243 deletions

View file

@ -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 = "<group>"; };
D726E43E2B19E56F0083C415 /* StartCallViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartCallViewModel.swift; sourceTree = "<group>"; };
D72992382ADD7F68003AF125 /* HistoryContactFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryContactFragment.swift; sourceTree = "<group>"; };
D72A9A042B9750A1000DC093 /* UIList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIList.swift; sourceTree = "<group>"; };
D732A9082AFD235500DB42BA /* ShareSheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheetController.swift; sourceTree = "<group>"; };
D732A90A2B0376F500DB42BA /* linphonerc-default */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "linphonerc-default"; sourceTree = "<group>"; };
D732A90B2B0376F500DB42BA /* linphonerc-factory */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "linphonerc-factory"; sourceTree = "<group>"; };
@ -205,6 +209,8 @@
D7D24D122AC1B4E800C6F35B /* NotoSans-ExtraBold.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "NotoSans-ExtraBold.ttf"; sourceTree = "<group>"; };
D7DA67612ACCB2FA00E95002 /* LoginFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFragment.swift; sourceTree = "<group>"; };
D7DA67632ACCB31700E95002 /* ProfileModeFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileModeFragment.swift; sourceTree = "<group>"; };
D7E6ADF22B9875C20009A2BC /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = "<group>"; };
D7E6ADF42B9876ED0009A2BC /* Attachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Attachment.swift; sourceTree = "<group>"; };
D7E6D0482AE933AD00A57AAF /* FavoriteContactsListFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListFragment.swift; sourceTree = "<group>"; };
D7E6D04A2AE9347D00A57AAF /* FavoriteContactsListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteContactsListViewModel.swift; sourceTree = "<group>"; };
D7E6D04C2AEBD77600A57AAF /* CustomBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomBottomSheet.swift; sourceTree = "<group>"; };
@ -258,6 +264,8 @@
isa = PBXGroup;
children = (
D70959F02B8DF3EC0014AC0B /* ConversationModel.swift */,
D7E6ADF22B9875C20009A2BC /* Message.swift */,
D7E6ADF42B9876ED0009A2BC /* Attachment.swift */,
);
path = Model;
sourceTree = "<group>";
@ -576,6 +584,7 @@
D70A26ED2B7CF60B006CC8FC /* ConversationsListBottomSheet.swift */,
D70A26F12B7F5D95006CC8FC /* ConversationFragment.swift */,
D71968912B86369D00DF4459 /* ChatBubbleView.swift */,
D72A9A042B9750A1000DC093 /* UIList.swift */,
);
path = Fragments;
sourceTree = "<group>";
@ -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 */,

View file

@ -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)
}
*/

View file

@ -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, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, index: index)
.id(conversationViewModel.conversationMessagesList[index])
.scaleEffect(x: 1, y: -1, anchor: .center)
.listRowInsets(EdgeInsets(top: 2, leading: 10, bottom: 2, trailing: 10))
.listRowSeparator(.hidden)
.transition(.move(edge: .top))
}
}
.scaleEffect(x: 1, y: -1, anchor: .center)
.listStyle(.plain)
.frame(maxWidth: .infinity)
.background(.white)
.onTapGesture {
UIApplication.shared.endEditing()
}
.onDisappear {
conversationViewModel.resetMessage()
}
*/
ScrollViewReader { proxy in
List {
ForEach(0..<conversationViewModel.conversationMessagesList.count, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, index: index)
.id(conversationViewModel.conversationMessagesList[index])
.listRowInsets(EdgeInsets(top: 2, leading: 10, bottom: 2, trailing: 10))
.listRowSeparator(.hidden)
.onAppear {
if index == 0 && conversationViewModel.displayedConversationHistorySize > 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..<conversationViewModel.conversationMessagesList.count, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, index: index)
.id(conversationViewModel.conversationMessagesList[index])
}
}
.frame(minHeight: reader.size.height)
.padding(.horizontal, 10)
.padding(.bottom, 8)
.background(GeometryReader { geometry -> 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, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, message: conversationViewModel.conversationMessagesSection.first!.rows[index])
.id(conversationViewModel.conversationMessagesList[index])
.listRowInsets(EdgeInsets(top: 2, leading: 10, bottom: 2, trailing: 10))
.listRowSeparator(.hidden)
.onAppear {
if index == 0 && conversationViewModel.displayedConversationHistorySize > 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.conversationMessagesList.count, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, index: index)
.id(conversationViewModel.conversationMessagesList[index])
}
}
}
.onTapGesture {
UIApplication.shared.endEditing()
}
.onAppear {
conversationViewModel.getMessages()
}
.onChange(of: conversationViewModel.conversationMessagesList) { _ in
withAnimation {
proxy.scrollTo(conversationViewModel.conversationMessagesList.last, anchor: .top)
}
}
.onDisappear {
conversationViewModel.resetMessage()
}
.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 {
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..<conversationViewModel.conversationMessagesList.count, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, index: index)
.id(conversationViewModel.conversationMessagesList[index])
}
}
.frame(maxWidth: .infinity)
.background(.white)
.onTapGesture {
UIApplication.shared.endEditing()
}
.onAppear {
conversationViewModel.getMessage()
}
.onDisappear {
conversationViewModel.resetMessage()
}
}
.defaultScrollAnchor(.bottom)
} else {
ScrollView {
LazyVStack {
ForEach(0..<conversationViewModel.conversationMessagesList.count, id: \.self) { index in
ChatBubbleView(conversationViewModel: conversationViewModel, index: index)
.id(conversationViewModel.conversationMessagesList[index])
}
}
.frame(maxWidth: .infinity)
.background(.white)
.onTapGesture {
UIApplication.shared.endEditing()
}
.onAppear {
conversationViewModel.getMessage()
if conversationViewModel.conversationMessagesList.last != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
proxy.scrollTo(conversationViewModel.conversationMessagesList.last!, anchor: .bottom)
}
}
}
.onDisappear {
conversationViewModel.resetMessage()
}
}
}
}
*/
HStack(spacing: 0) {
Button {
@ -384,7 +275,7 @@ struct ConversationFragment: View {
.padding(.top, 4)
}
.padding(.horizontal, isMessageTextFocused ? 0 : 2)
Button {
} label: {
Image("camera")
@ -398,36 +289,35 @@ struct ConversationFragment: View {
.padding(.horizontal, isMessageTextFocused ? 0 : 2)
HStack {
if #available(iOS 16.0, *) {
if #available(iOS 16.0, *) {
TextField("Say something...", text: $conversationViewModel.messageText, axis: .vertical)
.default_text_style(styleSize: 15)
.focused($isMessageTextFocused)
.padding(.vertical, 5)
} else {
ZStack(alignment: .leading) {
TextEditor(text: $conversationViewModel.messageText)
.multilineTextAlignment(.leading)
.frame(maxHeight: 160)
.fixedSize(horizontal: false, vertical: true)
.default_text_style(styleSize: 15)
.focused($isMessageTextFocused)
if conversationViewModel.messageText.isEmpty {
Text("Say something...")
.padding(.leading, 4)
.opacity(conversationViewModel.messageText.isEmpty ? 1 : 0)
.foregroundStyle(Color.gray300)
.default_text_style(styleSize: 15)
}
}
.onTapGesture {
isMessageTextFocused = true
}
}
} else {
ZStack(alignment: .leading) {
TextEditor(text: $conversationViewModel.messageText)
.multilineTextAlignment(.leading)
.frame(maxHeight: 160)
.fixedSize(horizontal: false, vertical: true)
.default_text_style(styleSize: 15)
.focused($isMessageTextFocused)
if conversationViewModel.messageText.isEmpty {
Text("Say something...")
.padding(.leading, 4)
.opacity(conversationViewModel.messageText.isEmpty ? 1 : 0)
.foregroundStyle(Color.gray300)
.default_text_style(styleSize: 15)
}
}
.onTapGesture {
isMessageTextFocused = true
}
}
if conversationViewModel.messageText.isEmpty {
Button {
conversationViewModel.getOldMessages()
} label: {
Image("microphone")
.renderingMode(.template)
@ -439,6 +329,7 @@ struct ConversationFragment: View {
}
} else {
Button {
NotificationCenter.default.post(name: .onScrollToBottom, object: nil)
conversationViewModel.sendMessage()
} label: {
Image("paper-plane-tilt")
@ -488,6 +379,18 @@ struct ConversationFragment: View {
}
.navigationViewStyle(.stack)
}
@ViewBuilder
var list: some View {
UIList(viewModel: viewModel,
paginationState: paginationState,
conversationViewModel: conversationViewModel,
isScrolledToBottom: $isScrolledToBottom,
showMessageMenuOnLongPress: showMessageMenuOnLongPress,
sections: conversationViewModel.conversationMessagesSection,
ids: conversationViewModel.conversationMessagesIds
)
}
}
extension UIApplication {
@ -495,7 +398,9 @@ extension UIApplication {
sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
/*
#Preview {
ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel())
ConversationFragment(conversationViewModel: ConversationViewModel(), conversationsListViewModel: ConversationsListViewModel(), sections: [MessagesSection], ids: [""])
}
*/

View file

@ -134,7 +134,14 @@ struct ConversationsListFragment: View {
.background(.white)
.onTapGesture {
withAnimation {
conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index])
if conversationViewModel.displayedConversation != nil {
conversationViewModel.displayedConversation = nil
conversationViewModel.resetMessage()
conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index])
conversationViewModel.getMessages()
} else {
conversationViewModel.changeDisplayedChatRoom(conversationModel: conversationsListViewModel.conversationsList[index])
}
conversationsListViewModel.conversationsList[index].markAsRead()
conversationsListViewModel.updateUnreadMessagesCount()
}

View file

@ -0,0 +1,30 @@
/*
* 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
struct MessageMenu: View {
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
}
}
#Preview {
MessageMenu()
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
// 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..<oldRows.count {
let oldRow = oldRows[row]
let newRow = newRows[row]
if oldRow.id != newRow.id { // a swap: rows in same position are not actually the same rows
if let index = newRows.firstIndex(where: { $0.id == oldRow.id }) {
if !swapsContain(swaps: swapOperations, section: oldIndex, index: row) ||
!swapsContain(swaps: swapOperations, section: oldIndex, index: index) {
swapOperations.append(.swap(oldIndex, row, index))
}
}
} else if oldRow != newRow { // same ids om same positions but something changed - reload rows without animation
editOperations.append(.edit(oldIndex, row))
}
}
// 4 store row changes in sections
appliedDeletes[oldIndex].rows = oldRows
appliedDeletesSwapsAndEdits[newIndex].rows = newRows
}
return (appliedDeletes, appliedDeletesSwapsAndEdits, deleteOperations, swapOperations, editOperations, insertOperations)
}
func swapsContain(swaps: [Operation], section: Int, index: Int) -> 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<Bool>, isScrolledToTop: Binding<Bool>, 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<Attachment> = 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

View file

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

View file

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

View file

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

View file

@ -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<AnyCancellable?>()
@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() {

View file

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