// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation import SignalServiceKit public typealias MessageSortKey = UInt64 public struct ConversationSortKey: Comparable { let isContactThread: Bool let creationDate: Date? let lastInteractionRowId: Int64 // MARK: Comparable public static func < (lhs: ConversationSortKey, rhs: ConversationSortKey) -> Bool { // always show matching contact results first if lhs.isContactThread, !rhs.isContactThread { return false } else if !lhs.isContactThread, rhs.isContactThread { return true } if lhs.lastInteractionRowId != rhs.lastInteractionRowId { return lhs.lastInteractionRowId < rhs.lastInteractionRowId } let longAgo = Date(timeIntervalSince1970: 0) let lhsDate = lhs.creationDate ?? longAgo let rhsDate = rhs.creationDate ?? longAgo return lhsDate < rhsDate } } // MARK: - public class ConversationSearchResult: Comparable where SortKey: Comparable { public let thread: ThreadViewModel public let messageId: String? public let messageDate: Date? public let snippet: String? private let sortKey: SortKey init(thread: ThreadViewModel, sortKey: SortKey, messageId: String? = nil, messageDate: Date? = nil, snippet: String? = nil) { self.thread = thread self.sortKey = sortKey self.messageId = messageId self.messageDate = messageDate self.snippet = snippet } // MARK: Comparable public static func < (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool { return lhs.sortKey < rhs.sortKey } // MARK: Equatable public static func == (lhs: ConversationSearchResult, rhs: ConversationSearchResult) -> Bool { return lhs.thread.threadRecord.uniqueId == rhs.thread.threadRecord.uniqueId && lhs.messageId == rhs.messageId } } // MARK: - @objc public class ContactSearchResult: NSObject, Comparable { public let signalAccount: SignalAccount private let comparableName: String public var recipientAddress: SignalServiceAddress { return signalAccount.recipientAddress } init(signalAccount: SignalAccount, transaction: SDSAnyReadTransaction) { self.signalAccount = signalAccount self.comparableName = Self.contactsManager.comparableName(for: signalAccount, transaction: transaction) } // MARK: Comparable public static func < (lhs: ContactSearchResult, rhs: ContactSearchResult) -> Bool { var comparisonResult = lhs.comparableName.caseInsensitiveCompare(rhs.comparableName) if comparisonResult == .orderedSame { comparisonResult = lhs.recipientAddress.stringForDisplay.compare(rhs.recipientAddress.stringForDisplay) } return comparisonResult == .orderedAscending } // MARK: Equatable public static func == (lhs: ContactSearchResult, rhs: ContactSearchResult) -> Bool { return lhs.recipientAddress == rhs.recipientAddress } } // MARK: - public class HomeScreenSearchResultSet: NSObject { public let searchText: String public let contactThreads: [ConversationSearchResult] public let groupThreads: [GroupSearchResult] public let contacts: [ContactSearchResult] public let messages: [ConversationSearchResult] public init( searchText: String, contactThreads: [ConversationSearchResult], groupThreads: [GroupSearchResult], contacts: [ContactSearchResult], messages: [ConversationSearchResult] ) { self.searchText = searchText self.contactThreads = contactThreads self.groupThreads = groupThreads self.contacts = contacts self.messages = messages } public class var empty: HomeScreenSearchResultSet { return HomeScreenSearchResultSet(searchText: "", contactThreads: [], groupThreads: [], contacts: [], messages: []) } public var isEmpty: Bool { return contactThreads.isEmpty && groupThreads.isEmpty && contacts.isEmpty && messages.isEmpty } } // MARK: - @objc public class GroupSearchResult: NSObject, Comparable { public let thread: ThreadViewModel public let matchedMembersSnippet: String? private let sortKey: ConversationSortKey class func withMatchedMembersSnippet( thread: ThreadViewModel, sortKey: ConversationSortKey, searchText: String, transaction: SDSAnyReadTransaction ) -> GroupSearchResult? { guard let groupThread = thread.threadRecord as? TSGroupThread else { owsFailDebug("Unexpected thread type") return nil } let members: [( address: SignalServiceAddress, displayName: String, comparableName: String, isMatched: Bool )] = groupThread.groupMembership.fullMembers.map { address in var displayName = contactsManager.displayName(for: address, transaction: transaction) var isMatched = false if let matchRange = displayName.range(of: searchText, options: [.caseInsensitive, .diacriticInsensitive]) { isMatched = true displayName = displayName.replacingCharacters( in: matchRange, with: "<\(FullTextSearchFinder.matchTag)>\(displayName[matchRange])" ) } return ( address: address, displayName: displayName, comparableName: contactsManager.comparableName(for: address, transaction: transaction), isMatched: isMatched ) } let matchedMembersSnippet = members.sorted { lhs, rhs in // Bubble matched members to the top if rhs.isMatched != lhs.isMatched { return lhs.isMatched } // Sort numbers to the end of the list if lhs.comparableName.hasPrefix("+") != rhs.comparableName.hasPrefix("+") { return !lhs.comparableName.hasPrefix("+") } // Otherwise, sort by comparable name return lhs.comparableName.caseInsensitiveCompare(rhs.comparableName) == .orderedAscending }.map { $0.displayName }.joined(separator: ", ") return GroupSearchResult(thread: thread, sortKey: sortKey, matchedMembersSnippet: matchedMembersSnippet) } init(thread: ThreadViewModel, sortKey: ConversationSortKey, matchedMembersSnippet: String? = nil) { self.thread = thread self.sortKey = sortKey self.matchedMembersSnippet = matchedMembersSnippet } // MARK: Comparable public static func < (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool { return lhs.sortKey < rhs.sortKey } // MARK: Equatable public static func == (lhs: GroupSearchResult, rhs: GroupSearchResult) -> Bool { return lhs.thread.threadRecord.uniqueId == rhs.thread.threadRecord.uniqueId } } // MARK: - @objc public class ComposeScreenSearchResultSet: NSObject { @objc public let searchText: String @objc public let groups: [GroupSearchResult] @objc public var groupThreads: [TSGroupThread] { return groups.compactMap { $0.thread.threadRecord as? TSGroupThread } } @objc public let signalContacts: [ContactSearchResult] @objc public var signalAccounts: [SignalAccount] { return signalContacts.map { $0.signalAccount } } public init(searchText: String, groups: [GroupSearchResult], signalContacts: [ContactSearchResult]) { self.searchText = searchText self.groups = groups self.signalContacts = signalContacts } @objc public static let empty = ComposeScreenSearchResultSet(searchText: "", groups: [], signalContacts: []) @objc public var isEmpty: Bool { return groups.isEmpty && signalContacts.isEmpty } } // MARK: - @objc public class MessageSearchResult: NSObject, Comparable { public let messageId: String public let sortId: UInt64 init(messageId: String, sortId: UInt64) { self.messageId = messageId self.sortId = sortId } // MARK: - Comparable public static func < (lhs: MessageSearchResult, rhs: MessageSearchResult) -> Bool { return lhs.sortId < rhs.sortId } } // MARK: - @objc public class ConversationScreenSearchResultSet: NSObject { @objc public let searchText: String @objc public let messages: [MessageSearchResult] @objc public lazy var messageSortIds: [UInt64] = { return messages.map { $0.sortId } }() // MARK: Static members public static let empty: ConversationScreenSearchResultSet = ConversationScreenSearchResultSet(searchText: "", messages: []) // MARK: Init public init(searchText: String, messages: [MessageSearchResult]) { self.searchText = searchText self.messages = messages } // MARK: - CustomDebugStringConvertible override public var debugDescription: String { return "ConversationScreenSearchResultSet(searchText: \(searchText), messages: [\(messages.count) matches])" } } @objc public class FullTextSearcher: NSObject { @objc public static let kDefaultMaxResults: UInt = 500 private let finder: FullTextSearchFinder @objc public static let shared: FullTextSearcher = FullTextSearcher() override private init() { finder = FullTextSearchFinder() super.init() } @objc public func searchForComposeScreen(searchText: String, omitLocalUser: Bool, maxResults: UInt = kDefaultMaxResults, transaction: SDSAnyReadTransaction) -> ComposeScreenSearchResultSet { var signalContactMap = [SignalServiceAddress: ContactSearchResult]() var signalRecipentResults: [ContactSearchResult] = [] var groups: [GroupSearchResult] = [] var hasReachedMaxResults: Bool { guard (signalContactMap.count + signalRecipentResults.count + groups.count) < maxResults else { return true } return false } finder.enumerateObjects( searchText: searchText, collections: [ SignalAccount.collection(), SignalRecipient.collection(), TSThread.collection() ], maxResults: maxResults, transaction: transaction ) { match, _, stop in guard !hasReachedMaxResults else { stop.pointee = true return } switch match { case let signalAccount as SignalAccount: let searchResult = ContactSearchResult(signalAccount: signalAccount, transaction: transaction) assert(signalContactMap[signalAccount.recipientAddress] == nil) signalContactMap[signalAccount.recipientAddress] = searchResult case let signalRecipient as SignalRecipient: guard signalRecipient.devices.count > 0 else { // Ignore unregistered recipients. return } let signalAccount = SignalAccount(signalRecipient: signalRecipient, contact: nil, multipleAccountLabelText: nil) let searchResult = ContactSearchResult(signalAccount: signalAccount, transaction: transaction) signalRecipentResults.append(searchResult) case let groupThread as TSGroupThread: let sortKey = ConversationSortKey(isContactThread: false, creationDate: groupThread.creationDate, lastInteractionRowId: groupThread.lastInteractionRowId) let threadViewModel = ThreadViewModel(thread: groupThread, forConversationList: true, transaction: transaction) let searchResult = GroupSearchResult(thread: threadViewModel, sortKey: sortKey) groups.append(searchResult) case is TSContactThread: // not included in compose screen results break default: owsFailDebug("Unexpected match of type \(type(of: match))") } } // Fill in user matches from SignalRecipients, but only if // we don't already have a SignalAccount for the same user. for signalRecipentResult in signalRecipentResults { if signalContactMap[signalRecipentResult.recipientAddress] == nil { signalContactMap[signalRecipentResult.recipientAddress] = signalRecipentResult } } if let localAddress = TSAccountManager.localAddress { if matchesNoteToSelf(searchText: searchText, transaction: transaction) { if signalContactMap[localAddress] == nil { let localAccount = SignalAccount(address: localAddress) let localResult = ContactSearchResult(signalAccount: localAccount, transaction: transaction) signalContactMap[localAddress] = localResult } } } else { owsFailDebug("localAddress was unexpectedly nil") } // Filter out contact results with pending message requests. var signalContacts = Array(signalContactMap.values).filter { (contactResult: ContactSearchResult) in !self.shouldFilterContactResult(contactResult: contactResult, omitLocalUser: omitLocalUser, transaction: transaction) } // Order contact results by display name. signalContacts.sort() // Order the conversation and message results in reverse chronological order. // The contact results are pre-sorted by display name. groups.sort(by: >) return ComposeScreenSearchResultSet(searchText: searchText, groups: groups, signalContacts: signalContacts) } func shouldFilterContactResult(contactResult: ContactSearchResult, omitLocalUser: Bool, transaction: SDSAnyReadTransaction) -> Bool { let address = contactResult.recipientAddress if address.isLocalAddress { return omitLocalUser } if self.contactsManager.isSystemContact(address: address) { return false } guard let thread = TSContactThread.getWithContactAddress(address, transaction: transaction) else { // Filter out users with whom we've never had contact. return true } return thread.hasPendingMessageRequest(transaction: transaction.unwrapGrdbRead) } func matchesNoteToSelf(searchText: String, transaction: SDSAnyReadTransaction) -> Bool { guard let localAddress = TSAccountManager.localAddress else { return false } let noteToSelfText = self.conversationIndexingString(address: localAddress, transaction: transaction) let matchedTerm = searchText.split(separator: " ").first { term in return noteToSelfText.contains(term) } return matchedTerm != nil } public func searchForHomeScreen(searchText: String, maxResults: UInt = kDefaultMaxResults, transaction: SDSAnyReadTransaction) -> HomeScreenSearchResultSet { var contactThreads: [ConversationSearchResult] = [] var groupThreads: [GroupSearchResult] = [] var contactsMap: [SignalServiceAddress: ContactSearchResult] = [:] var messages: [UInt64: ConversationSearchResult] = [:] var existingConversationAddresses: Set = Set() var threadCache = [String: TSThread]() let getThread: (String) -> TSThread? = { threadUniqueId in if let thread = threadCache[threadUniqueId] { return thread } guard let thread = TSThread.anyFetch(uniqueId: threadUniqueId, transaction: transaction) else { return nil } threadCache[threadUniqueId] = thread return thread } var threadViewModelCache = [String: ThreadViewModel]() let getThreadViewModel: (TSThread) -> ThreadViewModel = { thread in if let threadViewModel = threadViewModelCache[thread.uniqueId] { return threadViewModel } let threadViewModel = ThreadViewModel(thread: thread, forConversationList: true, transaction: transaction) threadViewModelCache[thread.uniqueId] = threadViewModel return threadViewModel } var mentionedMessageCache = [SignalServiceAddress: [TSMessage]]() let getMentionedMessages: (SignalServiceAddress) -> [TSMessage] = { address in if let mentionedMessages = mentionedMessageCache[address] { return mentionedMessages } let mentionedMessages = MentionFinder.messagesMentioning( address: address, transaction: transaction.unwrapGrdbRead ) mentionedMessageCache[address] = mentionedMessages return mentionedMessages } func appendMessage(_ message: TSMessage, snippet: String?) { guard let thread = getThread(message.uniqueThreadId) else { owsFailDebug("Missing thread: \(type(of: message))") return } let threadViewModel = getThreadViewModel(thread) let sortKey = message.sortId let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey, messageId: message.uniqueId, messageDate: NSDate.ows_date(withMillisecondsSince1970: message.timestamp), snippet: snippet) guard messages[sortKey] == nil else { return } messages[sortKey] = searchResult } func appendSignalAccount(_ signalAccount: SignalAccount) { guard contactsMap[signalAccount.recipientAddress] == nil else { return } let searchResult = ContactSearchResult(signalAccount: signalAccount, transaction: transaction) contactsMap[searchResult.recipientAddress] = searchResult getMentionedMessages(signalAccount.recipientAddress) .forEach { message in appendMessage( message, snippet: message.plaintextBody(with: transaction.unwrapGrdbRead) ) } } var remainingAllowedResults: UInt { UInt(max(0, Int(maxResults) - (contactThreads.count + groupThreads.count + contactsMap.count + messages.count))) } var hasReachedMaxResults: Bool { guard remainingAllowedResults > 0 else { return true } return false } // We search for each type of result independetly. The order here matters – // we want to give priority to chat and contact results above message results. // This makes sure if I search for a string like "Matthew" the first results // will be the chat with my contact named "Matthew", rather than messages // where his name was mentioned. finder.enumerateObjects( searchText: searchText, maxResults: remainingAllowedResults, transaction: transaction ) { (thread: TSThread, _, stop) in guard !hasReachedMaxResults else { stop.pointee = true return } // Ignore deleted threads. guard thread.shouldThreadBeVisible else { return } let threadViewModel = getThreadViewModel(thread) let sortKey = ConversationSortKey( isContactThread: thread is TSContactThread, creationDate: thread.creationDate, lastInteractionRowId: thread.lastInteractionRowId ) switch thread { case is TSGroupThread: guard let searchResult = GroupSearchResult.withMatchedMembersSnippet( thread: threadViewModel, sortKey: sortKey, searchText: searchText, transaction: transaction ) else { return owsFailDebug("Unexpectedly failed to determine members snippet") } groupThreads.append(searchResult) case let contactThread as TSContactThread: let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey) existingConversationAddresses.insert(contactThread.contactAddress) contactThreads.append(searchResult) default: owsFailDebug("unexpected thread: \(type(of: thread))") } } finder.enumerateObjects( searchText: searchText, maxResults: remainingAllowedResults, transaction: transaction ) { (account: SignalAccount, _, stop) in guard !hasReachedMaxResults else { stop.pointee = true return } appendSignalAccount(account) } finder.enumerateObjects( searchText: searchText, maxResults: remainingAllowedResults, transaction: transaction ) { (recipient: SignalRecipient, _, stop) in guard !hasReachedMaxResults else { stop.pointee = true return } // Ignore unregistered recipients. guard recipient.devices.count > 0 else { return } let account = SignalAccount(signalRecipient: recipient, contact: nil, multipleAccountLabelText: nil) appendSignalAccount(account) } finder.enumerateObjects( searchText: searchText, maxResults: remainingAllowedResults, transaction: transaction ) { (message: TSMessage, snippet: String?, stop: UnsafeMutablePointer) in guard !hasReachedMaxResults else { stop.pointee = true return } appendMessage(message, snippet: snippet) } if matchesNoteToSelf(searchText: searchText, transaction: transaction) { if let localAddress = TSAccountManager.localAddress, contactsMap[localAddress] == nil { let localAccount = SignalAccount(address: localAddress) let localResult = ContactSearchResult(signalAccount: localAccount, transaction: transaction) contactsMap[localAddress] = localResult } else { owsFailDebug("localAddress was unexpectedly nil") } } // Only show contacts which were not included in an existing 1:1 conversation. var otherContacts: [ContactSearchResult] = contactsMap.values.filter { !existingConversationAddresses.contains($0.recipientAddress) } // Order the conversation and message results in reverse chronological order. // The contact results are pre-sorted by display name. contactThreads.sort(by: >) groupThreads.sort(by: >) let sortedMessages = messages.values.sorted(by: >) // Order "other" contact results by display name. otherContacts.sort() return HomeScreenSearchResultSet( searchText: searchText, contactThreads: contactThreads, groupThreads: groupThreads, contacts: otherContacts, messages: sortedMessages ) } public func searchWithinConversation(thread: TSThread, searchText: String, maxResults: UInt = kDefaultMaxResults, transaction: SDSAnyReadTransaction) -> ConversationScreenSearchResultSet { var messages: [UInt64: MessageSearchResult] = [:] finder.enumerateObjects( searchText: searchText, collections: [ TSMessage.collection(), SignalRecipient.collection() ], maxResults: maxResults, transaction: transaction ) { match, _, stop in guard messages.count < maxResults else { stop.pointee = true return } func appendMessage(_ message: TSMessage) { let messageId = message.uniqueId let searchResult = MessageSearchResult(messageId: messageId, sortId: message.sortId) messages[message.sortId] = searchResult } switch match { case let message as TSMessage: guard message.uniqueThreadId == thread.uniqueId else { return } appendMessage(message) case let recipient as SignalRecipient: guard thread.recipientAddresses.contains(recipient.address) || recipient.address.isLocalAddress else { return } let messagesMentioningAccount = MentionFinder.messagesMentioning( address: recipient.address, in: thread, transaction: transaction.unwrapGrdbRead ) messagesMentioningAccount.forEach { appendMessage($0) } default: owsFailDebug("Unexpected match of type \(type(of: match))") } } // We want most recent first let sortedMessages = messages.values.sorted(by: >) return ConversationScreenSearchResultSet(searchText: searchText, messages: sortedMessages) } @objc(filterThreads:withSearchText:transaction:) public func filterThreads(_ threads: [TSThread], searchText: String, transaction: SDSAnyReadTransaction) -> [TSThread] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { return threads } return threads.filter { thread in switch thread { case let groupThread as TSGroupThread: return self.groupThreadSearcher.matches(item: groupThread, query: searchText, transaction: transaction) case let contactThread as TSContactThread: return self.contactThreadSearcher.matches(item: contactThread, query: searchText, transaction: transaction) default: owsFailDebug("Unexpected thread type: \(thread.uniqueId)") return false } } } @objc(filterGroupThreads:withSearchText:transaction:) public func filterGroupThreads(_ groupThreads: [TSGroupThread], searchText: String, transaction: SDSAnyReadTransaction) -> [TSGroupThread] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { return groupThreads } return groupThreads.filter { groupThread in return self.groupThreadSearcher.matches(item: groupThread, query: searchText, transaction: transaction) } } @objc(filterSignalAccounts:withSearchText:transaction:) public func filterSignalAccounts(_ signalAccounts: [SignalAccount], searchText: String, transaction: SDSAnyReadTransaction) -> [SignalAccount] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { return signalAccounts } return signalAccounts.filter { signalAccount in self.signalAccountSearcher.matches(item: signalAccount, query: searchText, transaction: transaction) } } // MARK: Searchers private lazy var groupThreadSearcher: Searcher = Searcher { (groupThread: TSGroupThread, transaction: SDSAnyReadTransaction) in let groupName = groupThread.groupModel.groupName let memberStrings = groupThread.groupModel.groupMembers.map { address in self.indexingString(address: address, transaction: transaction) }.joined(separator: " ") return "\(memberStrings) \(groupName ?? "")" } private lazy var contactThreadSearcher: Searcher = Searcher { (contactThread: TSContactThread, transaction: SDSAnyReadTransaction) in let recipientAddress = contactThread.contactAddress return self.conversationIndexingString(address: recipientAddress, transaction: transaction) } private lazy var signalAccountSearcher: Searcher = Searcher { (signalAccount: SignalAccount, transaction: SDSAnyReadTransaction) in let recipientAddress = signalAccount.recipientAddress return self.conversationIndexingString(address: recipientAddress, transaction: transaction) } private func conversationIndexingString(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String { var result = self.indexingString(address: address, transaction: transaction) if address.isLocalAddress { result += " \(MessageStrings.noteToSelf)" } return result } private func indexingString(address: SignalServiceAddress, transaction: SDSAnyReadTransaction) -> String { let displayName = contactsManager.displayName(for: address, transaction: transaction) return "\(address.phoneNumber ?? "") \(displayName)" } }