TM-SGNL-iOS/SignalUI/Search/FullTextSearcher.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

748 lines
28 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// Copyright 2017 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import BonMot
import Foundation
import LibSignalClient
public 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<SortKey>: 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 {
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: SSKEnvironment.shared.contactManagerRef.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<ConversationSortKey>]
public let groupThreadResults: [GroupSearchResult]
public let contactResults: [ContactSearchResult]
public let messageResults: [ConversationSearchResult<MessageSortKey>]
public init(
searchText: String,
contactThreadResults: [ConversationSearchResult<ConversationSortKey>],
groupThreadResults: [GroupSearchResult],
contactResults: [ContactSearchResult],
messageResults: [ConversationSearchResult<MessageSortKey>]
) {
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: SSKEnvironment.shared.contactManagerRef,
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 SSKEnvironment.shared.profileManagerRef.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 = SSKEnvironment.shared.contactManagerRef.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<ConversationSortKey>]()
var groupResults: [GroupSearchResult] = []
var groupThreadIds = Set<String>()
var messages: [UInt64: ConversationSearchResult<MessageSortKey>] = [:]
let nameResolver = NameResolverImpl(contactsManager: SSKEnvironment.shared.contactManagerRef)
var threadCache = [String: TSThread?]()
func fetchThread<T: TSThread>(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: Date(millisecondsSince1970: 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: SSKEnvironment.shared.contactManagerRef,
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: SSKEnvironment.shared.profileManagerRef.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<MessageBodyRanges.Style>]()
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: SSKEnvironment.shared.contactManagerRef,
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)
}
}