// // Copyright 2017 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import BonMot import Foundation import LibSignalClient import SignalServiceKit public typealias MessageSortKey = UInt64 public struct ConversationSortKey: Comparable { let isContactThread: Bool let creationDate: Date? let lastInteractionRowId: UInt64 // MARK: Comparable public static func < (lhs: ConversationSortKey, rhs: ConversationSortKey) -> Bool { // always show matching contact results first if lhs.isContactThread != rhs.isContactThread { return lhs.isContactThread } if lhs.lastInteractionRowId != rhs.lastInteractionRowId { return lhs.lastInteractionRowId < rhs.lastInteractionRowId } let lhsDate = lhs.creationDate ?? .distantPast let rhsDate = rhs.creationDate ?? .distantPast return lhsDate < rhsDate } } // MARK: - public class ConversationSearchResult: Comparable where SortKey: Comparable { public let threadViewModel: ThreadViewModel public let messageId: String? public let messageDate: Date? public let snippet: CVTextValue? private let sortKey: SortKey init(threadViewModel: ThreadViewModel, sortKey: SortKey, messageId: String? = nil, messageDate: Date? = nil, snippet: CVTextValue? = nil) { self.threadViewModel = threadViewModel 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.threadViewModel.threadRecord.uniqueId == rhs.threadViewModel.threadRecord.uniqueId && lhs.messageId == rhs.messageId ) } } // MARK: - public class ContactSearchResult: Comparable, Dependencies { public let recipientAddress: SignalServiceAddress private let comparableName: ComparableDisplayName private let lastInteractionRowID: UInt64? init(recipientAddress: SignalServiceAddress, transaction: SDSAnyReadTransaction) { self.recipientAddress = recipientAddress self.comparableName = ComparableDisplayName( address: recipientAddress, displayName: Self.contactsManager.displayName(for: recipientAddress, tx: transaction), config: .current() ) let thread = ContactThreadFinder().contactThread(for: recipientAddress, tx: transaction) lastInteractionRowID = thread?.lastInteractionRowId } // MARK: Comparable public static func < (lhs: ContactSearchResult, rhs: ContactSearchResult) -> Bool { // Sort contacts by most recent chat, falling back to alphabetical switch (lhs.lastInteractionRowID, rhs.lastInteractionRowID) { case (.some, .none): return true case (.none, .some): return false case let (.some(lhsRowID), .some(rhsRowID)): return lhsRowID > rhsRowID case (.none, .none): return lhs.comparableName < rhs.comparableName } } // MARK: Equatable public static func == (lhs: ContactSearchResult, rhs: ContactSearchResult) -> Bool { return lhs.recipientAddress == rhs.recipientAddress } } // MARK: - /// Can represent either a group thread with stories, or a private story thread. public class StorySearchResult: Comparable { public let thread: TSThread private let sortKey: ConversationSortKey init(thread: TSThread, sortKey: ConversationSortKey) { self.thread = thread self.sortKey = sortKey } // MARK: Comparable public static func < (lhs: StorySearchResult, rhs: StorySearchResult) -> Bool { return lhs.sortKey < rhs.sortKey } // MARK: Equatable public static func == (lhs: StorySearchResult, rhs: StorySearchResult) -> Bool { return lhs.thread.uniqueId == rhs.thread.uniqueId } } // MARK: - public class HomeScreenSearchResultSet: NSObject { public let searchText: String public let contactThreadResults: [ConversationSearchResult] public let groupThreadResults: [GroupSearchResult] public let contactResults: [ContactSearchResult] public let messageResults: [ConversationSearchResult] public init( searchText: String, contactThreadResults: [ConversationSearchResult], groupThreadResults: [GroupSearchResult], contactResults: [ContactSearchResult], messageResults: [ConversationSearchResult] ) { self.searchText = searchText self.contactThreadResults = contactThreadResults self.groupThreadResults = groupThreadResults self.contactResults = contactResults self.messageResults = messageResults } public class var empty: HomeScreenSearchResultSet { return HomeScreenSearchResultSet(searchText: "", contactThreadResults: [], groupThreadResults: [], contactResults: [], messageResults: []) } public var isEmpty: Bool { return contactThreadResults.isEmpty && groupThreadResults.isEmpty && contactResults.isEmpty && messageResults.isEmpty } } // MARK: - public class GroupSearchResult: Comparable { public let threadViewModel: ThreadViewModel public let matchedMembersSnippet: String? private let sortKey: ConversationSortKey class func withMatchedMembersSnippet( groupThread: TSGroupThread, threadViewModel: ThreadViewModel, sortKey: ConversationSortKey, searchText: String, nameResolver: NameResolver, transaction: SDSAnyReadTransaction ) -> GroupSearchResult { owsAssertDebug(threadViewModel.threadRecord === groupThread) let matchedMembers = groupThread.sortedMemberNames( searchText: searchText, includingBlocked: true, nameResolver: nameResolver, transaction: transaction ) let matchedMembersSnippet = matchedMembers.joined(separator: ", ") return GroupSearchResult(threadViewModel: threadViewModel, sortKey: sortKey, matchedMembersSnippet: matchedMembersSnippet) } init(threadViewModel: ThreadViewModel, sortKey: ConversationSortKey, matchedMembersSnippet: String? = nil) { self.threadViewModel = threadViewModel 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.threadViewModel.threadRecord.uniqueId == rhs.threadViewModel.threadRecord.uniqueId } } // MARK: - public struct RecipientSearchResultSet { public let searchText: String public let contactResults: [ContactSearchResult] public let groupResults: [GroupSearchResult] public let storyResults: [StorySearchResult] public var groupThreads: [TSGroupThread] { return groupResults.compactMap { $0.threadViewModel.threadRecord as? TSGroupThread } } public var storyThreads: [TSThread] { storyResults.map(\.thread) } } // MARK: - 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: - public class ConversationScreenSearchResultSet: NSObject { public let searchText: String public let messages: [MessageSearchResult] 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])" } } // MARK: - public class FullTextSearcher: NSObject { public static let kDefaultMaxResults: Int = 500 public static let shared: FullTextSearcher = FullTextSearcher() public func searchForRecipients( searchText: String, includeLocalUser: Bool, includeStories: Bool, maxResults: Int = kDefaultMaxResults, tx: SDSAnyReadTransaction ) -> RecipientSearchResultSet { var groupResults = [GroupSearchResult]() var storyResults = [StorySearchResult]() let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: tx.asV2Read) else { owsFail("Can't search if you've never been registered.") } var addresses = SearchableNameFinder( contactManager: contactsManager, searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer, phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher, recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable ).searchNames( for: searchText, maxResults: maxResults, localIdentifiers: localIdentifiers, tx: tx.asV2Read, checkCancellation: {}, addGroupThread: { groupThread in let sortKey = ConversationSortKey( isContactThread: false, creationDate: groupThread.creationDate, lastInteractionRowId: groupThread.lastInteractionRowId ) let threadViewModel = ThreadViewModel( thread: groupThread, forChatList: true, transaction: tx ) let searchResult = GroupSearchResult(threadViewModel: threadViewModel, sortKey: sortKey) groupResults.append(searchResult) if includeStories, groupThread.isStorySendEnabled(transaction: tx) { let searchResult = StorySearchResult(thread: groupThread, sortKey: sortKey) storyResults.append(searchResult) } }, addStoryThread: { storyThread in // Don't show disabled private story threads; these are queued up // to be deleted. if includeStories, storyThread.storyViewMode != .disabled { let sortKey = ConversationSortKey( isContactThread: false, creationDate: storyThread.creationDate, lastInteractionRowId: storyThread.lastInteractionRowId ) let searchResult = StorySearchResult(thread: storyThread, sortKey: sortKey) storyResults.append(searchResult) } } ) var contactResults: [ContactSearchResult] = [] addresses.removeAll(where: { $0 == localIdentifiers.aciAddress }) if includeLocalUser, noteToSelfMatch(searchText: searchText, localIdentifiers: localIdentifiers, tx: tx) != .none { contactResults.append(ContactSearchResult(recipientAddress: localIdentifiers.aciAddress, transaction: tx)) } for address in addresses { if profileManager.isUser(inProfileWhitelist: address, transaction: tx) { contactResults.append(ContactSearchResult(recipientAddress: address, transaction: tx)) } } return RecipientSearchResultSet( searchText: searchText, contactResults: contactResults.sorted(), groupResults: groupResults.sorted(by: >), storyResults: storyResults.sorted(by: >) ) } private enum NoteToSelfMatch { case nameOrNumber case noteToSelf case none } private func noteToSelfMatch(searchText: String, localIdentifiers: LocalIdentifiers, tx: SDSAnyReadTransaction) -> NoteToSelfMatch { let searchTerms = searchText.split(separator: " ") if searchTerms.contains(where: { localIdentifiers.phoneNumber.contains($0) }) { return .nameOrNumber } let displayName = contactsManager.displayName(for: localIdentifiers.aciAddress, tx: tx).resolvedValue() if searchTerms.contains(where: { displayName.contains($0) }) { return .nameOrNumber } if searchTerms.contains(where: { MessageStrings.noteToSelf.contains($0) }) { return .noteToSelf } return .none } public func searchForHomeScreen( searchText: String, maxResults: Int = kDefaultMaxResults, isCanceled: () -> Bool, transaction: SDSAnyReadTransaction ) -> HomeScreenSearchResultSet? { do { return try _searchForHomeScreen( searchText: searchText, maxResults: maxResults, isCanceled: isCanceled, transaction: transaction ) } catch is CancellationError { return nil } catch { owsFailDebug("Couldn't search: \(error)") return nil } } private func _searchForHomeScreen( searchText: String, maxResults: Int, isCanceled: () -> Bool, transaction: SDSAnyReadTransaction ) throws -> HomeScreenSearchResultSet? { var contactResults = [ContactSearchResult]() var contactThreadResults = [ConversationSearchResult]() var groupResults: [GroupSearchResult] = [] var groupThreadIds = Set() var messages: [UInt64: ConversationSearchResult] = [:] let nameResolver = NameResolverImpl(contactsManager: contactsManager) var threadCache = [String: TSThread?]() func fetchThread(threadUniqueId: String) -> T? { if let thread = threadCache[threadUniqueId] { return thread as? T } let thread = TSThread.anyFetch(uniqueId: threadUniqueId, transaction: transaction) threadCache[threadUniqueId] = thread return thread as? T } var threadViewModelCache = [String: ThreadViewModel]() func fetchThreadViewModel(for thread: TSThread) -> ThreadViewModel { if let threadViewModel = threadViewModelCache[thread.uniqueId] { return threadViewModel } let threadViewModel = ThreadViewModel( thread: thread, forChatList: true, transaction: transaction ) threadViewModelCache[thread.uniqueId] = threadViewModel return threadViewModel } func fetchGroupThreadIds(for address: SignalServiceAddress) -> [String] { return TSGroupThread.groupThreadIds(with: address, transaction: transaction) } func fetchMentionedMessages(for address: SignalServiceAddress) -> [TSMessage] { guard let aci = address.serviceId as? Aci else { return [] } return MentionFinder.messagesMentioning(aci: aci, tx: transaction) } func shouldIncludeResult(for thread: TSThread) -> Bool { return thread.shouldThreadBeVisible } func appendGroup(threadUniqueId: String, groupThread: @autoclosure () -> TSGroupThread?) { // Don't add threads multiple times. guard groupThreadIds.insert(threadUniqueId).inserted else { return } // Don't fetch the thread unless necessary. guard let groupThread = groupThread() else { owsFailDebug("Unexpectedly missing group thread.") return } guard shouldIncludeResult(for: groupThread) else { return } let threadViewModel = fetchThreadViewModel(for: groupThread) let sortKey = ConversationSortKey( isContactThread: false, creationDate: groupThread.creationDate, lastInteractionRowId: groupThread.lastInteractionRowId ) let searchResult = GroupSearchResult.withMatchedMembersSnippet( groupThread: groupThread, threadViewModel: threadViewModel, sortKey: sortKey, searchText: searchText, nameResolver: nameResolver, transaction: transaction ) groupResults.append(searchResult) } func appendMessage(_ message: TSMessage, snippet: CVTextValue?) { guard let thread: TSThread = fetchThread(threadUniqueId: message.uniqueThreadId) else { owsFailDebug("Missing thread: \(type(of: message))") return } let threadViewModel = fetchThreadViewModel(for: thread) let sortKey = message.sortId let searchResult = ConversationSearchResult( threadViewModel: 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 appendAddress( _ address: SignalServiceAddress, isInWhitelist: @autoclosure () -> Bool, fetchGroups: Bool, fetchMentions: Bool ) { if let contactThread = TSContactThread.getWithContactAddress(address, transaction: transaction), shouldIncludeResult(for: contactThread) { contactThreadResults.append(ConversationSearchResult( threadViewModel: fetchThreadViewModel(for: contactThread), sortKey: ConversationSortKey( isContactThread: true, creationDate: contactThread.creationDate, lastInteractionRowId: contactThread.lastInteractionRowId ) )) } else if isInWhitelist() { contactResults.append(ContactSearchResult(recipientAddress: address, transaction: transaction)) } if fetchGroups { fetchGroupThreadIds(for: address).forEach { groupThreadId in appendGroup( threadUniqueId: groupThreadId, groupThread: fetchThread(threadUniqueId: groupThreadId) ) } } if fetchMentions { fetchMentionedMessages(for: address).forEach { message in appendMessage(message, snippet: .messageBody(message.conversationListPreviewText(transaction))) } } } func remainingResultCount() -> Int { return max(0, maxResults - (groupResults.count + contactResults.count + contactThreadResults.count + messages.count)) } // We search for each type of result independently. 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. // Check if we've been canceled before running the first query. If we have // to wait a while for the database to be available, this search may have // already been canceled. guard !isCanceled() else { return nil } let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else { owsFail("Can't search if you've never been registered.") } var addresses = try SearchableNameFinder( contactManager: contactsManager, searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer, phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher, recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable ).searchNames( for: searchText, maxResults: remainingResultCount(), localIdentifiers: localIdentifiers, tx: transaction.asV2Read, checkCancellation: { if isCanceled() { throw CancellationError() } }, addGroupThread: { groupThread in appendGroup(threadUniqueId: groupThread.uniqueId, groupThread: groupThread) }, addStoryThread: { _ in } ) guard !isCanceled() else { return nil } addresses.removeAll(where: { $0 == localIdentifiers.aciAddress }) switch noteToSelfMatch(searchText: searchText, localIdentifiers: localIdentifiers, tx: transaction) { case .nameOrNumber: appendAddress(localIdentifiers.aciAddress, isInWhitelist: true, fetchGroups: true, fetchMentions: false) case .noteToSelf: appendAddress(localIdentifiers.aciAddress, isInWhitelist: true, fetchGroups: false, fetchMentions: false) case .none: break } guard !isCanceled() else { return nil } for address in addresses { appendAddress( address, isInWhitelist: profileManager.isUser(inProfileWhitelist: address, transaction: transaction), fetchGroups: true, fetchMentions: true ) } guard !isCanceled() else { return nil } FullTextSearchIndexer.search( for: searchText, maxResults: remainingResultCount(), tx: transaction ) { (message: TSMessage, snippet: String?, stop) in if isCanceled() || remainingResultCount() == 0 { stop = true return } let styledSnippet: CVTextValue? = { () -> CVTextValue? in guard let snippet else { return nil } let attributeKey = NSAttributedString.Key("OWSSearchMatch") let matchStyle = BonMot.StringStyle( .xmlRules([ .style(FullTextSearchIndexer.matchTag, StringStyle(.extraAttributes([attributeKey: 0]))) ]) ) let matchStyleApplied = snippet.styled(with: matchStyle) var styles = [NSRangedValue]() matchStyleApplied.enumerateAttributes(in: matchStyleApplied.entireRange, using: { attrs, range, _ in guard attrs[attributeKey] != nil else { return } styles.append(NSRangedValue(.bold, range: range)) }) let mergedMessageBody: MessageBody if let messageBody = message.conversationListSearchResultsBody(transaction) { mergedMessageBody = messageBody.mergeIntoFirstMatchOfStyledSubstring(matchStyleApplied.string, styles: styles) } else { let singleStyles = styles.flatMap { style in return style.value.contents.map { return NSRangedValue($0, range: style.range) } } mergedMessageBody = MessageBody(text: matchStyleApplied.string, ranges: .init(mentions: [:], styles: singleStyles)) } return .messageBody(mergedMessageBody .hydrating(mentionHydrator: ContactsMentionHydrator.mentionHydrator(transaction: transaction.asV2Read))) }() appendMessage(message, snippet: styledSnippet) } guard !isCanceled() else { return nil } // Order the conversation and message results in reverse chronological order. // Order "Other Contacts" by name. return HomeScreenSearchResultSet( searchText: searchText, contactThreadResults: contactThreadResults.sorted(by: >), groupThreadResults: groupResults.sorted(by: >), contactResults: contactResults.sorted(by: <), messageResults: messages.values.sorted(by: >) ) } public func searchWithinConversation( thread: TSThread, searchText: String, maxResults: Int = kDefaultMaxResults, transaction: SDSAnyReadTransaction ) -> ConversationScreenSearchResultSet { var messages: [UInt64: MessageSearchResult] = [:] func appendMessage(_ message: TSMessage) { let messageId = message.uniqueId let searchResult = MessageSearchResult(messageId: messageId, sortId: message.sortId) messages[message.sortId] = searchResult } FullTextSearchIndexer.search( for: searchText, maxResults: maxResults, tx: transaction ) { message, _, stop in guard messages.count < maxResults else { stop = true return } if message.uniqueThreadId == thread.uniqueId { appendMessage(message) } } let canSearchForMentions: Bool = thread is TSGroupThread if canSearchForMentions { let tsAccountManager = DependenciesBridge.shared.tsAccountManager guard let localIdentifiers = tsAccountManager.localIdentifiers(tx: transaction.asV2Read) else { owsFail("Can't search if you've never been registered.") } let addresses = SearchableNameFinder( contactManager: contactsManager, searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer, phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher, recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable ).searchNames( for: searchText, maxResults: maxResults - messages.count, localIdentifiers: localIdentifiers, tx: transaction.asV2Read, checkCancellation: {}, addGroupThread: { _ in }, addStoryThread: { _ in } ) for address in addresses { guard let aci = address.serviceId as? Aci else { continue } let messagesMentioningAccount = MentionFinder.messagesMentioning(aci: aci, in: thread, tx: transaction) messagesMentioningAccount.forEach { appendMessage($0) } } } // We want most recent first let sortedMessages = messages.values.sorted(by: >) return ConversationScreenSearchResultSet(searchText: searchText, messages: sortedMessages) } }