From ac5a23bfff81d73f6308091263df3071c3769747 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Thu, 8 Jan 2026 17:42:03 +0100 Subject: [PATCH 1/4] Add message search feature --- Linphone/GeneratedGit.swift | 4 +- .../Localizable/en.lproj/Localizable.strings | 7 +- .../Localizable/fr.lproj/Localizable.strings | 7 +- .../Fragments/ChatBubbleView.swift | 72 +++- .../Fragments/ConversationFragment.swift | 308 ++++++++----- .../ViewModel/ConversationViewModel.swift | 407 +++++++++++++++++- Linphone/UI/Main/Fragments/ToastView.swift | 23 +- 7 files changed, 681 insertions(+), 147 deletions(-) diff --git a/Linphone/GeneratedGit.swift b/Linphone/GeneratedGit.swift index ec24c7abb..65f061152 100644 --- a/Linphone/GeneratedGit.swift +++ b/Linphone/GeneratedGit.swift @@ -1,7 +1,7 @@ import Foundation public enum AppGitInfo { - public static let branch = "master" - public static let commit = "990d2f36a" + public static let branch = "feature/search_chat_message" + public static let commit = "50b9c69b6" public static let tag = "6.1.0-alpha" } diff --git a/Linphone/Localizable/en.lproj/Localizable.strings b/Linphone/Localizable/en.lproj/Localizable.strings index 6c9dae79b..82a102781 100644 --- a/Linphone/Localizable/en.lproj/Localizable.strings +++ b/Linphone/Localizable/en.lproj/Localizable.strings @@ -251,8 +251,11 @@ "conversation_info_participant_is_admin_label" = "Admin"; "conversation_info_participants_list_title" = "Group members (%d)"; "conversation_invalid_participant_due_to_security_mode_toast" = "Can't create conversation with a participant not on the same domain due to security restrictions!"; -"conversation_menu_configure_ephemeral_messages" = "Ephemeral messages"; +"conversation_menu_search_in_messages" = "Search"; "conversation_menu_go_to_info" = "Conversation info"; +"conversation_menu_configure_ephemeral_messages" = "Ephemeral messages"; +"conversation_menu_media_files" = "Media"; +"conversation_menu_documents_files" = "Documents"; "conversation_message_forward_cancelled_toast" = "Message forward was cancelled"; "conversation_message_forwarded_toast" = "Message was forwarded"; "conversation_message_meeting_cancelled_label" = "Meeting has been cancelled!"; @@ -261,6 +264,8 @@ "conversation_participants_list_empty" = "No participants found"; "conversation_participants_list_header" = "Participants"; "conversation_reply_to_message_title" = "Replying to: "; +"conversation_search_no_match_found" = "No matching result found"; +"conversation_search_results_limit_reached_label" = "Search results limit reached, refine your search"; "conversation_text_field_hint" = "Say something…"; "conversations_list_empty" = "No conversation for the moment…"; "conversation_take_picture_label" = "Take picture"; diff --git a/Linphone/Localizable/fr.lproj/Localizable.strings b/Linphone/Localizable/fr.lproj/Localizable.strings index 19e9a0803..fec3bcd12 100644 --- a/Linphone/Localizable/fr.lproj/Localizable.strings +++ b/Linphone/Localizable/fr.lproj/Localizable.strings @@ -251,8 +251,11 @@ "conversation_info_participant_is_admin_label" = "Administrateur"; "conversation_info_participants_list_title" = "Participants (%d)"; "conversation_invalid_participant_due_to_security_mode_toast" = "Pour des raisons de sécurité, la création d'une conversation avec un participant d'un domaine tiers est désactivé."; -"conversation_menu_configure_ephemeral_messages" = "Messages éphémères"; +"conversation_menu_search_in_messages" = "Chercher"; "conversation_menu_go_to_info" = "Informations"; +"conversation_menu_configure_ephemeral_messages" = "Messages éphémères"; +"conversation_menu_media_files" = "Médias"; +"conversation_menu_documents_files" = "Documents"; "conversation_message_forward_cancelled_toast" = "Transfert annulé"; "conversation_message_forwarded_toast" = "Message transféré"; "conversation_message_meeting_cancelled_label" = "La réunion a été annulée"; @@ -261,6 +264,8 @@ "conversation_participants_list_empty" = "Aucun participant trouvé"; "conversation_participants_list_header" = "Participants"; "conversation_reply_to_message_title" = "En réponse à : "; +"conversation_search_no_match_found" = "Aucun résultat trouvé"; +"conversation_search_results_limit_reached_label" = "Nombre maximal de résultats atteint, affinez votre recherche"; "conversation_text_field_hint" = "Dites quelque chose…"; "conversations_list_empty" = "Aucune conversation pour le moment…"; "conversation_take_picture_label" = "Prendre une photo"; diff --git a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift index 9954be02b..60d56d0b7 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ChatBubbleView.swift @@ -151,7 +151,8 @@ struct ChatBubbleView: View { .clipShape(RoundedRectangle(cornerRadius: 1)) .roundedCorner( 16, - corners: eventLogMessage.message.isOutgoing ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight] + corners: eventLogMessage.message.isOutgoing ? [.topLeft, .topRight, .bottomLeft] : [.topLeft, .topRight, .bottomRight], + stroke: eventLogMessage.message.id == conversationViewModel.highlightedMessageID ) } .onTapGesture { @@ -179,7 +180,12 @@ struct ChatBubbleView: View { } if !eventLogMessage.message.text.isEmpty { - DynamicLinkText(text: eventLogMessage.message.text, participantConversationModel: conversationViewModel.participantConversationModel) + DynamicLinkText( + text: eventLogMessage.message.text, + isMessageId: eventLogMessage.message.id == conversationViewModel.highlightedMessageID, + searchText: conversationViewModel.searchText, + participantConversationModel: conversationViewModel.participantConversationModel + ) } else if eventLogMessage.message.isRetracted { Text(eventLogMessage.message.isOutgoing ? "conversation_message_content_deleted_by_us_label" : "conversation_message_content_deleted_label") .italic() @@ -415,7 +421,9 @@ struct ChatBubbleView: View { .roundedCorner( 16, corners: eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topLeft, .topRight, .bottomLeft] : - (!eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners])) + (!eventLogMessage.message.isOutgoing && eventLogMessage.message.isFirstMessage ? [.topRight, .bottomRight, .bottomLeft] : [.allCorners]), + stroke: eventLogMessage.message.id == conversationViewModel.highlightedMessageID + ) if !eventLogMessage.message.reactions.isEmpty { HStack { @@ -946,6 +954,8 @@ struct ChatBubbleView: View { struct DynamicLinkText: View { let text: String + let isMessageId: Bool + let searchText: String let participantConversationModel: [ContactAvatarModel] var body: some View { @@ -956,6 +966,8 @@ struct DynamicLinkText: View { .default_text_style(styleSize: 14) } + // MARK: - Builder + private func makeAttributedString(from text: String) -> AttributedString { var result = AttributedString() var currentWord = "" @@ -971,9 +983,14 @@ struct DynamicLinkText: View { } appendWord(currentWord, to: &result) + + highlightSearch(in: &result, originalText: text) + return result } + // MARK: - Word handling + private func appendWord(_ word: String, to result: inout AttributedString) { guard !word.isEmpty else { return } @@ -981,7 +998,7 @@ struct DynamicLinkText: View { if let encoded = word.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), let url = URL(string: encoded), - ["http", "https"].contains(url.scheme) + ["http", "https", "sip", "sips"].contains(url.scheme) { var link = AttributedString(word) link.link = url @@ -993,13 +1010,15 @@ struct DynamicLinkText: View { // Mention if isMention(word), - let participant = participantConversationModel.first(where: {($0.address.dropFirst(4).split(separator: "@").first ?? "") == word.dropFirst()}), + let participant = participantConversationModel.first( + where: { ($0.address.dropFirst(4).split(separator: "@").first ?? "") == word.dropFirst() } + ), let mentionURL = URL(string: "linphone-mention://\(participant.address)") { var mention = AttributedString("@" + participant.name) mention.link = mentionURL mention.foregroundColor = Color.orangeMain500 - mention.font = .system(size: 14, weight: .semibold) + mention.font = .system(size: 14) result.append(mention) return } @@ -1010,6 +1029,40 @@ struct DynamicLinkText: View { result.append(normal) } + // MARK: - Highlight global + + private func highlightSearch( + in attributed: inout AttributedString, + originalText: String + ) { + guard !searchText.isEmpty && isMessageId else { return } + + let base = originalText.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + + let search = searchText.folding( + options: [.caseInsensitive, .diacriticInsensitive], + locale: .current + ) + + var searchRange = base.startIndex.. Bool { guard word.first == "@", word.count > 1 else { return false } @@ -1020,7 +1073,6 @@ struct DynamicLinkText: View { } } - enum URLType { case name(String) // local file name of gif case url(URL) // remote url @@ -1096,8 +1148,12 @@ struct RoundedCorner: Shape { } extension View { - func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View { + func roundedCorner(_ radius: CGFloat, corners: UIRectCorner, stroke: Bool? = false) -> some View { clipShape(RoundedCorner(radius: radius, corners: corners) ) + .overlay( + RoundedCorner(radius: radius, corners: corners) + .stroke(Color.orangeMain500, lineWidth: (stroke ?? false) ? 1 : 0) + ) } } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index bfc7467e5..3b50525a4 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -41,6 +41,7 @@ struct ConversationFragment: View { @State var isMenuOpen = false @State private var isMuted: Bool = false + @FocusState var isSearchTextFocused: Bool @FocusState var isMessageTextFocused: Bool @State var offset: CGPoint = .zero @@ -81,11 +82,13 @@ struct ConversationFragment: View { @Binding var isShowConversationInfoPopup: Bool @Binding var conversationInfoPopupText: String + @State var searchText: String = "" @State var messageText: String = "" @State private var chosen: String? @State private var showPicker = false @State private var isSheetVisible = false + @State private var isSearchVisible = false @State private var isImdnOrReactionsSheetVisible = false @@ -292,122 +295,105 @@ struct ConversationFragment: View { .edgesIgnoringSafeArea(.top) .frame(height: 0) - HStack { - if (!(orientation == .landscapeLeft || orientation == .landscapeRight - || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)) || isShowConversationFragment { - Image("caret-left") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 25, height: 25, alignment: .leading) - .padding(.all, 10) - .padding(.top, 4) - .padding(.leading, -10) - .onTapGesture { - withAnimation { - if isShowConversationFragment { - isShowConversationFragment = false - } - SharedMainViewModel.shared.displayedConversation = nil - } - } - } - - Avatar(contactAvatarModel: SharedMainViewModel.shared.displayedConversation?.avatarModel ?? cachedConversation!.avatarModel, avatarSize: 50) - .padding(.top, 4) - - VStack(spacing: 1) { - Text(SharedMainViewModel.shared.displayedConversation?.subject ?? cachedConversation!.subject) - .default_text_style(styleSize: 16) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - .lineLimit(1) - - if isMuted || conversationViewModel.ephemeralTime != NSLocalizedString("conversation_ephemeral_messages_duration_disabled", comment: "") { - HStack { - if isMuted { - Image("bell-slash") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 16, height: 16, alignment: .trailing) - } - - if conversationViewModel.ephemeralTime != NSLocalizedString("conversation_ephemeral_messages_duration_disabled", comment: "") { - Image("clock-countdown") - .renderingMode(.template) - .resizable() - .foregroundStyle(Color.orangeMain500) - .frame(width: 16, height: 16, alignment: .trailing) - - Text(conversationViewModel.ephemeralTime) - .default_text_style(styleSize: 12) - .padding(.leading, -2) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - } - - Spacer() - } - } - } - .background(.white) - .onTapGesture { - withAnimation { - isShowInfoConversationFragment = true - } - } - .padding(.vertical, 10) - - Spacer() - - if !(SharedMainViewModel.shared.displayedConversation?.isReadOnly ?? cachedConversation!.isReadOnly) { - Button { - if SharedMainViewModel.shared.displayedConversation!.isGroup { - isShowStartCallGroupPopup.toggle() - } else { - SharedMainViewModel.shared.displayedConversation!.call() - } - } label: { - Image("phone") + if !isSearchVisible { + HStack { + if (!(orientation == .landscapeLeft || orientation == .landscapeRight + || UIScreen.main.bounds.size.width > UIScreen.main.bounds.size.height)) || isShowConversationFragment { + Image("caret-left") .renderingMode(.template) .resizable() - .foregroundStyle(Color.grayMain2c500) + .foregroundStyle(Color.orangeMain500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) .padding(.top, 4) + .padding(.leading, -10) + .onTapGesture { + withAnimation { + if isShowConversationFragment { + isShowConversationFragment = false + } + SharedMainViewModel.shared.displayedConversation = nil + } + } } - } - - Menu { - Button { - isMenuOpen = false + + Avatar(contactAvatarModel: SharedMainViewModel.shared.displayedConversation?.avatarModel ?? cachedConversation!.avatarModel, avatarSize: 50) + .padding(.top, 4) + + VStack(spacing: 1) { + Text(SharedMainViewModel.shared.displayedConversation?.subject ?? cachedConversation!.subject) + .default_text_style(styleSize: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .lineLimit(1) + + if isMuted || conversationViewModel.ephemeralTime != NSLocalizedString("conversation_ephemeral_messages_duration_disabled", comment: "") { + HStack { + if isMuted { + Image("bell-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 16, height: 16, alignment: .trailing) + } + + if conversationViewModel.ephemeralTime != NSLocalizedString("conversation_ephemeral_messages_duration_disabled", comment: "") { + Image("clock-countdown") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.orangeMain500) + .frame(width: 16, height: 16, alignment: .trailing) + + Text(conversationViewModel.ephemeralTime) + .default_text_style(styleSize: 12) + .padding(.leading, -2) + .frame(maxWidth: .infinity, alignment: .leading) + .lineLimit(1) + } + + Spacer() + } + } + } + .background(.white) + .onTapGesture { withAnimation { isShowInfoConversationFragment = true } - } label: { - HStack { - Text("conversation_menu_go_to_info") - Spacer() - Image("info") + } + .padding(.vertical, 10) + + Spacer() + + if !(SharedMainViewModel.shared.displayedConversation?.isReadOnly ?? cachedConversation!.isReadOnly) { + Button { + if SharedMainViewModel.shared.displayedConversation!.isGroup { + isShowStartCallGroupPopup.toggle() + } else { + SharedMainViewModel.shared.displayedConversation!.call() + } + } label: { + Image("phone") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) + .padding(.top, 4) } } - if !(SharedMainViewModel.shared.displayedConversation?.isReadOnly ?? cachedConversation!.isReadOnly) { + Menu { Button { isMenuOpen = false - SharedMainViewModel.shared.displayedConversation!.toggleMute() - isMuted = !isMuted + withAnimation { + isShowInfoConversationFragment = true + } } label: { HStack { - Text(isMuted ? "conversation_action_unmute" : "conversation_action_mute") + Text("conversation_menu_go_to_info") Spacer() - Image(isMuted ? "bell-simple" : "bell-simple-slash") + Image("info") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) @@ -419,13 +405,14 @@ struct ConversationFragment: View { Button { isMenuOpen = false withAnimation { - isShowEphemeralFragment = true + isSearchVisible = true } + isSearchTextFocused = true } label: { HStack { - Text("conversation_menu_configure_ephemeral_messages") + Text("conversation_menu_search_in_messages") Spacer() - Image("clock-countdown") + Image("magnifying-glass") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) @@ -433,29 +420,128 @@ struct ConversationFragment: View { .padding(.all, 10) } } + + if !(SharedMainViewModel.shared.displayedConversation?.isReadOnly ?? cachedConversation!.isReadOnly) { + Button { + isMenuOpen = false + SharedMainViewModel.shared.displayedConversation!.toggleMute() + isMuted = !isMuted + } label: { + HStack { + Text(isMuted ? "conversation_action_unmute" : "conversation_action_mute") + Spacer() + Image(isMuted ? "bell-simple" : "bell-simple-slash") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } + + Button { + isMenuOpen = false + withAnimation { + isShowEphemeralFragment = true + } + } label: { + HStack { + Text("conversation_menu_configure_ephemeral_messages") + Spacer() + Image("clock-countdown") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + } + } + } + } label: { + Image("dots-three-vertical") + .renderingMode(.template) + .resizable() + .foregroundStyle(Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + .onChange(of: isMuted) { _ in } + .onAppear { + isMuted = SharedMainViewModel.shared.displayedConversation!.isMuted + } } - } label: { - Image("dots-three-vertical") + .onTapGesture { + isMenuOpen = true + } + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) + } else { + HStack { + Image("caret-left") .renderingMode(.template) .resizable() .foregroundStyle(Color.grayMain2c500) .frame(width: 25, height: 25, alignment: .leading) .padding(.all, 10) .padding(.top, 4) - .onChange(of: isMuted) { _ in } - .onAppear { - isMuted = SharedMainViewModel.shared.displayedConversation!.isMuted + .padding(.leading, -10) + .onTapGesture { + searchText = "" + conversationViewModel.searchText = "" + conversationViewModel.latestMatch = nil + conversationViewModel.canSearchDown = false + conversationViewModel.highlightedMessageID = nil + withAnimation { + isSearchVisible = false + } } + + TextField("conversation_menu_search_in_messages", text: $searchText) + .default_text_style(styleSize: 15) + .focused($isSearchTextFocused) + .padding(.vertical, 5) + .submitLabel(.search) + .onSubmit { + conversationViewModel.searchChatMessage(direction: .Up, textToSearch: searchText) + } + + Button { + conversationViewModel.searchChatMessage(direction: .Up, textToSearch: searchText) + } label: { + Image("caret-up") + .renderingMode(.template) + .resizable() + .foregroundStyle(searchText.isEmpty ? Color.grayMain2c300 : Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + } + .disabled(searchText.isEmpty) + + Button { + conversationViewModel.searchChatMessage(direction: .Down, textToSearch: searchText) + } label: { + Image("caret-down") + .renderingMode(.template) + .resizable() + .foregroundStyle((searchText.isEmpty || !conversationViewModel.canSearchDown) ? Color.grayMain2c300 : Color.grayMain2c500) + .frame(width: 25, height: 25, alignment: .leading) + .padding(.all, 10) + .padding(.top, 4) + } + .disabled(searchText.isEmpty || !conversationViewModel.canSearchDown) + } - .onTapGesture { - isMenuOpen = true - } + .frame(maxWidth: .infinity) + .frame(height: 50) + .padding(.horizontal) + .padding(.bottom, 4) + .background(.white) } - .frame(maxWidth: .infinity) - .frame(height: 50) - .padding(.horizontal) - .padding(.bottom, 4) - .background(.white) if #available(iOS 16.0, *) { ZStack(alignment: .bottomTrailing) { @@ -593,7 +679,7 @@ struct ConversationFragment: View { .transition(.move(edge: .bottom)) } - if !(SharedMainViewModel.shared.displayedConversation?.isReadOnly ?? cachedConversation!.isReadOnly) { + if !(SharedMainViewModel.shared.displayedConversation?.isReadOnly ?? cachedConversation!.isReadOnly) && !isSearchVisible { if conversationViewModel.messageToReply != nil { ZStack(alignment: .top) { HStack { diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 84611e0c5..6c55db2aa 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -106,6 +106,13 @@ class ConversationViewModel: ObservableObject { @Published var isSwiping = false + @Published var searchText = "" + @Published var canSearchDown = false + @Published var searchInProgress = false + @Published var highlightedMessageID: String? + + var latestMatch: EventLogMessage? + struct SheetCategory: Identifiable { let id = UUID() let name: String @@ -120,10 +127,10 @@ class ConversationViewModel: ObservableObject { } init() { - if let chatroom = self.sharedMainViewModel.displayedConversation?.chatRoom { - self.addConversationDelegate(chatRoom: chatroom) - self.getMessages() - } + if let chatroom = self.sharedMainViewModel.displayedConversation?.chatRoom { + self.addConversationDelegate(chatRoom: chatroom) + self.getMessages() + } } func addConversationDelegate(chatRoom: ChatRoom) { @@ -300,7 +307,7 @@ class ConversationViewModel: ObservableObject { if let displayedConversation = self.sharedMainViewModel.displayedConversation { displayedConversation.getContentTextMessage(chatRoom: displayedConversation.chatRoom) } - + DispatchQueue.main.async { if indexMessage != nil { self.conversationMessagesSection[0].rows[indexMessage!].message.text = "" @@ -444,7 +451,7 @@ class ConversationViewModel: ObservableObject { Log.error("[ConversationViewModel] Invalid contentIndex") return } - + self.conversationMessagesSection[0].rows[indexMessage].message.attachments[contentIndex] = newAttachment let attachmentIndex = self.getAttachmentIndex(attachment: newAttachment) @@ -678,7 +685,7 @@ class ConversationViewModel: ObservableObject { self.getUnreadMessagesCount() self.getParticipantConversationModel() self.computeComposingLabel() - self.getEphemeralTime() + self.getEphemeralTime() if self.sharedMainViewModel.displayedConversation != nil { let historyEvents = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeEvents(begin: 0, end: 30) @@ -1713,13 +1720,13 @@ class ConversationViewModel: ObservableObject { if let eventLogMessage = conversationMessagesTmp.last { DispatchQueue.main.async { - Log.info("[ConversationViewModel] Send first message") - if self.conversationMessagesSection.isEmpty && self.sharedMainViewModel.displayedConversation != nil { - self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.sharedMainViewModel.displayedConversation!.id, rows: conversationMessagesTmp)) - } else { - self.conversationMessagesSection[0].rows.append(eventLogMessage) - } - } + Log.info("[ConversationViewModel] Send first message") + if self.conversationMessagesSection.isEmpty && self.sharedMainViewModel.displayedConversation != nil { + self.conversationMessagesSection.append(MessagesSection(date: Date(), chatRoomID: self.sharedMainViewModel.displayedConversation!.id, rows: conversationMessagesTmp)) + } else { + self.conversationMessagesSection[0].rows.append(eventLogMessage) + } + } } getHistorySize() @@ -1762,18 +1769,18 @@ class ConversationViewModel: ObservableObject { conversationMessagesSection = [] } - func replyToMessage(index: Int, isMessageTextFocused: Binding) { + func replyToMessage(index: Int, isMessageTextFocused: Binding) { if self.messageToEdit != nil { self.messageToEdit = nil } coreContext.doOnCoreQueue { _ in let messageToReplyTmp = self.conversationMessagesSection[0].rows[index] - DispatchQueue.main.async { - withAnimation(.linear(duration: 0.15)) { - self.messageToReply = messageToReplyTmp - } - isMessageTextFocused.wrappedValue = true - } + DispatchQueue.main.async { + withAnimation(.linear(duration: 0.15)) { + self.messageToReply = messageToReplyTmp + } + isMessageTextFocused.wrappedValue = true + } } } @@ -1889,7 +1896,7 @@ class ConversationViewModel: ObservableObject { } else { if content.type != "video" { let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") - let path = URL(string: self.getNewFilePath(name: filePathSep[1])) + let path = URL(string: self.getNewFilePath(name: filePathSep[1])) var typeTmp: AttachmentType = .other switch content.type { @@ -1928,7 +1935,7 @@ class ConversationViewModel: ObservableObject { } } else if content.type == "video" { let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") - let path = URL(string: self.getNewFilePath(name: filePathSep[1])) + let path = URL(string: self.getNewFilePath(name: filePathSep[1])) let pathThumbnail = URL(string: self.generateThumbnail(name: filePathSep[1])) if path != nil && pathThumbnail != nil { @@ -2964,6 +2971,360 @@ class ConversationViewModel: ObservableObject { } } } + + func searchChatMessage(direction: SearchDirection, textToSearch: String) { + if let displayedConversation = self.sharedMainViewModel.displayedConversation { + searchInProgress = true + + if let match = displayedConversation.chatRoom.searchChatMessageByText(text: textToSearch, from: latestMatch?.eventModel.eventLog ?? nil, direction: direction) { + + Log.info("\(ConversationViewModel.TAG) Found result \(match.chatMessage?.messageId ?? "No message id") while looking up for message with text \(textToSearch) in direction \(direction) starting from message \(latestMatch?.eventModel.eventLog.chatMessage?.messageId ?? "No message id")" + ) + + if let sectionIndex = conversationMessagesSection.firstIndex(where: { + $0.chatRoomID == displayedConversation.id + }), + let rowIndex = conversationMessagesSection[sectionIndex].rows.firstIndex(where: { + $0.eventModel.eventLogId == match.chatMessage?.messageId + }) { + latestMatch = conversationMessagesSection[sectionIndex].rows[rowIndex] + + Log.info("\(ConversationViewModel.TAG) Found result is already in history, no need to load more history") + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.searchText = textToSearch + self.highlightedMessageID = match.chatMessage?.messageId + + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "onScrollToIndex"), object: nil, userInfo: ["index": rowIndex, "animated": true]) + } + print("searchChatMessageAAA 00 \(sectionIndex) \(rowIndex) \(latestMatch?.message.text ?? "No text")") + + searchInProgress = false + } else { + Log.info("\(ConversationViewModel.TAG) Found result isn't in currently loaded history, loading missing events") + loadMessagesUpTo(targetEvent: match, textToSearch: textToSearch) + print("searchChatMessageAAA 11") + } + + canSearchDown = true + } else { + Log.info("\(ConversationViewModel.TAG) No match found while looking up for message with text \(textToSearch) in direction \(direction) starting from message \(latestMatch?.eventModel.eventLog.chatMessage?.messageId ?? "No message id")" + ) + searchInProgress = false + if latestMatch == nil { + print("searchChatMessageAAA 22") + ToastViewModel.shared.toastMessage = "Failed_search_no_match_found" + ToastViewModel.shared.displayToast = true + } else { + print("searchChatMessageAAA 33") + // Scroll to last matching event anyway, user may have scrolled away + if let sectionIndex = conversationMessagesSection.firstIndex(where: { + $0.chatRoomID == displayedConversation.id + }), let latestMatchTmp = latestMatch, + let rowIndex = conversationMessagesSection[sectionIndex].rows.firstIndex(of: latestMatchTmp) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.searchText = textToSearch + self.highlightedMessageID = latestMatchTmp.message.id + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "onScrollToIndex"), object: nil, userInfo: ["index": rowIndex, "animated": true]) + } + print("searchChatMessageAAA 33Bis") + } + ToastViewModel.shared.toastMessage = "Failed_search_results_limit_reached" + ToastViewModel.shared.displayToast = true + } + } + } + } + + private func loadMessagesUpTo(targetEvent: EventLog, textToSearch: String) { + if self.conversationMessagesSection[0].rows.last != nil { + let firstEventLog = self.sharedMainViewModel.displayedConversation?.chatRoom.getHistoryRangeEvents( + begin: self.conversationMessagesSection[0].rows.count - 1, + end: self.conversationMessagesSection[0].rows.count + ) + + if let chatMessageTmp = targetEvent.chatMessage { + let lastEventLog = self.sharedMainViewModel.displayedConversation!.chatRoom.findEventLog(messageId: chatMessageTmp.messageId) + + var historyEvents = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeBetween( + firstEvent: firstEventLog!.first, + lastEvent: lastEventLog, + filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue) + ) + + let historyEventsAfter = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeEvents( + begin: self.conversationMessagesSection[0].rows.count + historyEvents.count + 1, + end: self.conversationMessagesSection[0].rows.count + historyEvents.count + 30 + ) + + if lastEventLog != nil { + historyEvents.insert(lastEventLog!, at: 0) + } + + historyEvents.insert(contentsOf: historyEventsAfter, at: 0) + + var conversationMessagesTmp: [EventLogMessage] = [] + + historyEvents.enumerated().reversed().forEach { index, eventLog in + var attachmentNameList: String = "" + var attachmentList: [Attachment] = [] + var contentText = "" + + guard let chatMessage = eventLog.chatMessage else { + conversationMessagesTmp.insert( + EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + isEditable: false, + isRetractable: false, + isEdited: false, + isRetracted: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) + ), at: 0 + ) + return + } + + if !chatMessage.contents.isEmpty { + chatMessage.contents.forEach { content in + if content.isText && content.name == nil { + contentText = content.utf8Text ?? "" + } else if content.name != nil && !content.name!.isEmpty { + if content.filePath == nil || content.filePath!.isEmpty { + // self.downloadContent(chatMessage: chatMessage, content: content) + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: .fileTransfer, + size: content.fileSize, + transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } + } else { + if content.type != "video" { + let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") + let path = URL(string: self.getNewFilePath(name: filePathSep[1])) + var typeTmp: AttachmentType = .other + + switch content.type { + case "image": + typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + case "audio": + typeTmp = content.isVoiceRecording ? .voiceRecording : .audio + case "application": + typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other + case "text": + typeTmp = .text + default: + typeTmp = .other + } + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, + size: content.fileSize, + transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + if typeTmp != .voiceRecording { + DispatchQueue.main.async { + if !attachment.full.pathExtension.isEmpty { + self.attachments.append(attachment) + } + } + } + } + } else if content.type == "video" { + let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") + let path = URL(string: self.getNewFilePath(name: filePathSep[1])) + let pathThumbnail = URL(string: self.generateThumbnail(name: filePathSep[1])) + + if path != nil && pathThumbnail != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + thumbnail: pathThumbnail!, + full: path!, + type: .video, + size: content.fileSize, + transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + DispatchQueue.main.async { + if !attachment.full.pathExtension.isEmpty { + self.attachments.append(attachment) + } + } + } + } + } + } + } + } + + let addressPrecCleaned = index > 0 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() + addressPrecCleaned?.clean() + + let addressNextCleaned = index <= historyEvents.count - 2 ? historyEvents[index + 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() + addressNextCleaned?.clean() + + let addressCleaned = chatMessage.fromAddress?.clone() + addressCleaned?.clean() + + if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressCleaned!) + } + + let isFirstMessageIncomingTmp = index > 0 ? addressPrecCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true + let isFirstMessageOutgoingTmp = index <= historyEvents.count - 2 ? addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true + + let isFirstMessageTmp = chatMessage.isOutgoing ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + + var statusTmp: Message.Status? = .sending + switch chatMessage.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + case .NotDelivered: + statusTmp = .error + default: + statusTmp = .sending + } + + var reactionsTmp: [String] = [] + chatMessage.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + + var replyMessageTmp: ReplyMessage? + if chatMessage.replyMessage != nil { + let addressReplyCleaned = chatMessage.replyMessage?.fromAddress?.clone() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) + } + + let contentReplyText = chatMessage.replyMessage?.utf8Text ?? "" + + let isReplyRetracted = chatMessage.replyMessage?.isRetracted ?? false + + var attachmentNameReplyList: String = "" + + chatMessage.replyMessage?.contents.forEach { content in + if !content.isText { + attachmentNameReplyList += ", \(content.name!)" + } + } + + if !attachmentNameReplyList.isEmpty { + attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) + } + + replyMessageTmp = ReplyMessage( + id: chatMessage.replyMessage!.messageId, + address: addressReplyCleaned?.asStringUriOnly() ?? "", + isFirstMessage: false, + text: contentReplyText, + isOutgoing: chatMessage.replyMessage!.isOutgoing, + isEditable: false, + isRetractable: false, + isEdited: false, + isRetracted: isReplyRetracted, + dateReceived: 0, + attachmentsNames: attachmentNameReplyList, + attachments: [] + ) + } + + conversationMessagesTmp.insert( + EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: !chatMessage.messageId.isEmpty ? chatMessage.messageId : UUID().uuidString, + status: statusTmp, + isOutgoing: chatMessage.isOutgoing, + isEditable: chatMessage.isOutgoing ? chatMessage.isEditable : false, + isRetractable: chatMessage.isOutgoing ? chatMessage.isRetractable : false, + isEdited: chatMessage.isEdited, + isRetracted: chatMessage.isRetracted, + dateReceived: chatMessage.time, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachmentsNames: attachmentNameList, + attachments: attachmentList, + replyMessage: replyMessageTmp, + isForward: chatMessage.isForward, + ownReaction: chatMessage.ownReaction?.body ?? "", + reactions: reactionsTmp, + isEphemeral: chatMessage.isEphemeral, + ephemeralExpireTime: chatMessage.ephemeralExpireTime, + ephemeralLifetime: chatMessage.ephemeralLifetime, + isIcalendar: chatMessage.contents.first?.isIcalendar ?? false, + messageConferenceInfo: chatMessage.contents.first != nil && chatMessage.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: chatMessage.contents.first!) : nil + ) + ), at: 0 + ) + + self.addChatMessageDelegate(message: chatMessage) + } + + if !conversationMessagesTmp.isEmpty { + DispatchQueue.main.async { + if self.conversationMessagesSection[0].rows.last?.message.address == conversationMessagesTmp.last?.message.address { + self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].message.isFirstMessage = false + } + self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) + + self.searchText = textToSearch + self.highlightedMessageID = targetEvent.chatMessage?.messageId + self.latestMatch = self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - historyEventsAfter.count - 1] + + NotificationCenter.default.post( + name: NSNotification.Name(rawValue: "onScrollToIndex"), + object: nil, + userInfo: ["index": self.conversationMessagesSection[0].rows.count - historyEventsAfter.count - 1, "animated": true] + ) + } + } + } + } + } } // swiftlint:enable line_length // swiftlint:enable type_body_length diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index 6a063eff7..f5833c143 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -27,7 +27,13 @@ struct ToastView: View { VStack { if toastViewModel.displayToast { HStack { - if toastViewModel.toastMessage.contains("toast_call_transfer") { + if toastViewModel.toastMessage.contains("Failed_search") { + Image("magnifying-glass") + .resizable() + .renderingMode(.template) + .frame(width: 25, height: 25, alignment: .leading) + .foregroundStyle(Color.redDanger500) + } else if toastViewModel.toastMessage.contains("toast_call_transfer") { Image("phone-transfer") .resizable() .renderingMode(.template) @@ -316,6 +322,20 @@ struct ToastView: View { .default_text_style(styleSize: 15) .padding(8) + case "Failed_search_no_match_found": + Text("conversation_search_no_match_found") + .multilineTextAlignment(.center) + .foregroundStyle(Color.redDanger500) + .default_text_style(styleSize: 15) + .padding(8) + + case "Failed_search_results_limit_reached": + Text("conversation_search_results_limit_reached_label") + .multilineTextAlignment(.center) + .foregroundStyle(Color.redDanger500) + .default_text_style(styleSize: 15) + .padding(8) + case "Success_settings_contacts_carddav_sync_successful_toast": Text("settings_contacts_carddav_sync_successful_toast") .multilineTextAlignment(.center) @@ -364,6 +384,7 @@ struct ToastView: View { } } .onAppear { + print("toastMessagetoastMessage 00 \(toastViewModel.toastMessage) \(toastViewModel.displayToast)") if !toastViewModel.toastMessage.contains("is recording") { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { withAnimation { From 6b93a7ef5e417c61c2dc7a818db58fcba572d861 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 20 Jan 2026 13:32:21 +0100 Subject: [PATCH 2/4] Refactor toast system --- Linphone/Core/CoreContext.swift | 20 +++---- Linphone/GeneratedGit.swift | 2 +- Linphone/TelecomManager/TelecomManager.swift | 56 +++++++++---------- .../Viewmodel/AccountLoginViewModel.swift | 3 +- .../UI/Assistant/Viewmodel/QRScanner.swift | 15 +++-- .../Viewmodel/RegisterViewModel.swift | 9 +-- Linphone/UI/Call/CallView.swift | 3 +- .../UI/Call/ViewModel/CallViewModel.swift | 23 ++++---- .../Fragments/ContactListBottomSheet.swift | 3 +- .../ViewModel/ContactsListViewModel.swift | 12 ++-- Linphone/UI/Main/ContentView.swift | 3 +- .../Fragments/ConversationFragment.swift | 3 +- .../Model/ConversationModel.swift | 3 +- .../ConversationForwardMessageViewModel.swift | 12 ++-- .../ViewModel/ConversationViewModel.swift | 10 ++-- .../StartConversationViewModel.swift | 15 ++--- Linphone/UI/Main/Fragments/HelpView.swift | 3 +- Linphone/UI/Main/Fragments/ToastView.swift | 42 +++++--------- .../Main/Help/Fragments/DebugFragment.swift | 6 +- .../Main/Help/ViewModel/HelpViewModel.swift | 9 +-- .../Fragments/HistoryContactFragment.swift | 3 +- .../Fragments/HistoryListBottomSheet.swift | 3 +- .../ViewModel/HistoryListViewModel.swift | 12 ++-- .../ViewModel/StartCallViewModel.swift | 3 +- .../Meetings/Fragments/MeetingFragment.swift | 3 +- .../Meetings/ViewModel/MeetingViewModel.swift | 24 +++----- .../ViewModel/MeetingsListViewModel.swift | 6 +- .../Fragments/AccountProfileFragment.swift | 3 +- .../Settings/ViewModel/CardDavViewModel.swift | 9 +-- .../UI/Main/Viewmodel/ToastViewModel.swift | 34 +++++++++-- Linphone/Utils/URIHandler.swift | 3 +- 31 files changed, 156 insertions(+), 199 deletions(-) diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 2f0222d12..65a60a307 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -106,12 +106,11 @@ class CoreContext: ObservableObject { DispatchQueue.main.async { if isConnected { Log.info("Network is now satisfied") - ToastViewModel.shared.toastMessage = "Success_toast_network_connected" + ToastViewModel.shared.show("Success_toast_network_connected") } else { Log.error("Network is now \(path.status)") - ToastViewModel.shared.toastMessage = "Unavailable_network" + ToastViewModel.shared.show("Unavailable_network") } - ToastViewModel.shared.displayToast = true } self.networkStatusIsConnected = isConnected } @@ -317,14 +316,11 @@ class CoreContext: ObservableObject { Log.info("[CoreContext] Transferred call \(transferred.remoteAddress!.asStringUriOnly()) state changed \(callState)") DispatchQueue.main.async { if callState == Call.State.Connected { - ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_successful" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_toast_call_transfer_successful") } else if callState == Call.State.OutgoingProgress { - ToastViewModel.shared.toastMessage = "Success_toast_call_transfer_in_progress" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_toast_call_transfer_in_progress") } else if callState == Call.State.End || callState == Call.State.Error { - ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_toast_call_transfer_failed") } } }, onConfiguringStatus: { (_: Core, status: ConfiguringState, message: String) in @@ -345,8 +341,7 @@ class CoreContext: ObservableObject { if info.starts(with: "https") { DispatchQueue.main.async { UIPasteboard.general.setValue(info, forPasteboardType: UTType.plainText.identifier) - ToastViewModel.shared.toastMessage = "Success_send_logs" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_send_logs") } } }, onAccountRegistrationStateChanged: { (core: Core, account: Account, state: RegistrationState, message: String) in @@ -396,8 +391,7 @@ class CoreContext: ObservableObject { self.loggedIn = false if self.networkStatusIsConnected { // If network is disconnected, a toast message with key "Unavailable_network" should already be displayed - ToastViewModel.shared.toastMessage = "Registration_failed" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Registration_failed") } } diff --git a/Linphone/GeneratedGit.swift b/Linphone/GeneratedGit.swift index 65f061152..b67b705a1 100644 --- a/Linphone/GeneratedGit.swift +++ b/Linphone/GeneratedGit.swift @@ -2,6 +2,6 @@ import Foundation public enum AppGitInfo { public static let branch = "feature/search_chat_message" - public static let commit = "50b9c69b6" + public static let commit = "ac5a23bff" public static let tag = "6.1.0-alpha" } diff --git a/Linphone/TelecomManager/TelecomManager.swift b/Linphone/TelecomManager/TelecomManager.swift index 61139420f..b06bad07c 100644 --- a/Linphone/TelecomManager/TelecomManager.swift +++ b/Linphone/TelecomManager/TelecomManager.swift @@ -493,38 +493,36 @@ class TelecomManager: ObservableObject { let isRecordingByRemoteTmp = call.remoteParams?.isRecording ?? false - if isRecordingByRemoteTmp && ToastViewModel.shared.toastMessage.isEmpty { - - var displayName = "" - let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress!) - if friend != nil && friend!.address != nil && friend!.address!.displayName != nil { - displayName = friend!.address!.displayName! - } else { - if call.remoteAddress!.displayName != nil { - displayName = call.remoteAddress!.displayName! - } else if call.remoteAddress!.username != nil { - displayName = call.remoteAddress!.username! - } else { - displayName = String(call.remoteAddress!.asStringUriOnly().dropFirst(4)) - } - } - - DispatchQueue.main.async { - self.isRecordingByRemote = isRecordingByRemoteTmp - ToastViewModel.shared.toastMessage = "\(displayName) is recording" - ToastViewModel.shared.displayToast = true - } - - Log.info("[Call] Call is recording by \(call.remoteAddress!.asStringUriOnly())") + let displayName: String + let friend = ContactsManager.shared.getFriendWithAddress(address: call.remoteAddress) + + if let name = friend?.address?.displayName { + displayName = name + } else if let name = call.remoteAddress?.displayName { + displayName = name + } else if let username = call.remoteAddress?.username { + displayName = username + } else if let uri = call.remoteAddress?.asStringUriOnly() { + displayName = String(uri.dropFirst(4)) + } else { + displayName = "Unknown" } - if !isRecordingByRemoteTmp && ToastViewModel.shared.toastMessage.contains("is recording") { - DispatchQueue.main.async { - self.isRecordingByRemote = isRecordingByRemoteTmp - ToastViewModel.shared.toastMessage = "" - ToastViewModel.shared.displayToast = false + DispatchQueue.main.async { + self.isRecordingByRemote = isRecordingByRemoteTmp + + if isRecordingByRemoteTmp { + ToastViewModel.shared.show("\(displayName) is recording") + } else if let toast = ToastViewModel.shared.toast, + toast.message.contains("is recording") { + ToastViewModel.shared.hide() } - Log.info("[Call] Recording is stopped by \(call.remoteAddress!.asStringUriOnly())") + } + + if isRecordingByRemoteTmp { + Log.info("[Call] Call is recording by \(call.remoteAddress?.asStringUriOnly() ?? "")") + } else { + Log.info("[Call] Recording is stopped by \(call.remoteAddress?.asStringUriOnly() ?? "")") } if cstate == Call.State.PausedByRemote { diff --git a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift index f559918a8..bfdd46e47 100644 --- a/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/AccountLoginViewModel.swift @@ -41,8 +41,7 @@ class AccountLoginViewModel: ObservableObject { guard self.coreContext.networkStatusIsConnected else { DispatchQueue.main.async { self.coreContext.loggingInProgress = false - ToastViewModel.shared.toastMessage = "Unavailable_network" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Unavailable_network") } return } diff --git a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift index a6db12b28..3d6512860 100644 --- a/Linphone/UI/Assistant/Viewmodel/QRScanner.swift +++ b/Linphone/UI/Assistant/Viewmodel/QRScanner.swift @@ -75,15 +75,18 @@ class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate { core.stop() try? core.start() } - ToastViewModel.shared.toastMessage = "Success_qr_code_validated" - ToastViewModel.shared.displayToast = true + DispatchQueue.main.async { + ToastViewModel.shared.show("Success_qr_code_validated") + } } else { - ToastViewModel.shared.toastMessage = "Invalide URI" - ToastViewModel.shared.displayToast.toggle() + DispatchQueue.main.async { + ToastViewModel.shared.show("Invalide URI") + } } } else { - ToastViewModel.shared.toastMessage = "Invalide URI" - ToastViewModel.shared.displayToast.toggle() + DispatchQueue.main.async { + ToastViewModel.shared.show("Invalide URI") + } } } } diff --git a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift index 81154d54f..a796f0718 100644 --- a/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift +++ b/Linphone/UI/Assistant/Viewmodel/RegisterViewModel.swift @@ -168,8 +168,7 @@ class RegisterViewModel: ObservableObject { if !errorMessage.isEmpty { DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Error: \(errorMessage)" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Error: \(errorMessage)") } } @@ -341,8 +340,7 @@ class RegisterViewModel: ObservableObject { DispatchQueue.main.async { self.createInProgress = false - ToastViewModel.shared.toastMessage = "Failed_push_notification_not_received_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_push_notification_not_received_error") } } @@ -430,8 +428,7 @@ class RegisterViewModel: ObservableObject { Log.error("\(RegisterViewModel.TAG) Account manager services hasn't been initialized!") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Failed_account_register_unexpected_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_account_register_unexpected_error") } } } diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index fbe85853c..bd16c1b15 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -672,8 +672,7 @@ struct CallView: View { ) DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_address_copied_into_clipboard") } }, label: { HStack { diff --git a/Linphone/UI/Call/ViewModel/CallViewModel.swift b/Linphone/UI/Call/ViewModel/CallViewModel.swift index 9c5c97dd5..4a9cb87b7 100644 --- a/Linphone/UI/Call/ViewModel/CallViewModel.swift +++ b/Linphone/UI/Call/ViewModel/CallViewModel.swift @@ -974,8 +974,7 @@ class CallViewModel: ObservableObject { self.isNotEncrypted = false if isDeviceTrusted && withToast { - ToastViewModel.shared.toastMessage = "Info_call_securised" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Info_call_securised") } } @@ -1058,8 +1057,9 @@ class CallViewModel: ObservableObject { try callToTransferTo!.transferToAnother(dest: self.currentCall!) Log.info("[CallViewModel] Attended transfer is successful") } catch _ { - ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" - ToastViewModel.shared.displayToast = true + DispatchQueue.main.async { + ToastViewModel.shared.show("Failed_toast_call_transfer_failed") + } Log.error("[CallViewModel] Failed to make attended transfer!") } @@ -1078,9 +1078,9 @@ class CallViewModel: ObservableObject { try self.currentCall!.transferTo(referTo: toAddress) Log.info("[CallViewModel] Blind call transfer is successful") } catch _ { - ToastViewModel.shared.toastMessage = "Failed_toast_call_transfer_failed" - ToastViewModel.shared.displayToast = true - + DispatchQueue.main.async { + ToastViewModel.shared.show("Failed_toast_call_transfer_failed") + } Log.error("[CallViewModel] Failed to make blind call transfer!") } } @@ -1275,8 +1275,7 @@ class CallViewModel: ObservableObject { ) DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } } @@ -1366,8 +1365,7 @@ class CallViewModel: ObservableObject { self.chatRoomDelegate = nil DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in @@ -1401,8 +1399,7 @@ class CallViewModel: ObservableObject { self.chatRoomDelegate = nil DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }) diff --git a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift index f1e3794e6..af37f2917 100644 --- a/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift +++ b/Linphone/UI/Main/Contacts/Fragments/ContactListBottomSheet.swift @@ -66,8 +66,7 @@ struct ContactListBottomSheet: View { dismiss() } - ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.show("Success_address_copied_into_clipboard") } label: { HStack { diff --git a/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift index 56414b9f2..d94550342 100644 --- a/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift +++ b/Linphone/UI/Main/Contacts/ViewModel/ContactsListViewModel.swift @@ -91,8 +91,7 @@ class ContactsListViewModel: ObservableObject { DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } return } @@ -137,8 +136,7 @@ class ContactsListViewModel: ObservableObject { DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } } else { @@ -168,8 +166,7 @@ class ContactsListViewModel: ObservableObject { } DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in @@ -207,8 +204,7 @@ class ContactsListViewModel: ObservableObject { } DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }) diff --git a/Linphone/UI/Main/ContentView.swift b/Linphone/UI/Main/ContentView.swift index f837974f8..ddd8d984b 100644 --- a/Linphone/UI/Main/ContentView.swift +++ b/Linphone/UI/Main/ContentView.swift @@ -1198,8 +1198,7 @@ struct ContentView: View { self.isShowDeleteAllHistoryPopup.toggle() sharedMainViewModel.displayedCall = nil - ToastViewModel.shared.toastMessage = "Success_remove_call_logs" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.show("Success_remove_call_logs") }, titleThirdButton: Text("dialog_cancel"), actionThirdButton: { diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index 3b50525a4..caddb0aec 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -1393,8 +1393,7 @@ struct ConversationFragment: View { forPasteboardType: UTType.plainText.identifier ) - ToastViewModel.shared.toastMessage = "Success_message_copied_into_clipboard" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_message_copied_into_clipboard") conversationViewModel.selectedMessage = nil } label: { diff --git a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift index 584e1c7d9..fe307a8d1 100644 --- a/Linphone/UI/Main/Conversations/Model/ConversationModel.swift +++ b/Linphone/UI/Main/Conversations/Model/ConversationModel.swift @@ -240,8 +240,7 @@ class ConversationModel: ObservableObject, Identifiable { } else if state == .CreationFailed { Log.error("\(ConversationModel.TAG) Failed to create group call!") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_group_call_error") } } }) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift index 50f2c37f3..47589e575 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationForwardMessageViewModel.swift @@ -134,8 +134,7 @@ class ConversationForwardMessageViewModel: ObservableObject { DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } return } @@ -180,8 +179,7 @@ class ConversationForwardMessageViewModel: ObservableObject { DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } } else { @@ -211,8 +209,7 @@ class ConversationForwardMessageViewModel: ObservableObject { self.chatRoomDelegate = nil DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in @@ -250,8 +247,7 @@ class ConversationForwardMessageViewModel: ObservableObject { self.chatRoomDelegate = nil DispatchQueue.main.async { self.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 6c55db2aa..6c4461062 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -3013,8 +3013,9 @@ class ConversationViewModel: ObservableObject { searchInProgress = false if latestMatch == nil { print("searchChatMessageAAA 22") - ToastViewModel.shared.toastMessage = "Failed_search_no_match_found" - ToastViewModel.shared.displayToast = true + DispatchQueue.main.async { + ToastViewModel.shared.show("Failed_search_no_match_found") + } } else { print("searchChatMessageAAA 33") // Scroll to last matching event anyway, user may have scrolled away @@ -3029,8 +3030,9 @@ class ConversationViewModel: ObservableObject { } print("searchChatMessageAAA 33Bis") } - ToastViewModel.shared.toastMessage = "Failed_search_results_limit_reached" - ToastViewModel.shared.displayToast = true + DispatchQueue.main.async { + ToastViewModel.shared.show("Failed_search_results_limit_reached") + } } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift index 165d01e73..5973ca674 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/StartConversationViewModel.swift @@ -149,8 +149,7 @@ class StartConversationViewModel: ObservableObject { DispatchQueue.main.async { self.operationGroupInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } } @@ -206,8 +205,7 @@ class StartConversationViewModel: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.operationOneToOneInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } return } @@ -252,8 +250,7 @@ class StartConversationViewModel: ObservableObject { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.operationOneToOneInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } } else { @@ -284,8 +281,7 @@ class StartConversationViewModel: ObservableObject { DispatchQueue.main.async { self.operationOneToOneInProgress = false self.operationGroupInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in @@ -314,8 +310,7 @@ class StartConversationViewModel: ObservableObject { DispatchQueue.main.async { self.operationOneToOneInProgress = false self.operationGroupInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }) diff --git a/Linphone/UI/Main/Fragments/HelpView.swift b/Linphone/UI/Main/Fragments/HelpView.swift index d7932a40d..f5aa79a4d 100644 --- a/Linphone/UI/Main/Fragments/HelpView.swift +++ b/Linphone/UI/Main/Fragments/HelpView.swift @@ -32,8 +32,7 @@ class HelpView { // TODO (basic debug moved here until halp view is implemented) CoreContext.shared.doOnCoreQueue { _ in Core.resetLogCollection() DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "help_troubleshooting_debug_logs_cleaned_toast_message" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("help_troubleshooting_debug_logs_cleaned_toast_message") } } } diff --git a/Linphone/UI/Main/Fragments/ToastView.swift b/Linphone/UI/Main/Fragments/ToastView.swift index f5833c143..95fe774e3 100644 --- a/Linphone/UI/Main/Fragments/ToastView.swift +++ b/Linphone/UI/Main/Fragments/ToastView.swift @@ -25,39 +25,39 @@ struct ToastView: View { var body: some View { VStack { - if toastViewModel.displayToast { + if let toast = toastViewModel.toast { HStack { - if toastViewModel.toastMessage.contains("Failed_search") { + if toast.message.contains("Failed_search") { Image("magnifying-glass") .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) .foregroundStyle(Color.redDanger500) - } else if toastViewModel.toastMessage.contains("toast_call_transfer") { + } else if toast.message.contains("toast_call_transfer") { Image("phone-transfer") .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) - .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) - } else if toastViewModel.toastMessage.contains("is recording") { + .foregroundStyle(toast.message.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) + } else if toast.message.contains("is recording") { Image("record-fill") .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) .foregroundStyle(Color.redDanger500) - } else if toastViewModel.toastMessage.contains("Info_") { + } else if toast.message.contains("Info_") { Image("trusted") .resizable() .frame(width: 25, height: 25, alignment: .leading) } else { - Image(toastViewModel.toastMessage.contains("Success") ? "check" : "warning-circle") + Image(toast.message.contains("Success") ? "check" : "warning-circle") .resizable() .renderingMode(.template) .frame(width: 25, height: 25, alignment: .leading) - .foregroundStyle(toastViewModel.toastMessage.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) + .foregroundStyle(toast.message.contains("Success") ? Color.greenSuccess500 : Color.redDanger500) } - switch toastViewModel.toastMessage { + switch toast.message { case "Success_qr_code_validated": Text("qr_code_validated") .multilineTextAlignment(.center) @@ -122,7 +122,7 @@ struct ToastView: View { .padding(8) case let str where str.contains("is recording"): - Text(toastViewModel.toastMessage) + Text(toast.message) .multilineTextAlignment(.center) .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 15) @@ -255,7 +255,7 @@ struct ToastView: View { .padding(8) case let str where str.contains("Error: "): - Text(toastViewModel.toastMessage) + Text(toast.message) .multilineTextAlignment(.center) .foregroundStyle(Color.redDanger500) .default_text_style(styleSize: 15) @@ -371,26 +371,14 @@ struct ToastView: View { .overlay( RoundedRectangle(cornerRadius: 50) .inset(by: 0.5) - .stroke(toastViewModel.toastMessage.contains("Success") - ? Color.greenSuccess500 : (toastViewModel.toastMessage.contains("Info_") + .stroke(toast.message.contains("Success") + ? Color.greenSuccess500 : (toast.message.contains("Info_") ? Color.blueInfo500 : Color.redDanger500), lineWidth: 1) ) .onTapGesture { - if !toastViewModel.toastMessage.contains("is recording") { + if !toast.message.contains("is recording") { withAnimation { - toastViewModel.toastMessage = "" - toastViewModel.displayToast = false - } - } - } - .onAppear { - print("toastMessagetoastMessage 00 \(toastViewModel.toastMessage) \(toastViewModel.displayToast)") - if !toastViewModel.toastMessage.contains("is recording") { - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - withAnimation { - toastViewModel.toastMessage = "" - toastViewModel.displayToast = false - } + toastViewModel.hide() } } } diff --git a/Linphone/UI/Main/Help/Fragments/DebugFragment.swift b/Linphone/UI/Main/Help/Fragments/DebugFragment.swift index 15cc2141d..638f8e5ff 100644 --- a/Linphone/UI/Main/Help/Fragments/DebugFragment.swift +++ b/Linphone/UI/Main/Help/Fragments/DebugFragment.swift @@ -109,8 +109,7 @@ struct DebugFragment: View { helpViewModel.version, forPasteboardType: UTType.plainText.identifier ) - ToastViewModel.shared.toastMessage = "Success_text_copied_into_clipboard" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_text_copied_into_clipboard") } label: { HStack { Image("app-store-logo") @@ -145,8 +144,7 @@ struct DebugFragment: View { helpViewModel.sdkVersion, forPasteboardType: UTType.plainText.identifier ) - ToastViewModel.shared.toastMessage = "Success_text_copied_into_clipboard" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_text_copied_into_clipboard") } label: { HStack { Image("package") diff --git a/Linphone/UI/Main/Help/ViewModel/HelpViewModel.swift b/Linphone/UI/Main/Help/ViewModel/HelpViewModel.swift index 97ce95fbc..476e80e13 100644 --- a/Linphone/UI/Main/Help/ViewModel/HelpViewModel.swift +++ b/Linphone/UI/Main/Help/ViewModel/HelpViewModel.swift @@ -92,14 +92,12 @@ class HelpViewModel: ObservableObject { case .UpToDate: Log.info("\(self.TAG): This version is up-to-date") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_version_up_to_date" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_version_up_to_date") } default: Log.info("\(self.TAG): Can't check for update, an error happened [\(result)]") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Error") } } } @@ -142,8 +140,7 @@ class HelpViewModel: ObservableObject { Core.resetLogCollection() Log.info("\(self.TAG) Debug logs have been cleaned") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_clear_logs" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_clear_logs") } } } diff --git a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift index 055c72e34..dd85deb3a 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryContactFragment.swift @@ -120,8 +120,7 @@ struct HistoryContactFragment: View { ) } - ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.show("Success_address_copied_into_clipboard") } label: { HStack { diff --git a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift index 5b682f655..cbb88e6c4 100644 --- a/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift +++ b/Linphone/UI/Main/History/Fragments/HistoryListBottomSheet.swift @@ -152,8 +152,7 @@ struct HistoryListBottomSheet: View { dismiss() } - ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.show("Success_address_copied_into_clipboard") } label: { HStack { diff --git a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift index ceb6df8ea..e515e48a1 100644 --- a/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/HistoryListViewModel.swift @@ -280,8 +280,7 @@ class HistoryListViewModel: ObservableObject { DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } return } @@ -326,8 +325,7 @@ class HistoryListViewModel: ObservableObject { DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } } else { @@ -357,8 +355,7 @@ class HistoryListViewModel: ObservableObject { } DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }, onConferenceJoined: { (chatRoom: ChatRoom, _: EventLog) in @@ -396,8 +393,7 @@ class HistoryListViewModel: ObservableObject { } DispatchQueue.main.async { SharedMainViewModel.shared.operationInProgress = false - ToastViewModel.shared.toastMessage = "Failed_to_create_conversation_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_conversation_error") } } }) diff --git a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift index 3b69db4cf..99d9c45dc 100644 --- a/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift +++ b/Linphone/UI/Main/History/ViewModel/StartCallViewModel.swift @@ -139,8 +139,7 @@ class StartCallViewModel: ObservableObject { } else if state == .CreationFailed { Log.error("\(StartCallViewModel.TAG) Failed to create group call!") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Failed_to_create_group_call_error" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_to_create_group_call_error") self.operationInProgress = false } } diff --git a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift index 5c0de1313..fb8b9d102 100644 --- a/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift +++ b/Linphone/UI/Main/Meetings/Fragments/MeetingFragment.swift @@ -199,8 +199,7 @@ struct MeetingFragment: View { ) DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_address_copied_into_clipboard") } }, label: { HStack { diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift index 0a33b5b1e..7dd87d9d8 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingViewModel.swift @@ -201,8 +201,7 @@ class MeetingViewModel: ObservableObject { DispatchQueue.main.async { self.operationInProgress = false self.errorMsg = (SharedMainViewModel.shared.displayedMeeting != nil) ? "Could not edit conference" : "Could not create conference" - ToastViewModel.shared.toastMessage = (SharedMainViewModel.shared.displayedMeeting != nil) ? "meeting_failed_to_edit_toast" : "meeting_failed_to_schedule_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show((SharedMainViewModel.shared.displayedMeeting != nil) ? "meeting_failed_to_edit_toast" : "meeting_failed_to_schedule_toast") } } else if state == ConferenceScheduler.State.Ready { if let confInfo = scheduler.info, let conferenceAddress = confInfo.uri { @@ -210,8 +209,7 @@ class MeetingViewModel: ObservableObject { } DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Success_meeting_info_created_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_meeting_info_created_toast") } if SharedMainViewModel.shared.displayedMeeting != nil { @@ -243,8 +241,7 @@ class MeetingViewModel: ObservableObject { } else if failedInvitations.count == self.participants.count { Log.error("\(MeetingViewModel.TAG) No invitation sent!") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "meeting_failed_to_send_invites_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("meeting_failed_to_send_invites_toast") } } else { var failInvList = "" @@ -256,8 +253,7 @@ class MeetingViewModel: ObservableObject { } Log.warn("\(MeetingViewModel.TAG) \(failedInvitations.count) invitations couldn't have been sent to: \(failInvList)") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "meeting_failed_to_send_part_of_invites_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("meeting_failed_to_send_part_of_invites_toast") } } @@ -273,16 +269,14 @@ class MeetingViewModel: ObservableObject { guard !subject.isEmpty && !participants.isEmpty else { Log.error("\(MeetingViewModel.TAG) Either no subject was set or no participant was selected, can't schedule meeting.") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Failed_no_subject_or_participant" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Failed_no_subject_or_participant") } return } guard CoreContext.shared.networkStatusIsConnected else { DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "Unavailable_network" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Unavailable_network") } return } @@ -427,12 +421,10 @@ class MeetingViewModel: ObservableObject { do { try self.eventStore.save(event, span: .thisEvent) Log.info("\(MeetingViewModel.TAG) Meeting '\(self.subject)' added to calendar") - ToastViewModel.shared.toastMessage = "Meeting_added_to_calendar" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Meeting_added_to_calendar") } catch let error as NSError { Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error)") - ToastViewModel.shared.toastMessage = "Error: \(error)" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Error: \(error)") } } else { Log.error("\(MeetingViewModel.TAG) Failed to add meeting to calendar: \(error?.localizedDescription ?? "")") diff --git a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift index 56eb6d512..2ae43d0a0 100644 --- a/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift +++ b/Linphone/UI/Main/Meetings/ViewModel/MeetingsListViewModel.swift @@ -143,8 +143,7 @@ class MeetingsListViewModel: ObservableObject { // Only remaining meeting is the fake TodayMeeting, remove it too self.meetingsList.removeAll() } - ToastViewModel.shared.toastMessage = "Success_toast_meeting_deleted" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_toast_meeting_deleted") } } } @@ -180,8 +179,7 @@ class MeetingsListViewModel: ObservableObject { } Log.warn("\(MeetingViewModel.TAG) \(failedInvitations.count) invitations couldn't have been sent to: \(failInvList)") DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = "meeting_failed_to_send_part_of_invites_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("meeting_failed_to_send_part_of_invites_toast") } } }) diff --git a/Linphone/UI/Main/Settings/Fragments/AccountProfileFragment.swift b/Linphone/UI/Main/Settings/Fragments/AccountProfileFragment.swift index 90d4fbd6b..21aec010d 100644 --- a/Linphone/UI/Main/Settings/Fragments/AccountProfileFragment.swift +++ b/Linphone/UI/Main/Settings/Fragments/AccountProfileFragment.swift @@ -287,8 +287,7 @@ struct AccountProfileFragment: View { forPasteboardType: UTType.plainText.identifier ) - ToastViewModel.shared.toastMessage = "Success_address_copied_into_clipboard" - ToastViewModel.shared.displayToast.toggle() + ToastViewModel.shared.show("Success_address_copied_into_clipboard") }, label: { Image("copy") .resizable() diff --git a/Linphone/UI/Main/Settings/ViewModel/CardDavViewModel.swift b/Linphone/UI/Main/Settings/ViewModel/CardDavViewModel.swift index 5853bcb6f..e8cffdc39 100644 --- a/Linphone/UI/Main/Settings/ViewModel/CardDavViewModel.swift +++ b/Linphone/UI/Main/Settings/ViewModel/CardDavViewModel.swift @@ -128,8 +128,7 @@ class CardDavViewModel: ObservableObject { NotificationCenter.default.post(name: NSNotification.Name("ContactLoaded"), object: nil) self.cardDavServerOperationSuccessful = true - ToastViewModel.shared.toastMessage = "Success_settings_contacts_carddav_deleted_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_settings_contacts_carddav_deleted_toast") } } } @@ -227,8 +226,7 @@ class CardDavViewModel: ObservableObject { DispatchQueue.main.async { self.cardDavServerOperationInProgress = false - ToastViewModel.shared.toastMessage = "Success_settings_contacts_carddav_sync_successful_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("Success_settings_contacts_carddav_sync_successful_toast") } let name = self.displayName @@ -256,8 +254,7 @@ class CardDavViewModel: ObservableObject { DispatchQueue.main.async { self.cardDavServerOperationInProgress = false - ToastViewModel.shared.toastMessage = "settings_contacts_carddav_sync_error_toast" - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show("settings_contacts_carddav_sync_error_toast") } if !self.isEdit { Log.error("\(CardDavViewModel.TAG) Synchronization failed, removing Friend list from Core") diff --git a/Linphone/UI/Main/Viewmodel/ToastViewModel.swift b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift index b046c5a06..c62f41d92 100644 --- a/Linphone/UI/Main/Viewmodel/ToastViewModel.swift +++ b/Linphone/UI/Main/Viewmodel/ToastViewModel.swift @@ -19,13 +19,39 @@ import Foundation -class ToastViewModel: ObservableObject { +@MainActor +final class ToastViewModel: ObservableObject { static let shared = ToastViewModel() - var toastMessage: String = "" - @Published var displayToast = false + @Published var toast: ToastData? - private init() { + private var hideWorkItem: DispatchWorkItem? + + private init() {} + + func show(_ message: String, duration: TimeInterval = 2.0) { + hideWorkItem?.cancel() + + toast = ToastData(message: message) + + let workItem = DispatchWorkItem { [weak self] in + self?.toast = nil + } + hideWorkItem = workItem + + if !message.contains("is recording") { + DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem) + } + } + + func hide() { + hideWorkItem?.cancel() + toast = nil } } + +struct ToastData: Identifiable, Equatable { + let id = UUID() + let message: String +} diff --git a/Linphone/Utils/URIHandler.swift b/Linphone/Utils/URIHandler.swift index 3bc2153d0..09d6af463 100644 --- a/Linphone/Utils/URIHandler.swift +++ b/Linphone/Utils/URIHandler.swift @@ -170,8 +170,7 @@ class URIHandler { private static func toast(_ message: String) { DispatchQueue.main.async { - ToastViewModel.shared.toastMessage = message - ToastViewModel.shared.displayToast = true + ToastViewModel.shared.show(message) } } } From ceca9acc2189b33955ceaf553ce5298f76c79942 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 20 Jan 2026 16:36:41 +0100 Subject: [PATCH 3/4] Fix first message display when displaying old messages --- .../Conversations/ViewModel/ConversationViewModel.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index 6c4461062..b8d33a6e4 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -1075,7 +1075,9 @@ class ConversationViewModel: ObservableObject { let addressPrecCleaned = index > 0 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() addressPrecCleaned?.clean() - let addressNextCleaned = index <= historyEvents.count - 2 ? historyEvents[index + 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() + let addressNextCleaned = index <= historyEvents.count - 2 + ? historyEvents[index + 1].chatMessage?.fromAddress?.clone() + : self.conversationMessagesSection[0].rows.last?.eventModel.eventLog.chatMessage?.fromAddress?.clone() addressNextCleaned?.clean() let addressCleaned = chatMessage.fromAddress?.clone() @@ -1086,7 +1088,7 @@ class ConversationViewModel: ObservableObject { } let isFirstMessageIncomingTmp = index > 0 ? addressPrecCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true - let isFirstMessageOutgoingTmp = index <= historyEvents.count - 2 ? addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true + let isFirstMessageOutgoingTmp = addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() let isFirstMessageTmp = chatMessage.isOutgoing ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp From 02c0509c98495b9b5e029eb0a125e4198ac8f9d6 Mon Sep 17 00:00:00 2001 From: Benoit Martins Date: Tue, 20 Jan 2026 17:16:37 +0100 Subject: [PATCH 4/4] Add loading popup when searching messages --- Linphone/GeneratedGit.swift | 2 +- .../Fragments/ConversationFragment.swift | 16 + .../ViewModel/ConversationViewModel.swift | 620 +++++++++--------- 3 files changed, 334 insertions(+), 304 deletions(-) diff --git a/Linphone/GeneratedGit.swift b/Linphone/GeneratedGit.swift index b67b705a1..311fe7cd1 100644 --- a/Linphone/GeneratedGit.swift +++ b/Linphone/GeneratedGit.swift @@ -2,6 +2,6 @@ import Foundation public enum AppGitInfo { public static let branch = "feature/search_chat_message" - public static let commit = "ac5a23bff" + public static let commit = "4c79162b0" public static let tag = "6.1.0-alpha" } diff --git a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift index caddb0aec..d0f54e629 100644 --- a/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift +++ b/Linphone/UI/Main/Conversations/Fragments/ConversationFragment.swift @@ -1542,6 +1542,22 @@ struct ConversationFragment: View { .zIndex(5) .transition(.move(edge: .trailing)) } + + if conversationViewModel.searchInProgress { + PopupLoadingView() + .background(.black.opacity(0.65)) + .onDisappear { + if conversationViewModel.targetIndex >= 0 { + NotificationCenter.default.post( + name: NSNotification.Name("onScrollToIndex"), + object: nil, + userInfo: ["index": conversationViewModel.targetIndex, "animated": true] + ) + + conversationViewModel.targetIndex = -1 + } + } + } } } diff --git a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift index b8d33a6e4..1668e9fff 100644 --- a/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift +++ b/Linphone/UI/Main/Conversations/ViewModel/ConversationViewModel.swift @@ -107,6 +107,7 @@ class ConversationViewModel: ObservableObject { @Published var isSwiping = false @Published var searchText = "" + @Published var targetIndex: Int = -1 @Published var canSearchDown = false @Published var searchInProgress = false @Published var highlightedMessageID: String? @@ -2975,65 +2976,66 @@ class ConversationViewModel: ObservableObject { } func searchChatMessage(direction: SearchDirection, textToSearch: String) { + let textToSearch = textToSearch.trimmingCharacters(in: .whitespacesAndNewlines) if let displayedConversation = self.sharedMainViewModel.displayedConversation { - searchInProgress = true - - if let match = displayedConversation.chatRoom.searchChatMessageByText(text: textToSearch, from: latestMatch?.eventModel.eventLog ?? nil, direction: direction) { - - Log.info("\(ConversationViewModel.TAG) Found result \(match.chatMessage?.messageId ?? "No message id") while looking up for message with text \(textToSearch) in direction \(direction) starting from message \(latestMatch?.eventModel.eventLog.chatMessage?.messageId ?? "No message id")" - ) - - if let sectionIndex = conversationMessagesSection.firstIndex(where: { - $0.chatRoomID == displayedConversation.id - }), - let rowIndex = conversationMessagesSection[sectionIndex].rows.firstIndex(where: { - $0.eventModel.eventLogId == match.chatMessage?.messageId - }) { - latestMatch = conversationMessagesSection[sectionIndex].rows[rowIndex] + CoreContext.shared.doOnCoreQueue { core in + if let match = displayedConversation.chatRoom.searchChatMessageByText(text: textToSearch, from: self.latestMatch?.eventModel.eventLog ?? nil, direction: direction) { - Log.info("\(ConversationViewModel.TAG) Found result is already in history, no need to load more history") + Log.info("\(ConversationViewModel.TAG) Found result \(match.chatMessage?.messageId ?? "No message id") while looking up for message with text \(textToSearch) in direction \(direction) starting from message \(self.latestMatch?.eventModel.eventLog.chatMessage?.messageId ?? "No message id")" + ) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.searchText = textToSearch - self.highlightedMessageID = match.chatMessage?.messageId - - NotificationCenter.default.post(name: NSNotification.Name(rawValue: "onScrollToIndex"), object: nil, userInfo: ["index": rowIndex, "animated": true]) - } - print("searchChatMessageAAA 00 \(sectionIndex) \(rowIndex) \(latestMatch?.message.text ?? "No text")") - - searchInProgress = false - } else { - Log.info("\(ConversationViewModel.TAG) Found result isn't in currently loaded history, loading missing events") - loadMessagesUpTo(targetEvent: match, textToSearch: textToSearch) - print("searchChatMessageAAA 11") - } - - canSearchDown = true - } else { - Log.info("\(ConversationViewModel.TAG) No match found while looking up for message with text \(textToSearch) in direction \(direction) starting from message \(latestMatch?.eventModel.eventLog.chatMessage?.messageId ?? "No message id")" - ) - searchInProgress = false - if latestMatch == nil { - print("searchChatMessageAAA 22") - DispatchQueue.main.async { - ToastViewModel.shared.show("Failed_search_no_match_found") - } - } else { - print("searchChatMessageAAA 33") - // Scroll to last matching event anyway, user may have scrolled away - if let sectionIndex = conversationMessagesSection.firstIndex(where: { + if let sectionIndex = self.conversationMessagesSection.firstIndex(where: { $0.chatRoomID == displayedConversation.id - }), let latestMatchTmp = latestMatch, - let rowIndex = conversationMessagesSection[sectionIndex].rows.firstIndex(of: latestMatchTmp) { + }), + let rowIndex = self.conversationMessagesSection[sectionIndex].rows.firstIndex(where: { + $0.eventModel.eventLogId == match.chatMessage?.messageId + }) { + self.latestMatch = self.conversationMessagesSection[sectionIndex].rows[rowIndex] + + Log.info("\(ConversationViewModel.TAG) Found result is already in history, no need to load more history") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { self.searchText = textToSearch - self.highlightedMessageID = latestMatchTmp.message.id + self.highlightedMessageID = match.chatMessage?.messageId + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "onScrollToIndex"), object: nil, userInfo: ["index": rowIndex, "animated": true]) } - print("searchChatMessageAAA 33Bis") + + DispatchQueue.main.async { + self.canSearchDown = true + } + } else { + DispatchQueue.main.async { + self.searchInProgress = true + self.canSearchDown = true + } + + Log.info("\(ConversationViewModel.TAG) Found result isn't in currently loaded history, loading missing events") + self.loadMessagesUpTo(targetEvent: match, textToSearch: textToSearch) } - DispatchQueue.main.async { - ToastViewModel.shared.show("Failed_search_results_limit_reached") + } else { + Log.info("\(ConversationViewModel.TAG) No match found while looking up for message with text \(textToSearch) in direction \(direction) starting from message \(self.latestMatch?.eventModel.eventLog.chatMessage?.messageId ?? "No message id")" + ) + + if self.latestMatch == nil { + DispatchQueue.main.async { + ToastViewModel.shared.show("Failed_search_no_match_found") + } + } else { + // Scroll to last matching event anyway, user may have scrolled away + if let sectionIndex = self.conversationMessagesSection.firstIndex(where: { + $0.chatRoomID == displayedConversation.id + }), let latestMatchTmp = self.latestMatch, + let rowIndex = self.conversationMessagesSection[sectionIndex].rows.firstIndex(of: latestMatchTmp) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.searchText = textToSearch + self.highlightedMessageID = latestMatchTmp.message.id + NotificationCenter.default.post(name: NSNotification.Name(rawValue: "onScrollToIndex"), object: nil, userInfo: ["index": rowIndex, "animated": true]) + } + } + DispatchQueue.main.async { + ToastViewModel.shared.show("Failed_search_results_limit_reached") + } } } } @@ -3041,72 +3043,104 @@ class ConversationViewModel: ObservableObject { } private func loadMessagesUpTo(targetEvent: EventLog, textToSearch: String) { - if self.conversationMessagesSection[0].rows.last != nil { - let firstEventLog = self.sharedMainViewModel.displayedConversation?.chatRoom.getHistoryRangeEvents( - begin: self.conversationMessagesSection[0].rows.count - 1, - end: self.conversationMessagesSection[0].rows.count + if self.conversationMessagesSection[0].rows.last != nil { + let firstEventLog = self.sharedMainViewModel.displayedConversation?.chatRoom.getHistoryRangeEvents( + begin: self.conversationMessagesSection[0].rows.count - 1, + end: self.conversationMessagesSection[0].rows.count + ) + + if let chatMessageTmp = targetEvent.chatMessage { + let lastEventLog = self.sharedMainViewModel.displayedConversation!.chatRoom.findEventLog(messageId: chatMessageTmp.messageId) + + var historyEvents = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeBetween( + firstEvent: firstEventLog!.first, + lastEvent: lastEventLog, + filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue) ) - if let chatMessageTmp = targetEvent.chatMessage { - let lastEventLog = self.sharedMainViewModel.displayedConversation!.chatRoom.findEventLog(messageId: chatMessageTmp.messageId) + let historyEventsAfter = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeEvents( + begin: self.conversationMessagesSection[0].rows.count + historyEvents.count + 1, + end: self.conversationMessagesSection[0].rows.count + historyEvents.count + 30 + ) + + if lastEventLog != nil { + historyEvents.insert(lastEventLog!, at: 0) + } + + historyEvents.insert(contentsOf: historyEventsAfter, at: 0) + + var conversationMessagesTmp: [EventLogMessage] = [] + + historyEvents.enumerated().reversed().forEach { index, eventLog in + var attachmentNameList: String = "" + var attachmentList: [Attachment] = [] + var contentText = "" - var historyEvents = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeBetween( - firstEvent: firstEventLog!.first, - lastEvent: lastEventLog, - filters: UInt(ChatRoom.HistoryFilter([.ChatMessage, .InfoNoDevice]).rawValue) - ) - - let historyEventsAfter = self.sharedMainViewModel.displayedConversation!.chatRoom.getHistoryRangeEvents( - begin: self.conversationMessagesSection[0].rows.count + historyEvents.count + 1, - end: self.conversationMessagesSection[0].rows.count + historyEvents.count + 30 - ) - - if lastEventLog != nil { - historyEvents.insert(lastEventLog!, at: 0) + guard let chatMessage = eventLog.chatMessage else { + conversationMessagesTmp.insert( + EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: UUID().uuidString, + status: nil, + isOutgoing: false, + isEditable: false, + isRetractable: false, + isEdited: false, + isRetracted: false, + dateReceived: 0, + address: "", + isFirstMessage: false, + text: "", + attachments: [], + ownReaction: "", + reactions: [] + ) + ), at: 0 + ) + return } - historyEvents.insert(contentsOf: historyEventsAfter, at: 0) - - var conversationMessagesTmp: [EventLogMessage] = [] - - historyEvents.enumerated().reversed().forEach { index, eventLog in - var attachmentNameList: String = "" - var attachmentList: [Attachment] = [] - var contentText = "" - - guard let chatMessage = eventLog.chatMessage else { - conversationMessagesTmp.insert( - EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: UUID().uuidString, - status: nil, - isOutgoing: false, - isEditable: false, - isRetractable: false, - isEdited: false, - isRetracted: false, - dateReceived: 0, - address: "", - isFirstMessage: false, - text: "", - attachments: [], - ownReaction: "", - reactions: [] - ) - ), at: 0 - ) - return - } - - if !chatMessage.contents.isEmpty { - chatMessage.contents.forEach { content in - if content.isText && content.name == nil { - contentText = content.utf8Text ?? "" - } else if content.name != nil && !content.name!.isEmpty { - if content.filePath == nil || content.filePath!.isEmpty { - // self.downloadContent(chatMessage: chatMessage, content: content) - let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + if !chatMessage.contents.isEmpty { + chatMessage.contents.forEach { content in + if content.isText && content.name == nil { + contentText = content.utf8Text ?? "" + } else if content.name != nil && !content.name!.isEmpty { + if content.filePath == nil || content.filePath!.isEmpty { + // self.downloadContent(chatMessage: chatMessage, content: content) + let path = URL(string: self.getNewFilePath(name: content.name ?? "")) + + if path != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + url: path!, + type: .fileTransfer, + size: content.fileSize, + transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + } + } else { + if content.type != "video" { + let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") + let path = URL(string: self.getNewFilePath(name: filePathSep[1])) + var typeTmp: AttachmentType = .other + + switch content.type { + case "image": + typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image + case "audio": + typeTmp = content.isVoiceRecording ? .voiceRecording : .audio + case "application": + typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other + case "text": + typeTmp = .text + default: + typeTmp = .other + } if path != nil { let attachment = @@ -3114,71 +3148,14 @@ class ConversationViewModel: ObservableObject { id: UUID().uuidString, name: content.name!, url: path!, - type: .fileTransfer, + type: typeTmp, + duration: typeTmp == . voiceRecording ? content.fileDuration : 0, size: content.fileSize, transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 ) attachmentNameList += ", \(content.name!)" attachmentList.append(attachment) - } - } else { - if content.type != "video" { - let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") - let path = URL(string: self.getNewFilePath(name: filePathSep[1])) - var typeTmp: AttachmentType = .other - - switch content.type { - case "image": - typeTmp = (content.name?.lowercased().hasSuffix("gif"))! ? .gif : .image - case "audio": - typeTmp = content.isVoiceRecording ? .voiceRecording : .audio - case "application": - typeTmp = content.subtype.lowercased() == "pdf" ? .pdf : .other - case "text": - typeTmp = .text - default: - typeTmp = .other - } - - if path != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name!, - url: path!, - type: typeTmp, - duration: typeTmp == . voiceRecording ? content.fileDuration : 0, - size: content.fileSize, - transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 - ) - attachmentNameList += ", \(content.name!)" - attachmentList.append(attachment) - if typeTmp != .voiceRecording { - DispatchQueue.main.async { - if !attachment.full.pathExtension.isEmpty { - self.attachments.append(attachment) - } - } - } - } - } else if content.type == "video" { - let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") - let path = URL(string: self.getNewFilePath(name: filePathSep[1])) - let pathThumbnail = URL(string: self.generateThumbnail(name: filePathSep[1])) - - if path != nil && pathThumbnail != nil { - let attachment = - Attachment( - id: UUID().uuidString, - name: content.name!, - thumbnail: pathThumbnail!, - full: path!, - type: .video, - size: content.fileSize, - transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 - ) - attachmentNameList += ", \(content.name!)" - attachmentList.append(attachment) + if typeTmp != .voiceRecording { DispatchQueue.main.async { if !attachment.full.pathExtension.isEmpty { self.attachments.append(attachment) @@ -3186,148 +3163,185 @@ class ConversationViewModel: ObservableObject { } } } + } else if content.type == "video" { + let filePathSep = content.filePath!.components(separatedBy: "/Library/Images/") + let path = URL(string: self.getNewFilePath(name: filePathSep[1])) + let pathThumbnail = URL(string: self.generateThumbnail(name: filePathSep[1])) + + if path != nil && pathThumbnail != nil { + let attachment = + Attachment( + id: UUID().uuidString, + name: content.name!, + thumbnail: pathThumbnail!, + full: path!, + type: .video, + size: content.fileSize, + transferProgressIndication: content.filePath != nil && !content.filePath!.isEmpty ? 100 : -1 + ) + attachmentNameList += ", \(content.name!)" + attachmentList.append(attachment) + DispatchQueue.main.async { + if !attachment.full.pathExtension.isEmpty { + self.attachments.append(attachment) + } + } + } } } } } - - let addressPrecCleaned = index > 0 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() - addressPrecCleaned?.clean() - - let addressNextCleaned = index <= historyEvents.count - 2 ? historyEvents[index + 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() - addressNextCleaned?.clean() - - let addressCleaned = chatMessage.fromAddress?.clone() - addressCleaned?.clean() - - if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressCleaned!) - } - - let isFirstMessageIncomingTmp = index > 0 ? addressPrecCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true - let isFirstMessageOutgoingTmp = index <= historyEvents.count - 2 ? addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true - - let isFirstMessageTmp = chatMessage.isOutgoing ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp - - var statusTmp: Message.Status? = .sending - switch chatMessage.state { - case .InProgress: - statusTmp = .sending - case .Delivered: - statusTmp = .sent - case .DeliveredToUser: - statusTmp = .received - case .Displayed: - statusTmp = .read - case .NotDelivered: - statusTmp = .error - default: - statusTmp = .sending - } - - var reactionsTmp: [String] = [] - chatMessage.reactions.forEach({ chatMessageReaction in - reactionsTmp.append(chatMessageReaction.body) - }) - - if !attachmentNameList.isEmpty { - attachmentNameList = String(attachmentNameList.dropFirst(2)) - } - - var replyMessageTmp: ReplyMessage? - if chatMessage.replyMessage != nil { - let addressReplyCleaned = chatMessage.replyMessage?.fromAddress?.clone() - addressReplyCleaned?.clean() - - if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { - self.addParticipantConversationModel(address: addressReplyCleaned!) - } - - let contentReplyText = chatMessage.replyMessage?.utf8Text ?? "" - - let isReplyRetracted = chatMessage.replyMessage?.isRetracted ?? false - - var attachmentNameReplyList: String = "" - - chatMessage.replyMessage?.contents.forEach { content in - if !content.isText { - attachmentNameReplyList += ", \(content.name!)" - } - } - - if !attachmentNameReplyList.isEmpty { - attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) - } - - replyMessageTmp = ReplyMessage( - id: chatMessage.replyMessage!.messageId, - address: addressReplyCleaned?.asStringUriOnly() ?? "", - isFirstMessage: false, - text: contentReplyText, - isOutgoing: chatMessage.replyMessage!.isOutgoing, - isEditable: false, - isRetractable: false, - isEdited: false, - isRetracted: isReplyRetracted, - dateReceived: 0, - attachmentsNames: attachmentNameReplyList, - attachments: [] - ) - } - - conversationMessagesTmp.insert( - EventLogMessage( - eventModel: EventModel(eventLog: eventLog), - message: Message( - id: !chatMessage.messageId.isEmpty ? chatMessage.messageId : UUID().uuidString, - status: statusTmp, - isOutgoing: chatMessage.isOutgoing, - isEditable: chatMessage.isOutgoing ? chatMessage.isEditable : false, - isRetractable: chatMessage.isOutgoing ? chatMessage.isRetractable : false, - isEdited: chatMessage.isEdited, - isRetracted: chatMessage.isRetracted, - dateReceived: chatMessage.time, - address: addressCleaned?.asStringUriOnly() ?? "", - isFirstMessage: isFirstMessageTmp, - text: contentText, - attachmentsNames: attachmentNameList, - attachments: attachmentList, - replyMessage: replyMessageTmp, - isForward: chatMessage.isForward, - ownReaction: chatMessage.ownReaction?.body ?? "", - reactions: reactionsTmp, - isEphemeral: chatMessage.isEphemeral, - ephemeralExpireTime: chatMessage.ephemeralExpireTime, - ephemeralLifetime: chatMessage.ephemeralLifetime, - isIcalendar: chatMessage.contents.first?.isIcalendar ?? false, - messageConferenceInfo: chatMessage.contents.first != nil && chatMessage.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: chatMessage.contents.first!) : nil - ) - ), at: 0 - ) - - self.addChatMessageDelegate(message: chatMessage) } - if !conversationMessagesTmp.isEmpty { - DispatchQueue.main.async { - if self.conversationMessagesSection[0].rows.last?.message.address == conversationMessagesTmp.last?.message.address { - self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].message.isFirstMessage = false - } - self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) - - self.searchText = textToSearch - self.highlightedMessageID = targetEvent.chatMessage?.messageId - self.latestMatch = self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - historyEventsAfter.count - 1] - - NotificationCenter.default.post( - name: NSNotification.Name(rawValue: "onScrollToIndex"), - object: nil, - userInfo: ["index": self.conversationMessagesSection[0].rows.count - historyEventsAfter.count - 1, "animated": true] - ) + let addressPrecCleaned = index > 0 ? historyEvents[index - 1].chatMessage?.fromAddress?.clone() : chatMessage.fromAddress?.clone() + addressPrecCleaned?.clean() + + let addressNextCleaned = index <= historyEvents.count - 2 + ? historyEvents[index + 1].chatMessage?.fromAddress?.clone() + : self.conversationMessagesSection[0].rows.last?.eventModel.eventLog.chatMessage?.fromAddress?.clone() + addressNextCleaned?.clean() + + let addressCleaned = chatMessage.fromAddress?.clone() + addressCleaned?.clean() + + if addressCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressCleaned!) + } + + let isFirstMessageIncomingTmp = index > 0 ? addressPrecCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() : true + let isFirstMessageOutgoingTmp = addressNextCleaned?.asStringUriOnly() != addressCleaned?.asStringUriOnly() + + let isFirstMessageTmp = chatMessage.isOutgoing ? isFirstMessageOutgoingTmp : isFirstMessageIncomingTmp + + var statusTmp: Message.Status? = .sending + switch chatMessage.state { + case .InProgress: + statusTmp = .sending + case .Delivered: + statusTmp = .sent + case .DeliveredToUser: + statusTmp = .received + case .Displayed: + statusTmp = .read + case .NotDelivered: + statusTmp = .error + default: + statusTmp = .sending + } + + var reactionsTmp: [String] = [] + chatMessage.reactions.forEach({ chatMessageReaction in + reactionsTmp.append(chatMessageReaction.body) + }) + + if !attachmentNameList.isEmpty { + attachmentNameList = String(attachmentNameList.dropFirst(2)) + } + + var replyMessageTmp: ReplyMessage? + if chatMessage.replyMessage != nil { + let addressReplyCleaned = chatMessage.replyMessage?.fromAddress?.clone() + addressReplyCleaned?.clean() + + if addressReplyCleaned != nil && self.participantConversationModel.first(where: {$0.address == addressReplyCleaned!.asStringUriOnly()}) == nil { + self.addParticipantConversationModel(address: addressReplyCleaned!) } + + let contentReplyText = chatMessage.replyMessage?.utf8Text ?? "" + + let isReplyRetracted = chatMessage.replyMessage?.isRetracted ?? false + + var attachmentNameReplyList: String = "" + + chatMessage.replyMessage?.contents.forEach { content in + if !content.isText { + attachmentNameReplyList += ", \(content.name!)" + } + } + + if !attachmentNameReplyList.isEmpty { + attachmentNameReplyList = String(attachmentNameReplyList.dropFirst(2)) + } + + replyMessageTmp = ReplyMessage( + id: chatMessage.replyMessage!.messageId, + address: addressReplyCleaned?.asStringUriOnly() ?? "", + isFirstMessage: false, + text: contentReplyText, + isOutgoing: chatMessage.replyMessage!.isOutgoing, + isEditable: false, + isRetractable: false, + isEdited: false, + isRetracted: isReplyRetracted, + dateReceived: 0, + attachmentsNames: attachmentNameReplyList, + attachments: [] + ) + } + + conversationMessagesTmp.insert( + EventLogMessage( + eventModel: EventModel(eventLog: eventLog), + message: Message( + id: !chatMessage.messageId.isEmpty ? chatMessage.messageId : UUID().uuidString, + status: statusTmp, + isOutgoing: chatMessage.isOutgoing, + isEditable: chatMessage.isOutgoing ? chatMessage.isEditable : false, + isRetractable: chatMessage.isOutgoing ? chatMessage.isRetractable : false, + isEdited: chatMessage.isEdited, + isRetracted: chatMessage.isRetracted, + dateReceived: chatMessage.time, + address: addressCleaned?.asStringUriOnly() ?? "", + isFirstMessage: isFirstMessageTmp, + text: contentText, + attachmentsNames: attachmentNameList, + attachments: attachmentList, + replyMessage: replyMessageTmp, + isForward: chatMessage.isForward, + ownReaction: chatMessage.ownReaction?.body ?? "", + reactions: reactionsTmp, + isEphemeral: chatMessage.isEphemeral, + ephemeralExpireTime: chatMessage.ephemeralExpireTime, + ephemeralLifetime: chatMessage.ephemeralLifetime, + isIcalendar: chatMessage.contents.first?.isIcalendar ?? false, + messageConferenceInfo: chatMessage.contents.first != nil && chatMessage.contents.first!.isIcalendar == true ? self.parseConferenceInvite(content: chatMessage.contents.first!) : nil + ) + ), at: 0 + ) + + self.addChatMessageDelegate(message: chatMessage) + } + + DispatchQueue.main.async { + self.searchInProgress = false + + + guard !conversationMessagesTmp.isEmpty else { return } + + if let lastRow = self.conversationMessagesSection[0].rows.last, + lastRow.message.address == conversationMessagesTmp.last?.message.address { + self.conversationMessagesSection[0].rows[self.conversationMessagesSection[0].rows.count - 1].message.isFirstMessage = false + } + + self.conversationMessagesSection[0].rows.append(contentsOf: conversationMessagesTmp.reversed()) + + if self.conversationMessagesSection[0].rows.count > historyEventsAfter.count { + self.targetIndex = self.conversationMessagesSection[0].rows.count - historyEventsAfter.count - 1 + self.searchText = textToSearch + self.highlightedMessageID = targetEvent.chatMessage?.messageId + self.latestMatch = self.conversationMessagesSection[0].rows[self.targetIndex] } } + } else { + DispatchQueue.main.async { + self.searchInProgress = false + } } + } else { + DispatchQueue.main.async { + self.searchInProgress = false + } + } } } // swiftlint:enable line_length