diff --git a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift index 0ec65a0a1..dbd67f50e 100644 --- a/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift +++ b/Signal/src/ViewControllers/HomeView/ConversationSearchViewController.swift @@ -32,6 +32,7 @@ class ConversationSearchViewController: UITableViewController { self.view.isHidden = true self.tableView.register(ChatSearchResultCell.self, forCellReuseIdentifier: ChatSearchResultCell.reuseIdentifier) + self.tableView.register(MessageSearchResultCell.self, forCellReuseIdentifier: MessageSearchResultCell.reuseIdentifier) } // MARK: UITableViewDelegate @@ -55,8 +56,93 @@ class ConversationSearchViewController: UITableViewController { class ChatSearchResultCell: UITableViewCell { static let reuseIdentifier = "ChatSearchResultCell" + let nameLabel: UILabel + let snippetLabel: UILabel + let avatarView: AvatarImageView + + override init(style: UITableViewCellStyle, reuseIdentifier: String?) { + self.nameLabel = UILabel() + self.snippetLabel = UILabel() + self.avatarView = AvatarImageView() + avatarView.autoSetDimensions(to: CGSize(width: 40, height: 40)) + + super.init(style: style, reuseIdentifier: reuseIdentifier) + + 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") + } + func configure(searchResult: SearchResult) { - self.textLabel!.text = searchResult.thread.name + 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) + + 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 + +// [[NSAttributedString alloc] initWithData:[htmlString dataUsingEncoding:NSUTF8StringEncoding] +// options:@{NSDocumentTypeDocumentAttribute: NSHTMLTextDocumentType, +// NSCharacterEncodingDocumentAttribute: @(NSUTF8StringEncoding)} +// documentAttributes:nil error:nil]; + + guard let snippet = searchResult.snippet else { + self.snippetLabel.text = nil + return + } + + guard let encodedString = snippet.data(using: .utf8) else { + self.snippetLabel.text = nil + return + } + +// NSAttributedString(data: <#T##Data#>, options: [, ], documentAttributes: <#T##AutoreleasingUnsafeMutablePointer?#>) + // Bold snippet text + do { + + // TODO NSAttributedString.DocumentReadingOptionKey.characterEncoding: .utf8 + let attributedSnippet = try NSAttributedString(data: encodedString, + options: [NSAttributedString.DocumentReadingOptionKey.documentType: NSAttributedString.DocumentType.html], + documentAttributes: nil) + + self.snippetLabel.attributedText = attributedSnippet + } catch { + owsFail("failed to generate snippet: \(error)") + } } } @@ -79,10 +165,18 @@ class ConversationSearchViewController: UITableViewController { return cell case .contacts: // TODO - return UITableViewCell() + return UITableViewCell() case .messages: - // TODO + 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 } } diff --git a/Signal/test/util/SearcherTest.swift b/Signal/test/util/SearcherTest.swift index 924bd26cb..99744f9ad 100644 --- a/Signal/test/util/SearcherTest.swift +++ b/Signal/test/util/SearcherTest.swift @@ -26,6 +26,7 @@ class ConversationSearcherTest: XCTestCase { TSContactThread.removeAllObjectsInCollection() TSGroupThread.removeAllObjectsInCollection() + TSMessage.removeAllObjectsInCollection() self.dbConnection.readWrite { transaction in let bookModel = TSGroupModel(title: "Book Club", memberIds: [], image: nil, groupId: Randomness.generateRandomBytes(16)) @@ -41,6 +42,18 @@ class ConversationSearcherTest: XCTestCase { let bobContactThread = TSContactThread.getOrCreateThread(withContactId: "+49030183000", transaction: transaction) self.bobThread = ThreadViewModel(thread: bobContactThread, transaction: transaction) + + let helloAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Hello Alice", attachmentId: nil) + helloAlice.save(with: transaction) + + let goodbyeAlice = TSOutgoingMessage(in: aliceContactThread, messageBody: "Goodbye Alice", attachmentId: nil) + goodbyeAlice.save(with: transaction) + + let helloBookClub = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "Hello Book Club", attachmentId: nil) + helloBookClub.save(with: transaction) + + let goodbyeBookClub = TSOutgoingMessage(in: bookClubGroupThread, messageBody: "Goodbye Book Club", attachmentId: nil) + goodbyeBookClub.save(with: transaction) } } @@ -144,7 +157,7 @@ class ConversationSearcherTest: XCTestCase { XCTAssertEqual(aliceThread, resultSet.conversations.first?.thread) } - func testSearchContactByName() { + func pending_testSearchConversationByContactByName() { var resultSet: SearchResultSet = .empty resultSet = getResultSet(searchText: "Alice") @@ -160,6 +173,19 @@ class ConversationSearcherTest: XCTestCase { XCTAssertEqual(bobThread, resultSet.conversations.first?.thread) } + func testSearchMessageByBodyContent() { + var resultSet: SearchResultSet = .empty + + resultSet = getResultSet(searchText: "Hello Alice") + XCTAssertEqual(1, resultSet.messages.count) + XCTAssertEqual(aliceThread, resultSet.messages.first?.thread) + + resultSet = getResultSet(searchText: "Hello") + XCTAssertEqual(2, resultSet.messages.count) + XCTAssert(resultSet.messages.map { $0.thread }.contains(aliceThread)) + XCTAssert(resultSet.messages.map { $0.thread }.contains(bookClubThread)) + } + // Mark: Helpers private func getResultSet(searchText: String) -> SearchResultSet { diff --git a/SignalMessaging/utils/ConversationSearcher.swift b/SignalMessaging/utils/ConversationSearcher.swift index 6bdc5a1b6..93d8393fc 100644 --- a/SignalMessaging/utils/ConversationSearcher.swift +++ b/SignalMessaging/utils/ConversationSearcher.swift @@ -5,13 +5,13 @@ import Foundation import SignalServiceKit -@objc -public class SearchResult: NSObject { - @objc +public class SearchResult { public let thread: ThreadViewModel + public let snippet: String? - init(thread: ThreadViewModel) { + init(thread: ThreadViewModel, snippet: String?) { self.thread = thread + self.snippet = snippet } } @@ -50,12 +50,20 @@ public class ConversationSearcher: NSObject { var contacts: [SearchResult] = [] var messages: [SearchResult] = [] - self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any) in + 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 searchResult = SearchResult(thread: threadViewModel) + let snippet: String? = thread.lastMessageText(transaction: transaction) + let searchResult = SearchResult(thread: threadViewModel, snippet: snippet) conversations.append(searchResult) + } else if let message = match as? TSMessage { + let thread = message.thread(with: transaction) + + let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction) + let searchResult = SearchResult(thread: threadViewModel, snippet: snippet) + + messages.append(searchResult) } else { Logger.debug("\(self.logTag) in \(#function) unhandled item: \(match)") } diff --git a/SignalServiceKit/src/Storage/FullTextSearchFinder.swift b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift index 639389ec2..2f2247157 100644 --- a/SignalServiceKit/src/Storage/FullTextSearchFinder.swift +++ b/SignalServiceKit/src/Storage/FullTextSearchFinder.swift @@ -21,8 +21,8 @@ public class SearchIndexer { @objc public class FullTextSearchFinder: NSObject { - public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any) -> Void) { - guard let ext = ext(transaction: transaction) else { + 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 } @@ -33,8 +33,9 @@ public class FullTextSearchFinder: NSObject { // TODO a stricter "whole word" query for body text? let prefixQuery = "*\(normalized)*" - ext.enumerateKeysAndObjects(matching: prefixQuery) { (_, _, object, _) in - block(object) + // (snippet: String, collection: String, key: String, object: Any, stop: UnsafeMutablePointer) + ext.enumerateKeysAndObjects(matching: prefixQuery, with: nil) { (snippet: String, _: String, _: String, object: Any, _: UnsafeMutablePointer) in + block(object, snippet) } } @@ -82,11 +83,20 @@ public class FullTextSearchFinder: NSObject { return normalize(text: searchableContent) } + private static let messageIndexer: SearchIndexer = 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 }