Add message search feature

This commit is contained in:
Benoit Martins 2026-01-08 17:42:03 +01:00
parent 6575a4b0f2
commit ac5a23bfff
7 changed files with 681 additions and 147 deletions

View file

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

View file

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

View file

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

View file

@ -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..<base.endIndex
while let found = base.range(of: search, range: searchRange) {
guard
let start = AttributedString.Index(found.lowerBound, within: attributed),
let end = AttributedString.Index(found.upperBound, within: attributed)
else { break }
attributed[start..<end].font = .system(size: 14, weight: .bold)
searchRange = found.upperBound..<base.endIndex
}
}
// MARK: - Mention validation
private func isMention(_ word: String) -> 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)
)
}
}

View file

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

View file

@ -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<Bool>) {
func replyToMessage(index: Int, isMessageTextFocused: Binding<Bool>) {
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

View file

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