mirror of
https://github.com/oxen-io/session-ios.git
synced 2023-12-13 21:30:14 +01:00
WIP: Full Text Search
-[ ] indexes -[x] results: Contacts / Conversations / Messages -[ ] group thread -[x] group name -[ ] group member name -[ ] group member number -[ ] contact thread -[ ] name -[ ] number -[ ] messages -[ ] content - [ ] show search results: Contact / Conversation / Messages - [ ] tapping thread search result takes you to conversation - [ ] tapping message search result takes you to message - [ ] show snippet text for matched message - [ ] highlight matched text in thread - [ ] go to next search result in thread
This commit is contained in:
parent
a6200a5600
commit
429af7854a
4 changed files with 200 additions and 14 deletions
|
@ -320,7 +320,6 @@
|
||||||
45360B911F952AA900FA666C /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; };
|
45360B911F952AA900FA666C /* MarqueeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */; };
|
||||||
4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; };
|
4539B5861F79348F007141FF /* PushRegistrationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4539B5851F79348F007141FF /* PushRegistrationManager.swift */; };
|
||||||
4541B71D209D3B7A0008608F /* ContactShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */; };
|
4541B71D209D3B7A0008608F /* ContactShareViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */; };
|
||||||
4542DF52208B82E9007B4E76 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; };
|
|
||||||
4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */; };
|
4542DF54208D40AC007B4E76 /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF53208D40AC007B4E76 /* LoadingViewController.swift */; };
|
||||||
45464DBC1DFA041F001D3FD6 /* DataChannelMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */; };
|
45464DBC1DFA041F001D3FD6 /* DataChannelMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45464DBB1DFA041F001D3FD6 /* DataChannelMessage.swift */; };
|
||||||
454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A84032059C787008B8C75 /* MediaTileViewController.swift */; };
|
454A84042059C787008B8C75 /* MediaTileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 454A84032059C787008B8C75 /* MediaTileViewController.swift */; };
|
||||||
|
@ -412,6 +411,7 @@
|
||||||
45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBC59A1DF8575700E9B410 /* CallKitCallManager.swift */; };
|
45FBC5C81DF8575700E9B410 /* CallKitCallManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBC59A1DF8575700E9B410 /* CallKitCallManager.swift */; };
|
||||||
45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBC5D01DF8592E00E9B410 /* SignalCall.swift */; };
|
45FBC5D11DF8592E00E9B410 /* SignalCall.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45FBC5D01DF8592E00E9B410 /* SignalCall.swift */; };
|
||||||
4AC4EA13C8A444455DAB351F /* Pods_SignalMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 264242150E87D10A357DB07B /* Pods_SignalMessaging.framework */; };
|
4AC4EA13C8A444455DAB351F /* Pods_SignalMessaging.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 264242150E87D10A357DB07B /* Pods_SignalMessaging.framework */; };
|
||||||
|
4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */; };
|
||||||
70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; };
|
70377AAB1918450100CAF501 /* MobileCoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 70377AAA1918450100CAF501 /* MobileCoreServices.framework */; };
|
||||||
768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; };
|
768A1A2B17FC9CD300E00ED8 /* libz.dylib in Frameworks */ = {isa = PBXBuildFile; fileRef = 768A1A2A17FC9CD300E00ED8 /* libz.dylib */; };
|
||||||
76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; };
|
76C87F19181EFCE600C4ACAB /* MediaPlayer.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 76C87F18181EFCE600C4ACAB /* MediaPlayer.framework */; };
|
||||||
|
@ -1911,7 +1911,6 @@
|
||||||
4541B719209D2D860008608F /* ViewModels */ = {
|
4541B719209D2D860008608F /* ViewModels */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */,
|
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1922,6 +1921,7 @@
|
||||||
4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */,
|
4541B71A209D2DAE0008608F /* ContactShareViewModel.swift */,
|
||||||
459B7759207BA3A80071D0AB /* OWSQuotedReplyModel.h */,
|
459B7759207BA3A80071D0AB /* OWSQuotedReplyModel.h */,
|
||||||
459B775A207BA3A80071D0AB /* OWSQuotedReplyModel.m */,
|
459B775A207BA3A80071D0AB /* OWSQuotedReplyModel.m */,
|
||||||
|
4542DF51208B82E9007B4E76 /* ThreadViewModel.swift */,
|
||||||
);
|
);
|
||||||
path = ViewModels;
|
path = ViewModels;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -3086,6 +3086,7 @@
|
||||||
452EC6E1205FF5DC000E787C /* Bench.swift in Sources */,
|
452EC6E1205FF5DC000E787C /* Bench.swift in Sources */,
|
||||||
3478506C1FD9B78A007B8332 /* NoopNotificationsManager.swift in Sources */,
|
3478506C1FD9B78A007B8332 /* NoopNotificationsManager.swift in Sources */,
|
||||||
34480B621FD0A98800BC14EF /* UIColor+OWS.m in Sources */,
|
34480B621FD0A98800BC14EF /* UIColor+OWS.m in Sources */,
|
||||||
|
4C20B2B720CA0034001BAC90 /* ThreadViewModel.swift in Sources */,
|
||||||
34480B531FD0A7A400BC14EF /* OWSLogger.m in Sources */,
|
34480B531FD0A7A400BC14EF /* OWSLogger.m in Sources */,
|
||||||
34480B641FD0A98800BC14EF /* UIView+OWS.m in Sources */,
|
34480B641FD0A98800BC14EF /* UIView+OWS.m in Sources */,
|
||||||
34C3C7932040B0DD0000134C /* OWSAudioPlayer.m in Sources */,
|
34C3C7932040B0DD0000134C /* OWSAudioPlayer.m in Sources */,
|
||||||
|
@ -3191,7 +3192,6 @@
|
||||||
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
34D1F0501F7D45A60066283D /* GifPickerCell.swift in Sources */,
|
||||||
34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */,
|
34D99C931F2937CC00D284D6 /* OWSAnalytics.swift in Sources */,
|
||||||
340FC8B8204DAC8D007AEB0F /* AddToGroupViewController.m in Sources */,
|
340FC8B8204DAC8D007AEB0F /* AddToGroupViewController.m in Sources */,
|
||||||
4542DF52208B82E9007B4E76 /* ThreadViewModel.swift in Sources */,
|
|
||||||
341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */,
|
341F2C0F1F2B8AE700D07D6B /* DebugUIMisc.m in Sources */,
|
||||||
340FC8AF204DAC8D007AEB0F /* OWSLinkDeviceViewController.m in Sources */,
|
340FC8AF204DAC8D007AEB0F /* OWSLinkDeviceViewController.m in Sources */,
|
||||||
34E3EF0D1EFC235B007F6822 /* DebugUIDiskUsage.m in Sources */,
|
34E3EF0D1EFC235B007F6822 /* DebugUIDiskUsage.m in Sources */,
|
||||||
|
|
|
@ -1,11 +1,92 @@
|
||||||
//
|
//
|
||||||
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
@testable import Signal
|
@testable import Signal
|
||||||
@testable import SignalMessaging
|
@testable import SignalMessaging
|
||||||
|
|
||||||
|
class ConversationSearcherTest: XCTestCase {
|
||||||
|
|
||||||
|
// Mark: Dependencies
|
||||||
|
var searcher: ConversationSearcher {
|
||||||
|
return ConversationSearcher.shared
|
||||||
|
}
|
||||||
|
|
||||||
|
var dbConnection: YapDatabaseConnection {
|
||||||
|
return OWSPrimaryStorage.shared().dbReadWriteConnection
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark: Test Life Cycle
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
super.setUp()
|
||||||
|
|
||||||
|
ConversationFullTextSearchFinder.syncRegisterDatabaseExtension(storage: OWSPrimaryStorage.shared())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark: Tests
|
||||||
|
|
||||||
|
func testSearchByGroupName() {
|
||||||
|
|
||||||
|
TSGroupThread.removeAllObjectsInCollection()
|
||||||
|
|
||||||
|
var bookClubThread: ThreadViewModel!
|
||||||
|
var snackClubThread: ThreadViewModel!
|
||||||
|
self.dbConnection.readWrite { transaction in
|
||||||
|
let bookModel = TSGroupModel(title: "Book Club", memberIds: [], image: nil, groupId: Randomness.generateRandomBytes(16))
|
||||||
|
let bookClubGroupThread = TSGroupThread.getOrCreateThread(with: bookModel, transaction: transaction)
|
||||||
|
bookClubThread = ThreadViewModel(thread: bookClubGroupThread, transaction: transaction)
|
||||||
|
|
||||||
|
let snackModel = TSGroupModel(title: "Snack Club", memberIds: [], image: nil, groupId: Randomness.generateRandomBytes(16))
|
||||||
|
let snackClubGroupThread = TSGroupThread.getOrCreateThread(with: snackModel, transaction: transaction)
|
||||||
|
snackClubThread = ThreadViewModel(thread: snackClubGroupThread, transaction: transaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No Match
|
||||||
|
let noMatch = results(searchText: "asdasdasd")
|
||||||
|
XCTAssert(noMatch.conversations.isEmpty)
|
||||||
|
|
||||||
|
// Partial Match
|
||||||
|
let bookMatch = results(searchText: "Book")
|
||||||
|
XCTAssert(bookMatch.conversations.count == 1)
|
||||||
|
if let foundThread: ThreadViewModel = bookMatch.conversations.first?.thread {
|
||||||
|
XCTAssertEqual(bookClubThread, foundThread)
|
||||||
|
} else {
|
||||||
|
XCTFail("no thread found")
|
||||||
|
}
|
||||||
|
|
||||||
|
let snackMatch = results(searchText: "Snack")
|
||||||
|
XCTAssert(snackMatch.conversations.count == 1)
|
||||||
|
if let foundThread: ThreadViewModel = snackMatch.conversations.first?.thread {
|
||||||
|
XCTAssertEqual(snackClubThread, foundThread)
|
||||||
|
} else {
|
||||||
|
XCTFail("no thread found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple Partial Matches
|
||||||
|
let multipleMatch = results(searchText: "Club")
|
||||||
|
XCTAssert(multipleMatch.conversations.count == 2)
|
||||||
|
XCTAssert(multipleMatch.conversations.map { $0.thread }.contains(bookClubThread))
|
||||||
|
XCTAssert(multipleMatch.conversations.map { $0.thread }.contains(snackClubThread))
|
||||||
|
|
||||||
|
// Match Name Exactly
|
||||||
|
let exactMatch = results(searchText: "Book Club")
|
||||||
|
XCTAssert(exactMatch.conversations.count == 1)
|
||||||
|
XCTAssertEqual(bookClubThread, exactMatch.conversations.first!.thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark: Helpers
|
||||||
|
|
||||||
|
private func results(searchText: String) -> ConversationSearchResults {
|
||||||
|
var results: ConversationSearchResults!
|
||||||
|
self.dbConnection.read { transaction in
|
||||||
|
results = self.searcher.results(searchText: searchText, transaction: transaction)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class SearcherTest: XCTestCase {
|
class SearcherTest: XCTestCase {
|
||||||
|
|
||||||
struct TestCharacter {
|
struct TestCharacter {
|
||||||
|
|
|
@ -38,4 +38,13 @@ public class ThreadViewModel: NSObject {
|
||||||
self.unreadCount = thread.unreadMessageCount(transaction: transaction)
|
self.unreadCount = thread.unreadMessageCount(transaction: transaction)
|
||||||
self.hasUnreadMessages = unreadCount > 0
|
self.hasUnreadMessages = unreadCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
override public func isEqual(_ object: Any?) -> Bool {
|
||||||
|
guard let otherThread = object as? ThreadViewModel else {
|
||||||
|
return super.isEqual(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
return threadRecord.isEqual(otherThread.threadRecord)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,19 +1,67 @@
|
||||||
//
|
//
|
||||||
// Copyright (c) 2017 Open Whisper Systems. All rights reserved.
|
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SignalServiceKit
|
import SignalServiceKit
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public class ConversationSearchItem: NSObject {
|
||||||
|
@objc
|
||||||
|
public let thread: ThreadViewModel
|
||||||
|
|
||||||
|
init(thread: ThreadViewModel) {
|
||||||
|
self.thread = thread
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public class ConversationSearchResults: NSObject {
|
||||||
|
let conversations: [ConversationSearchItem]
|
||||||
|
let contacts: [ConversationSearchItem]
|
||||||
|
let messages: [ConversationSearchItem]
|
||||||
|
|
||||||
|
public init(conversations: [ConversationSearchItem], contacts: [ConversationSearchItem], messages: [ConversationSearchItem]) {
|
||||||
|
self.conversations = conversations
|
||||||
|
self.contacts = contacts
|
||||||
|
self.messages = messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public class ConversationSearcher: NSObject {
|
public class ConversationSearcher: NSObject {
|
||||||
|
|
||||||
|
private let finder: ConversationFullTextSearchFinder
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
public static let shared: ConversationSearcher = ConversationSearcher()
|
public static let shared: ConversationSearcher = ConversationSearcher()
|
||||||
override private init() {
|
override private init() {
|
||||||
|
finder = ConversationFullTextSearchFinder()
|
||||||
super.init()
|
super.init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc
|
||||||
|
public func results(searchText: String, transaction: YapDatabaseReadTransaction) -> ConversationSearchResults {
|
||||||
|
|
||||||
|
// TODO limit results, prioritize conversations, then contacts, then messages.
|
||||||
|
var conversations: [ConversationSearchItem] = []
|
||||||
|
var contacts: [ConversationSearchItem] = []
|
||||||
|
var messages: [ConversationSearchItem] = []
|
||||||
|
|
||||||
|
self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any) in
|
||||||
|
if let thread = match as? TSThread {
|
||||||
|
let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
||||||
|
let searchItem = ConversationSearchItem(thread: threadViewModel)
|
||||||
|
|
||||||
|
conversations.append(searchItem)
|
||||||
|
} else {
|
||||||
|
Logger.debug("\(self.logTag) in \(#function) unhandled item: \(match)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConversationSearchResults(conversations: conversations, contacts: contacts, messages: messages)
|
||||||
|
}
|
||||||
|
|
||||||
@objc(filterThreads:withSearchText:)
|
@objc(filterThreads:withSearchText:)
|
||||||
public func filterThreads(_ threads: [TSThread], searchText: String) -> [TSThread] {
|
public func filterThreads(_ threads: [TSThread], searchText: String) -> [TSThread] {
|
||||||
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else {
|
guard searchText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 else {
|
||||||
|
@ -58,6 +106,7 @@ public class ConversationSearcher: NSObject {
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|
||||||
// MARK: Searchers
|
// MARK: Searchers
|
||||||
|
|
||||||
private lazy var groupThreadSearcher: Searcher<TSGroupThread> = Searcher { (groupThread: TSGroupThread) in
|
private lazy var groupThreadSearcher: Searcher<TSGroupThread> = Searcher { (groupThread: TSGroupThread) in
|
||||||
let groupName = groupThread.groupModel.groupName
|
let groupName = groupThread.groupModel.groupName
|
||||||
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in
|
let memberStrings = groupThread.groupModel.groupMemberIds.map { recipientId in
|
||||||
|
@ -88,3 +137,50 @@ public class ConversationSearcher: NSObject {
|
||||||
return "\(recipientId) \(contactName) \(profileName ?? "")"
|
return "\(recipientId) \(contactName) \(profileName ?? "")"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue