mirror of https://github.com/oxen-io/session-ios
Merge branch 'mkirk/fts-search-results-controller'
commit
f118066b3f
@ -1 +1 @@
|
||||
Subproject commit 4fa9dbed3419fc81f5afbf17aa1e35d62656c72e
|
||||
Subproject commit 47c8a2611481201e17387b4335421f373f8c8e9b
|
@ -0,0 +1,244 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@objc
|
||||
class ConversationSearchViewController: UITableViewController {
|
||||
|
||||
var searchResultSet: SearchResultSet = SearchResultSet.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
|
||||
case contacts = 1
|
||||
case messages = 2
|
||||
}
|
||||
|
||||
// MARK: View Lifecyle
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
tableView.rowHeight = UITableViewAutomaticDimension
|
||||
tableView.estimatedRowHeight = 60
|
||||
|
||||
tableView.register(ConversationSearchResultCell.self, forCellReuseIdentifier: ConversationSearchResultCell.reuseIdentifier)
|
||||
tableView.register(MessageSearchResultCell.self, forCellReuseIdentifier: MessageSearchResultCell.reuseIdentifier)
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
|
||||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let searchSection = SearchSection(rawValue: section) else {
|
||||
owsFail("unknown section: \(section)")
|
||||
return 0
|
||||
}
|
||||
|
||||
switch searchSection {
|
||||
case .conversations:
|
||||
return searchResultSet.conversations.count
|
||||
case .contacts:
|
||||
return searchResultSet.contacts.count
|
||||
case .messages:
|
||||
return searchResultSet.messages.count
|
||||
}
|
||||
}
|
||||
|
||||
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: ConversationSearchResultCell.reuseIdentifier) as? ConversationSearchResultCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
|
||||
guard let searchResult = self.searchResultSet.conversations[safe: indexPath.row] else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
cell.configure(searchResult: searchResult)
|
||||
return cell
|
||||
case .contacts:
|
||||
// TODO
|
||||
return UITableViewCell()
|
||||
case .messages:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: MessageSearchResultCell.reuseIdentifier) as? MessageSearchResultCell else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
|
||||
guard let searchResult = self.searchResultSet.messages[safe: indexPath.row] else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
|
||||
cell.configure(searchResult: searchResult)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
||||
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 searchResultSet.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 searchResultSet.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 searchResultSet.messages.count > 0 {
|
||||
return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "section header for search results that match a message in a conversation")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: UISearchBarDelegate
|
||||
|
||||
@objc
|
||||
public func updateSearchResults(searchText: String) {
|
||||
guard searchText.stripped.count > 0 else {
|
||||
self.searchResultSet = SearchResultSet.empty
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: async?
|
||||
// TODO: debounce?
|
||||
|
||||
self.uiDatabaseConnection.read { transaction in
|
||||
self.searchResultSet = self.searcher.results(searchText: searchText, transaction: transaction)
|
||||
}
|
||||
|
||||
// TODO: more perfomant way to do this?
|
||||
self.tableView.reloadData()
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationSearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "ConversationSearchResultCell"
|
||||
|
||||
let nameLabel: UILabel
|
||||
let snippetLabel: UILabel
|
||||
let avatarView: AvatarImageView
|
||||
let avatarWidth: UInt = 40
|
||||
|
||||
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
|
||||
self.nameLabel = UILabel()
|
||||
self.snippetLabel = UILabel()
|
||||
self.avatarView = AvatarImageView()
|
||||
avatarView.autoSetDimensions(to: CGSize(width: CGFloat(avatarWidth), height: CGFloat(avatarWidth)))
|
||||
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
nameLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight()
|
||||
snippetLabel.font = UIFont.ows_dynamicTypeFootnote
|
||||
|
||||
let textRows = UIStackView(arrangedSubviews: [nameLabel, snippetLabel])
|
||||
textRows.axis = .vertical
|
||||
|
||||
let columns = UIStackView(arrangedSubviews: [avatarView, textRows])
|
||||
columns.axis = .horizontal
|
||||
columns.spacing = 8
|
||||
|
||||
contentView.addSubview(columns)
|
||||
columns.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
var contactsManager: OWSContactsManager {
|
||||
return Environment.current().contactsManager
|
||||
}
|
||||
|
||||
func configure(searchResult: SearchResult) {
|
||||
self.avatarView.image = OWSAvatarBuilder.buildImage(thread: searchResult.thread.threadRecord, diameter: avatarWidth, contactsManager: self.contactsManager)
|
||||
self.nameLabel.text = searchResult.thread.name
|
||||
self.snippetLabel.text = searchResult.snippet
|
||||
}
|
||||
}
|
||||
|
||||
class MessageSearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "MessageSearchResultCell"
|
||||
|
||||
let nameLabel: UILabel
|
||||
let snippetLabel: UILabel
|
||||
|
||||
override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
|
||||
self.nameLabel = UILabel()
|
||||
self.snippetLabel = UILabel()
|
||||
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
nameLabel.font = UIFont.ows_dynamicTypeBody.ows_mediumWeight()
|
||||
snippetLabel.font = UIFont.ows_dynamicTypeFootnote
|
||||
|
||||
let textRows = UIStackView(arrangedSubviews: [nameLabel, snippetLabel])
|
||||
textRows.axis = .vertical
|
||||
|
||||
contentView.addSubview(textRows)
|
||||
textRows.autoPinEdgesToSuperviewMargins()
|
||||
}
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func configure(searchResult: SearchResult) {
|
||||
self.nameLabel.text = searchResult.thread.name
|
||||
|
||||
guard let snippet = searchResult.snippet else {
|
||||
self.snippetLabel.text = nil
|
||||
return
|
||||
}
|
||||
|
||||
guard let encodedString = snippet.data(using: .utf8) else {
|
||||
self.snippetLabel.text = nil
|
||||
return
|
||||
}
|
||||
|
||||
// Bold snippet text
|
||||
do {
|
||||
|
||||
// FIXME - The snippet marks up the matched search text with <b> tags.
|
||||
// We can parse this into an attributed string, but it also takes on an undesirable font.
|
||||
// We want to apply our own font without clobbering bold in the process - maybe by enumerating and inspecting the attributes? Or maybe we can pass in a base font?
|
||||
let attributedSnippet = try NSMutableAttributedString(data: encodedString,
|
||||
options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html],
|
||||
documentAttributes: nil)
|
||||
attributedSnippet.addAttribute(NSAttributedStringKey.font, value: self.snippetLabel.font, range: NSRange(location: 0, length: attributedSnippet.length))
|
||||
|
||||
self.snippetLabel.attributedText = attributedSnippet
|
||||
} catch {
|
||||
owsFail("failed to generate snippet: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,149 @@
|
||||
//
|
||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// Create a searchable index for objects of type T
|
||||
public class SearchIndexer<T> {
|
||||
|
||||
private let indexBlock: (T) -> String
|
||||
|
||||
public init(indexBlock: @escaping (T) -> String) {
|
||||
self.indexBlock = indexBlock
|
||||
}
|
||||
|
||||
public func index(_ item: T) -> String {
|
||||
return indexBlock(item)
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
public class FullTextSearchFinder: NSObject {
|
||||
|
||||
public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) {
|
||||
guard let ext: YapDatabaseFullTextSearchTransaction = ext(transaction: transaction) else {
|
||||
assertionFailure("ext was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
let normalized = FullTextSearchFinder.normalize(text: searchText)
|
||||
|
||||
// We want a forgiving query for phone numbers
|
||||
// TODO a stricter "whole word" query for body text?
|
||||
let prefixQuery = "*\(normalized)*"
|
||||
|
||||
let maxSearchResults = 500
|
||||
var searchResultCount = 0
|
||||
// (snippet: String, collection: String, key: String, object: Any, stop: UnsafeMutablePointer<ObjCBool>)
|
||||
ext.enumerateKeysAndObjects(matching: prefixQuery, with: nil) { (snippet: String, _: String, _: String, object: Any, stop: UnsafeMutablePointer<ObjCBool>) in
|
||||
guard searchResultCount < maxSearchResults else {
|
||||
stop.pointee = true
|
||||
return
|
||||
}
|
||||
searchResultCount = searchResultCount + 1
|
||||
|
||||
block(object, snippet)
|
||||
}
|
||||
}
|
||||
|
||||
private func ext(transaction: YapDatabaseReadTransaction) -> YapDatabaseFullTextSearchTransaction? {
|
||||
return transaction.ext(FullTextSearchFinder.dbExtensionName) as? YapDatabaseFullTextSearchTransaction
|
||||
}
|
||||
|
||||
// Mark: Index Building
|
||||
|
||||
private class var contactsManager: ContactsManagerProtocol {
|
||||
return TextSecureKitEnv.shared().contactsManager
|
||||
}
|
||||
|
||||
private class func normalize(text: String) -> String {
|
||||
var normalized: String = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Remove any phone number formatting from the search terms
|
||||
let nonformattingScalars = normalized.unicodeScalars.lazy.filter {
|
||||
!CharacterSet.punctuationCharacters.contains($0)
|
||||
}
|
||||
|
||||
normalized = String(String.UnicodeScalarView(nonformattingScalars))
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
private static let groupThreadIndexer: SearchIndexer<TSGroupThread> = SearchIndexer { (groupThread: TSGroupThread) in
|
||||
let groupName = groupThread.groupModel.groupName ?? ""
|
||||
|
||||
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in
|
||||
recipientIndexer.index(recipientId)
|
||||
}.joined(separator: " ")
|
||||
|
||||
let searchableContent = "\(groupName) \(memberStrings)"
|
||||
|
||||
return normalize(text: searchableContent)
|
||||
}
|
||||
|
||||
private static let contactThreadIndexer: SearchIndexer<TSContactThread> = SearchIndexer { (contactThread: TSContactThread) in
|
||||
let recipientId = contactThread.contactIdentifier()
|
||||
let searchableContent = recipientIndexer.index(recipientId)
|
||||
|
||||
return normalize(text: searchableContent)
|
||||
}
|
||||
|
||||
private static let recipientIndexer: SearchIndexer<String> = SearchIndexer { (recipientId: String) in
|
||||
let displayName = contactsManager.displayName(forPhoneIdentifier: recipientId)
|
||||
let searchableContent = "\(recipientId) \(displayName)"
|
||||
|
||||
return normalize(text: searchableContent)
|
||||
}
|
||||
|
||||
private static let messageIndexer: SearchIndexer<TSMessage> = SearchIndexer { (message: TSMessage) in
|
||||
let searchableContent = message.body ?? ""
|
||||
|
||||
return normalize(text: searchableContent)
|
||||
}
|
||||
|
||||
private class func indexContent(object: Any) -> String? {
|
||||
if let groupThread = object as? TSGroupThread {
|
||||
return self.groupThreadIndexer.index(groupThread)
|
||||
} else if let contactThread = object as? TSContactThread {
|
||||
return self.contactThreadIndexer.index(contactThread)
|
||||
} else if let message = object as? TSMessage {
|
||||
return self.messageIndexer.index(message)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Extension Registration
|
||||
|
||||
// MJK - FIXME - while developing it's helpful to rebuild the index every launch. But we need to remove this before releasing.
|
||||
private static let dbExtensionName: String = "FullTextSearchFinderExtension\(Date())"
|
||||
|
||||
@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 {
|
||||
// TODO is it worth doing faceted search, i.e. Author / Name / Content?
|
||||
// seems unlikely that mobile users would use the "author: Alice" search syntax.
|
||||
// so for now, everything searchable is jammed into a single column
|
||||
let contentColumnName = "content"
|
||||
|
||||
let handler = YapDatabaseFullTextSearchHandler.withObjectBlock { (dict: NSMutableDictionary, _: String, _: String, object: Any) in
|
||||
if let content: String = indexContent(object: object) {
|
||||
dict[contentColumnName] = content
|
||||
}
|
||||
}
|
||||
|
||||
// update search index on contact name changes?
|
||||
// update search index on message insertion?
|
||||
|
||||
return YapDatabaseFullTextSearch(columnNames: ["content"], handler: handler)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue