// // Copyright (c) 2018 Open Whisper Systems. All rights reserved. // import Foundation import SignalServiceKit public class ConversationSearchResult: Comparable { public let thread: ThreadViewModel public let messageId: String? public let messageDate: Date? public let snippet: String? private let sortKey: UInt64 init(thread: ThreadViewModel, sortKey: UInt64, 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 } } public class ContactSearchResult: Comparable { public let signalAccount: SignalAccount public let contactsManager: ContactsManagerProtocol public var recipientId: String { return signalAccount.recipientId } init(signalAccount: SignalAccount, contactsManager: ContactsManagerProtocol) { self.signalAccount = signalAccount self.contactsManager = contactsManager } // MARK: Comparable public static func < (lhs: ContactSearchResult, rhs: ContactSearchResult) -> Bool { return lhs.contactsManager.compare(signalAccount: lhs.signalAccount, with: rhs.signalAccount) == .orderedAscending } // MARK: Equatable public static func == (lhs: ContactSearchResult, rhs: ContactSearchResult) -> Bool { return lhs.recipientId == rhs.recipientId } } public class SearchResultSet { public let searchText: String public let conversations: [ConversationSearchResult] public let contacts: [ContactSearchResult] public let messages: [ConversationSearchResult] public init(searchText: String, conversations: [ConversationSearchResult], contacts: [ContactSearchResult], messages: [ConversationSearchResult]) { self.searchText = searchText self.conversations = conversations self.contacts = contacts self.messages = messages } public class var empty: SearchResultSet { return SearchResultSet(searchText: "", conversations: [], contacts: [], messages: []) } public var isEmpty: Bool { return conversations.isEmpty && contacts.isEmpty && messages.isEmpty } } @objc public class ConversationSearcher: NSObject { private let finder: FullTextSearchFinder @objc public static let shared: ConversationSearcher = ConversationSearcher() override private init() { finder = FullTextSearchFinder() super.init() } public func results(searchText: String, transaction: YapDatabaseReadTransaction, contactsManager: ContactsManagerProtocol) -> SearchResultSet { var conversations: [ConversationSearchResult] = [] var contacts: [ContactSearchResult] = [] var messages: [ConversationSearchResult] = [] var existingConversationRecipientIds: Set = Set() self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in if let thread = match as? TSThread { let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) let sortKey = NSDate.ows_millisecondsSince1970(for: threadViewModel.lastMessageDate) let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey) if let contactThread = thread as? TSContactThread { let recipientId = contactThread.contactIdentifier() existingConversationRecipientIds.insert(recipientId) } conversations.append(searchResult) } else if let message = match as? TSMessage { let thread = message.thread(with: transaction) let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) let sortKey = message.timestamp let searchResult = ConversationSearchResult(thread: threadViewModel, sortKey: sortKey, messageId: message.uniqueId, messageDate: NSDate.ows_date(withMillisecondsSince1970: message.timestamp), snippet: snippet) messages.append(searchResult) } else if let signalAccount = match as? SignalAccount { let searchResult = ContactSearchResult(signalAccount: signalAccount, contactsManager: contactsManager) contacts.append(searchResult) } else { owsFailDebug("unhandled item: \(match)") } } // Only show contacts which were not included in an existing 1:1 conversation. var otherContacts: [ContactSearchResult] = contacts.filter { !existingConversationRecipientIds.contains($0.recipientId) } // Order the conversation and message results in reverse chronological order. // The contact results are pre-sorted by display name. conversations.sort(by: >) messages.sort(by: >) // Order "other" contact results by display name. otherContacts.sort() return SearchResultSet(searchText: searchText, conversations: conversations, contacts: otherContacts, messages: messages) } @objc(filterThreads:withSearchText:) public func filterThreads(_ threads: [TSThread], searchText: String) -> [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) case let contactThread as TSContactThread: return self.contactThreadSearcher.matches(item: contactThread, query: searchText) default: owsFailDebug("Unexpected thread type: \(thread)") return false } } } @objc(filterGroupThreads:withSearchText:) public func filterGroupThreads(_ groupThreads: [TSGroupThread], searchText: String) -> [TSGroupThread] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { return groupThreads } return groupThreads.filter { groupThread in return self.groupThreadSearcher.matches(item: groupThread, query: searchText) } } @objc(filterSignalAccounts:withSearchText:) public func filterSignalAccounts(_ signalAccounts: [SignalAccount], searchText: String) -> [SignalAccount] { guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else { return signalAccounts } return signalAccounts.filter { signalAccount in self.signalAccountSearcher.matches(item: signalAccount, query: searchText) } } // MARK: Searchers private lazy var groupThreadSearcher: Searcher = Searcher { (groupThread: TSGroupThread) in let groupName = groupThread.groupModel.groupName let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in self.indexingString(recipientId: recipientId) }.joined(separator: " ") return "\(memberStrings) \(groupName ?? "")" } private lazy var contactThreadSearcher: Searcher = Searcher { (contactThread: TSContactThread) in let recipientId = contactThread.contactIdentifier() return self.indexingString(recipientId: recipientId) } private lazy var signalAccountSearcher: Searcher = Searcher { (signalAccount: SignalAccount) in let recipientId = signalAccount.recipientId return self.indexingString(recipientId: recipientId) } private var contactsManager: OWSContactsManager { return Environment.shared.contactsManager } private func indexingString(recipientId: String) -> String { let contactName = contactsManager.displayName(forPhoneIdentifier: recipientId) let profileName = contactsManager.profileName(forRecipientId: recipientId) return "\(recipientId) \(contactName) \(profileName ?? "")" } }