From a9e2834d9f6fd0f6eaaa3708519d00aa65546372 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 7 Jun 2018 22:00:49 -0600 Subject: [PATCH] WIP: FTS - rudimentary show results -[] Backend -[] indexes e5.25 -[x] wire up results: Contacts / Conversations / Messages actual: 3hr -[ ] group thread est: actual: -[x] group name actual: e.25 -[ ] group member name: e.25 -[ ] group member number: e.25 -[ ] contact thread e.5 -[ ] name -[ ] number -[ ] messages e1 -[ ] content -[] Frontend e10.75 -[x] wire up VC's a.5 -[x] show search results only when search box has content a.25 -[] show search results: Contact / Conversation / Messages e2 -[x] wire up matchs -[] style contact cell -[] style conversation cell -[] style messages cell -[] tapping thread search result takes you to conversation e1 -[] tapping message search result takes you to message e1 -[] show snippet text for matched message e1 -[] highlight matched text in thread e3 -[] go to next search result in thread e2 -[] No Results page -[] Hide search unless pulled down --- .../ConversationSearchViewController.swift | 104 +++++++++++++++--- .../translations/en.lproj/Localizable.strings | 9 ++ .../utils/ConversationSearcher.swift | 98 ++++++++--------- .../src/Storage/FullTextSearchFinder.swift | 54 +++++++++ .../src/Storage/OWSPrimaryStorage.m | 5 +- SignalServiceKit/src/Storage/OWSStorage.m | 11 ++ 6 files changed, 213 insertions(+), 68 deletions(-) create mode 100644 SignalServiceKit/src/Storage/FullTextSearchFinder.swift diff --git a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift index 6e672b09c..38cb9f052 100644 --- a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift +++ b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift @@ -7,7 +7,16 @@ import Foundation @objc class ConversationSearchViewController: UITableViewController { - var searchResults: ConversationSearchResults = ConversationSearchResults.empty() + var searchResults: ConversationSearchResults = ConversationSearchResults.empty + + var uiDatabaseConnection: YapDatabaseConnection { + // TODO do we want to respond to YapDBModified? Might be hard when there's lots of search results, for only marginal value + return OWSPrimaryStorage.shared().uiDatabaseConnection + } + + var searcher: ConversationSearcher { + return ConversationSearcher.shared + } enum SearchSection: Int { case conversations = 0 @@ -21,12 +30,13 @@ class ConversationSearchViewController: UITableViewController { super.viewDidLoad() self.view.isHidden = true - self.view.backgroundColor = UIColor.yellow + + self.tableView.register(ChatSearchResultCell.self, forCellReuseIdentifier: ChatSearchResultCell.reuseIdentifier) } // MARK: UITableViewDelegate - override public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard let searchSection = SearchSection(rawValue: section) else { owsFail("unknown section: \(section)") return 0 @@ -42,22 +52,73 @@ class ConversationSearchViewController: UITableViewController { } } + class ChatSearchResultCell: UITableViewCell { + static let reuseIdentifier = "ChatSearchResultCell" + + func configure(searchResult: ConversationSearchItem) { + self.textLabel!.text = searchResult.thread.name + } + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + guard let searchSection = SearchSection(rawValue: indexPath.section) else { + return UITableViewCell() + } + + switch searchSection { + case .conversations: + guard let cell = tableView.dequeueReusableCell(withIdentifier: ChatSearchResultCell.reuseIdentifier) as? ChatSearchResultCell else { + return UITableViewCell() + } + + guard let searchResult = self.searchResults.conversations[safe: indexPath.row] else { + return UITableViewCell() + } + cell.configure(searchResult: searchResult) + return cell + case .contacts: + // TODO + return UITableViewCell() + case .messages: + // TODO + return UITableViewCell() + } + } + + override func numberOfSections(in tableView: UITableView) -> Int { + return 3 + } + + override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + guard let searchSection = SearchSection(rawValue: section) else { + owsFail("unknown section: \(section)") + return nil + } + + switch searchSection { + case .conversations: + if searchResults.conversations.count > 0 { + return NSLocalizedString("SEARCH_SECTION_CONVERSATIONS", comment: "section header for search results that match existing conversations (either group or contact conversations)") + } else { + return nil + } + case .contacts: + if searchResults.contacts.count > 0 { + return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "section header for search results that match a contact who doesn't have an existing conversation") + } else { + return nil + } + case .messages: + if searchResults.messages.count > 0 { + return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "section header for search results that match a message in a conversation") + } else { + return nil + } + } + } + /* - - // Row display. Implementers should *always* try to reuse cells by setting each cell's reuseIdentifier and querying for available reusable cells with dequeueReusableCellWithIdentifier: - // Cell gets various attributes set automatically based on table (separators) and data source (accessory views, editing controls) - - @available(iOS 2.0, *) - public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell - - - @available(iOS 2.0, *) - optional public func numberOfSections(in tableView: UITableView) -> Int // Default is 1 if not implemented - - - @available(iOS 2.0, *) - optional public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? // fixed font style. use custom view (UILabel) if you want something different - @available(iOS 2.0, *) optional public func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? @@ -118,11 +179,18 @@ extension ConversationSearchViewController: UISearchBarDelegate { public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { guard searchText.stripped.count > 0 else { + self.searchResults = ConversationSearchResults.empty self.view.isHidden = true return } self.view.isHidden = false + + self.uiDatabaseConnection.read { transaction in + self.searchResults = self.searcher.results(searchText: searchText, transaction: transaction) + } + // TODO: more perfomant way to do... + self.tableView.reloadData() } // diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 32ff71989..10a9e08af 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1753,6 +1753,15 @@ /* No comment provided by engineer. */ "SEARCH_BYNAMEORNUMBER_PLACEHOLDER_TEXT" = "Search by name or number"; +/* section header for search results that match a contact who doesn't have an existing conversation */ +"SEARCH_SECTION_CONTACTS" = "Other Contacts"; + +/* section header for search results that match existing conversations (either group or contact conversations) */ +"SEARCH_SECTION_CONVERSATIONS" = "Conversations"; + +/* section header for search results that match a message in a conversation */ +"SEARCH_SECTION_MESSAGES" = "Messages"; + /* No comment provided by engineer. */ "SECURE_SESSION_RESET" = "Secure session was reset."; diff --git a/SignalMessaging/utils/ConversationSearcher.swift b/SignalMessaging/utils/ConversationSearcher.swift index 2f0b16401..f7ade633f 100644 --- a/SignalMessaging/utils/ConversationSearcher.swift +++ b/SignalMessaging/utils/ConversationSearcher.swift @@ -26,7 +26,7 @@ public class ConversationSearchResults { self.messages = messages } - public class func empty() -> ConversationSearchResults { + public class var empty: ConversationSearchResults { return ConversationSearchResults(conversations: [], contacts: [], messages: []) } } @@ -34,12 +34,12 @@ public class ConversationSearchResults { @objc public class ConversationSearcher: NSObject { - private let finder: ConversationFullTextSearchFinder + private let finder: FullTextSearchFinder @objc public static let shared: ConversationSearcher = ConversationSearcher() override private init() { - finder = ConversationFullTextSearchFinder() + finder = FullTextSearchFinder() super.init() } @@ -140,49 +140,49 @@ public class ConversationSearcher: NSObject { } } -public class ConversationFullTextSearchFinder { - - public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any) -> Void) { - guard let ext = ext(transaction: transaction) else { - owsFail("ext was unexpectedly nil") - return - } - - ext.enumerateKeysAndObjects(matching: searchText) { (_, _, object, _) in - block(object) - } - } - - private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { - return transaction.ext(ConversationFullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction - } - - // MARK: - Extension Registration - - static let dbExtensionName: String = "ConversationFullTextSearchFinderExtension1" - - public class func asyncRegisterDatabaseExtension(storage: OWSStorage) { - storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName) - } - - // Only for testing. - public class func syncRegisterDatabaseExtension(storage: OWSStorage) { - storage.register(dbExtensionConfig, withName: dbExtensionName) - } - - private class var dbExtensionConfig: YapDatabaseFullTextSearch { - let contentColumnName = "content" - let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (dict: NSMutableDictionary, _: String, _: String, object: Any) in - if let groupThread = object as? TSGroupThread { - dict[contentColumnName] = groupThread.groupModel.groupName - } - } - - // update search index on contact name changes? - // update search index on message insertion? - - // TODO is it worth doing faceted search, i.e. Author / Name / Content? - // seems unlikely that mobile users would use the "author: Alice" search syntax. - return YapDatabaseFullTextSearch(columnNames: ["content"], handler: handler) - } -} +//public class ConversationFullTextSearchFinder { +// +// public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any) -> Void) { +// guard let ext = ext(transaction: transaction) else { +// owsFail("ext was unexpectedly nil") +// return +// } +// +// ext.enumerateKeysAndObjects(matching: searchText) { (_, _, object, _) in +// block(object) +// } +// } +// +// private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { +// return transaction.ext(ConversationFullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction +// } +// +// // MARK: - Extension Registration +// +// static let dbExtensionName: String = "ConversationFullTextSearchFinderExtension1" +// +// public class func asyncRegisterDatabaseExtension(storage: OWSStorage) { +// storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName) +// } +// +// // Only for testing. +// public class func syncRegisterDatabaseExtension(storage: OWSStorage) { +// storage.register(dbExtensionConfig, withName: dbExtensionName) +// } +// +// private class var dbExtensionConfig: YapDatabaseFullTextSearch { +// let contentColumnName = "content" +// let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (dict: NSMutableDictionary, _: String, _: String, object: Any) in +// if let groupThread = object as? TSGroupThread { +// dict[contentColumnName] = groupThread.groupModel.groupName +// } +// } +// +// // update search index on contact name changes? +// // update search index on message insertion? +// +// // TODO is it worth doing faceted search, i.e. Author / Name / Content? +// // seems unlikely that mobile users would use the "author: Alice" search syntax. +// return YapDatabaseFullTextSearch(columnNames: ["content"], handler: handler) +// } +//} diff --git a/SignalServiceKit/src/Storage/FullTextSearchFinder.swift b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift new file mode 100644 index 000000000..3cebd2d84 --- /dev/null +++ b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift @@ -0,0 +1,54 @@ +// +// Copyright (c) 2018 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public class FullTextSearchFinder: NSObject { + + public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any) -> Void) { + guard let ext = ext(transaction: transaction) else { + assertionFailure("ext was unexpectedly nil") + return + } + + ext.enumerateKeysAndObjects(matching: searchText) { (_, _, object, _) in + block(object) + } + } + + private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? { + return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction + } + + // MARK: - Extension Registration + + private static let dbExtensionName: String = "FullTextSearchFinderExtension" + + @objc + public class func asyncRegisterDatabaseExtension(storage: OWSStorage) { + storage.asyncRegister(dbExtensionConfig, withName: dbExtensionName) + } + + // Only for testing. + public class func syncRegisterDatabaseExtension(storage: OWSStorage) { + storage.register(dbExtensionConfig, withName: dbExtensionName) + } + + private class var dbExtensionConfig: YapDatabaseFullTextSearch { + let contentColumnName = "content" + let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (dict: NSMutableDictionary, _: String, _: String, object: Any) in + if let groupThread = object as? TSGroupThread { + dict[contentColumnName] = groupThread.groupModel.groupName + } + } + + // update search index on contact name changes? + // update search index on message insertion? + + // TODO is it worth doing faceted search, i.e. Author / Name / Content? + // seems unlikely that mobile users would use the "author: Alice" search syntax. + return YapDatabaseFullTextSearch(columnNames: ["content"], handler: handler) + } +} diff --git a/SignalServiceKit/src/Storage/OWSPrimaryStorage.m b/SignalServiceKit/src/Storage/OWSPrimaryStorage.m index c30519dd7..d264d5d56 100644 --- a/SignalServiceKit/src/Storage/OWSPrimaryStorage.m +++ b/SignalServiceKit/src/Storage/OWSPrimaryStorage.m @@ -16,6 +16,7 @@ #import "OWSStorage+Subclass.h" #import "TSDatabaseSecondaryIndexes.h" #import "TSDatabaseView.h" +#import NS_ASSUME_NONNULL_BEGIN @@ -58,13 +59,15 @@ void RunAsyncRegistrationsForStorage(OWSStorage *storage, dispatch_block_t compl [TSDatabaseView asyncRegisterThreadOutgoingMessagesDatabaseView:storage]; [TSDatabaseView asyncRegisterThreadSpecialMessagesDatabaseView:storage]; - // Register extensions which aren't essential for rendering threads async. + + [FullTextSearchFinder asyncRegisterDatabaseExtensionWithStorage:storage]; [OWSIncomingMessageFinder asyncRegisterExtensionWithPrimaryStorage:storage]; [TSDatabaseView asyncRegisterSecondaryDevicesDatabaseView:storage]; [OWSDisappearingMessagesFinder asyncRegisterDatabaseExtensions:storage]; [OWSFailedMessagesJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; [OWSFailedAttachmentDownloadsJob asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; [OWSMediaGalleryFinder asyncRegisterDatabaseExtensionsWithPrimaryStorage:storage]; + // NOTE: Always pass the completion to the _LAST_ of the async database // view registrations. [TSDatabaseView asyncRegisterLazyRestoreAttachmentsDatabaseView:storage completion:completion]; diff --git a/SignalServiceKit/src/Storage/OWSStorage.m b/SignalServiceKit/src/Storage/OWSStorage.m index 2145d78b9..8a61f5b2c 100644 --- a/SignalServiceKit/src/Storage/OWSStorage.m +++ b/SignalServiceKit/src/Storage/OWSStorage.m @@ -18,6 +18,8 @@ #import #import #import +#import +#import #import #import @@ -536,6 +538,15 @@ NSString *const kNSUserDefaults_DatabaseExtensionVersionMap = @"kNSUserDefaults_ extensionName:extensionName] options:secondaryIndex->options]; return secondaryIndexCopy; + } else if ([extension isKindOfClass:[YapDatabaseFullTextSearch class]]) { + YapDatabaseFullTextSearch *fullTextSearch = (YapDatabaseFullTextSearch *)extension; + + NSString *versionTag = [self appendSuffixToDatabaseExtensionVersionIfNecessary:fullTextSearch.versionTag extensionName:extensionName]; + YapDatabaseFullTextSearch *fullTextSearchCopy = [[YapDatabaseFullTextSearch alloc] initWithColumnNames:fullTextSearch->columnNames.array + handler:fullTextSearch->handler + versionTag:versionTag]; + + return fullTextSearchCopy; } else if ([extension isKindOfClass:[YapDatabaseCrossProcessNotification class]]) { // versionTag doesn't matter for YapDatabaseCrossProcessNotification. return extension;