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 {