diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 97ce6a4b5..0f0b70c63 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -140,6 +140,8 @@ 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; }; 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; }; 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */; }; + 7BA9057C278E58B300998B3C /* HomeVC+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA9057B278E58B300998B3C /* HomeVC+Search.swift */; }; + 7BA9057E27911C5800998B3C /* GlobalSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BA9057D27911C5800998B3C /* GlobalSearch.swift */; }; 7BC01A3E241F40AB00BC7C55 /* NotificationServiceExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */; }; 7BC01A42241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7BDCFC08242186E700641C39 /* NotificationServiceExtensionContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDCFC07242186E700641C39 /* NotificationServiceExtensionContext.swift */; }; @@ -1107,6 +1109,8 @@ 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = ""; }; 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = ""; }; 7B7CB18A270591630079FF93 /* ShareLogsModal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareLogsModal.swift; sourceTree = ""; }; + 7BA9057B278E58B300998B3C /* HomeVC+Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeVC+Search.swift"; sourceTree = ""; }; + 7BA9057D27911C5800998B3C /* GlobalSearch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalSearch.swift; sourceTree = ""; }; 7BC01A3B241F40AB00BC7C55 /* SessionNotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = SessionNotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 7BC01A3D241F40AB00BC7C55 /* NotificationServiceExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationServiceExtension.swift; sourceTree = ""; }; 7BC01A3F241F40AB00BC7C55 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -2756,7 +2760,9 @@ isa = PBXGroup; children = ( B8BB82A4238F627000BA5194 /* HomeVC.swift */, + 7BA9057B278E58B300998B3C /* HomeVC+Search.swift */, B83F2B85240C7B8F000A54AB /* NewConversationButtonSet.swift */, + 7BA9057D27911C5800998B3C /* GlobalSearch.swift */, ); path = Home; sourceTree = ""; @@ -4777,6 +4783,7 @@ 7B7CB18B270591630079FF93 /* ShareLogsModal.swift in Sources */, 4C4AEC4520EC343B0020E72B /* DismissableTextField.swift in Sources */, 3496955E219B605E00DCFE74 /* PhotoLibrary.swift in Sources */, + 7BA9057C278E58B300998B3C /* HomeVC+Search.swift in Sources */, C3A76A8D25DB83F90074CB90 /* PermissionMissingModal.swift in Sources */, 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */, B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */, @@ -4811,6 +4818,7 @@ B821494625D4D6FF009C0F2A /* URLModal.swift in Sources */, C374EEEB25DA3CA70073A857 /* ConversationTitleView.swift in Sources */, B88FA7F2260C3EB10049422F /* OpenGroupSuggestionGrid.swift in Sources */, + 7BA9057E27911C5800998B3C /* GlobalSearch.swift in Sources */, 4CA485BB2232339F004B9E7D /* PhotoCaptureViewController.swift in Sources */, 34330AA31E79686200DF2FB9 /* OWSProgressView.m in Sources */, 344825C6211390C800DB4BD8 /* OWSOrphanDataCleaner.m in Sources */, diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 0a58c831c..ea9aaef47 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -538,6 +538,7 @@ final class ConversationVC : BaseVC, ConversationViewModelDelegate, OWSConversat func showSearchUI() { isShowingSearchUI = true // Search bar + // FIXME: This code is duplicated with SearchBar let searchBar = searchController.uiSearchController.searchBar searchBar.searchBarStyle = .minimal searchBar.barStyle = .black diff --git a/Session/Home/GlobalSearch.swift b/Session/Home/GlobalSearch.swift new file mode 100644 index 000000000..0c005e58a --- /dev/null +++ b/Session/Home/GlobalSearch.swift @@ -0,0 +1,269 @@ +// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +@objc +public protocol GlobalSearchViewDelegate: AnyObject { + func globalSearchViewWillBeginDragging() + + func globalSearchDidSelectSearchResult(thread: ThreadViewModel, messageId: String?) +} + +@objc +public class GlobalSearchViewController: UITableViewController { + + @objc + public weak var delegate: GlobalSearchViewDelegate? + + @objc + public var searchText = "" { + didSet { + AssertIsOnMainThread() + + // Use a slight delay to debounce updates. + refreshSearchResults() + } + } + + var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty + private var lastSearchText: String? + + var searcher: FullTextSearcher { + return FullTextSearcher.shared + } + + enum SearchSection: Int { + case noResults + case contacts + case messages + } + + // MARK: Dependencies + + var dbReadConnection: YapDatabaseConnection { + return OWSPrimaryStorage.shared().dbReadConnection + } + + // MARK: View Lifecycle + + init() { + super.init(style: .grouped) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func viewDidLoad() { + super.viewDidLoad() + + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 60 + tableView.separatorColor = .clear + tableView.separatorInset = .zero + tableView.separatorStyle = .none + + tableView.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier) + tableView.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) + + } + + private func reloadTableData() { + tableView.reloadData() + } + + // MARK: Update Search Results + + var refreshTimer: Timer? + + private func refreshSearchResults() { + + guard !searchResultSet.isEmpty else { + // To avoid incorrectly showing the "no results" state, + // always search immediately if the current result set is empty. + refreshTimer?.invalidate() + refreshTimer = nil + + updateSearchResults(searchText: searchText) + return + } + + if refreshTimer != nil { + // Don't start a new refresh timer if there's already one active. + return + } + + refreshTimer?.invalidate() + refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in + guard let strongSelf = self else { + return + } + + strongSelf.updateSearchResults(searchText: strongSelf.searchText) + strongSelf.refreshTimer = nil + } + } + + private func updateSearchResults(searchText rawSearchText: String) { + + let searchText = rawSearchText.stripped + guard searchText.count > 0 else { + searchResultSet = HomeScreenSearchResultSet.empty + lastSearchText = nil + reloadTableData() + return + } + guard lastSearchText != searchText else { + // Ignoring redundant search. + return + } + + lastSearchText = searchText + + var searchResults: HomeScreenSearchResultSet? + self.dbReadConnection.asyncRead({[weak self] transaction in + guard let strongSelf = self else { return } + searchResults = strongSelf.searcher.searchForHomeScreen(searchText: searchText, transaction: transaction) + }, completionBlock: { [weak self] in + AssertIsOnMainThread() + guard let self = self else { return } + + guard let results = searchResults else { + owsFailDebug("searchResults was unexpectedly nil") + return + } + guard self.lastSearchText == searchText else { + // Discard results from stale search. + return + } + + self.searchResultSet = results + self.reloadTableData() + }) + } + +} + +// MARK: - UITableView +extension GlobalSearchViewController { + + // MARK: UITableViewDelegate + + public override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: false) + + guard let searchSection = SearchSection(rawValue: indexPath.section) else { return } + + switch searchSection { + case .noResults: + SNLog("shouldn't be able to tap 'no results' section") + case .contacts: + let sectionResults = searchResultSet.conversations + guard let searchResult = sectionResults[safe: indexPath.row] else { return } + delegate?.globalSearchDidSelectSearchResult(thread: searchResult.thread, messageId: searchResult.messageId) + case .messages: + let sectionResults = searchResultSet.messages + guard let searchResult = sectionResults[safe: indexPath.row] else { return } + delegate?.globalSearchDidSelectSearchResult(thread: searchResult.thread, messageId: searchResult.messageId) + } + } + + // MARK: UITableViewDataSource + + public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let searchSection = SearchSection(rawValue: section) else { return 0 } + switch searchSection { + case .noResults: + return searchResultSet.isEmpty ? 1 : 0 + case .contacts: + return searchResultSet.conversations.count + case .messages: + return searchResultSet.messages.count + } + } + + public override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return UITableView.automaticDimension + } + + public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + + guard let searchSection = SearchSection(rawValue: indexPath.section) else { + return UITableViewCell() + } + + switch searchSection { + case .noResults: + guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() } + cell.configure(searchText: searchText) + return cell + case .contacts, .messages: + // TODO: return correct cell + guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell else { return UITableViewCell() } + cell.configure(searchText: searchText) + return cell + } + } +} + +// MARK: - UIScrollViewDelegate + +extension GlobalSearchViewController { + public override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + delegate?.globalSearchViewWillBeginDragging() + } +} + +// MARK: - + +class EmptySearchResultCell: UITableViewCell { + static let reuseIdentifier = "EmptySearchResultCell" + + let messageLabel: UILabel + let activityIndicator = UIActivityIndicatorView(style: .whiteLarge) + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + self.messageLabel = UILabel() + super.init(style: style, reuseIdentifier: reuseIdentifier) + + messageLabel.textAlignment = .center + messageLabel.numberOfLines = 3 + + contentView.addSubview(messageLabel) + + messageLabel.autoSetDimension(.height, toSize: 150) + + messageLabel.autoPinEdge(toSuperviewMargin: .top, relation: .greaterThanOrEqual) + messageLabel.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual) + messageLabel.autoPinEdge(toSuperviewMargin: .bottom, relation: .greaterThanOrEqual) + messageLabel.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual) + + messageLabel.autoVCenterInSuperview() + messageLabel.autoHCenterInSuperview() + + messageLabel.setContentHuggingHigh() + messageLabel.setCompressionResistanceHigh() + + contentView.addSubview(activityIndicator) + activityIndicator.autoCenterInSuperview() + } + + required init?(coder aDecoder: NSCoder) { + notImplemented() + } + + public func configure(searchText: String) { + if searchText.isEmpty { + activityIndicator.color = Colors.text + activityIndicator.isHidden = false + activityIndicator.startAnimating() + messageLabel.isHidden = true + messageLabel.text = nil + } else { + activityIndicator.stopAnimating() + activityIndicator.isHidden = true + messageLabel.isHidden = false + messageLabel.text = NSLocalizedString("CONVERSATION_SEARCH_NO_RESULTS", comment: "") + messageLabel.textColor = Colors.text + } + } +} diff --git a/Session/Home/HomeVC+Search.swift b/Session/Home/HomeVC+Search.swift new file mode 100644 index 000000000..53c763d7b --- /dev/null +++ b/Session/Home/HomeVC+Search.swift @@ -0,0 +1,26 @@ +import UIKit + +extension HomeVC: UISearchBarDelegate, GlobalSearchViewDelegate { + + func GlobalSearchViewWillBeginDragging() { + + } + + // MARK: UISearchBarDelegate + + func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { + self.ensureSearchBarCancelButton() + } + + func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { + searchBar.text = nil + searchBar.resignFirstResponder() + self.ensureSearchBarCancelButton() + } + + func ensureSearchBarCancelButton() { + let shouldShowCancelButton = searchBar.isFirstResponder + guard searchBar.showsCancelButton != shouldShowCancelButton else { return } + self.searchBar.setShowsCancelButton(shouldShowCancelButton, animated: true) + } +} diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index ef46c96bb..1d720ff13 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -30,6 +30,18 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv result.delegate = self return result }() + + internal lazy var searchBar: SearchBar = { + let result = SearchBar() + result.delegate = self + return result + }() + + internal lazy var searchController: GlobalSearchViewController = { + let result = GlobalSearchViewController() + result.delegate = self + return result + }() private lazy var tableView: UITableView = { let result = UITableView() @@ -157,6 +169,15 @@ final class HomeVC : BaseVC, UITableViewDataSource, UITableViewDelegate, NewConv } // Get default open group rooms if needed OpenGroupAPIV2.getDefaultRoomsIfNeeded() + // Search + let searchBarContainer = UIView() + searchBarContainer.layoutMargins = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 8) + searchBar.sizeToFit() + searchBar.layoutMargins = UIEdgeInsets.zero + searchBarContainer.frame = searchBar.frame + searchBarContainer.addSubview(searchBar) + searchBar.autoPinEdgesToSuperviewMargins() + tableView.tableHeaderView = searchBarContainer } override func viewDidAppear(_ animated: Bool) { diff --git a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift index 96b112024..44913f7a9 100644 --- a/SessionMessagingKit/Utilities/FullTextSearchFinder.swift +++ b/SessionMessagingKit/Utilities/FullTextSearchFinder.swift @@ -85,14 +85,14 @@ public class FullTextSearchFinder: NSObject { return query } - public func enumerateObjects(searchText: String, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) { + public func enumerateObjects(searchText: String, maxSearchResults: Int? = nil, transaction: YapDatabaseReadTransaction, block: @escaping (Any, String) -> Void) { guard let ext: YapDatabaseFullTextSearchTransaction = ext(transaction: transaction) else { return } let query = FullTextSearchFinder.query(searchText: searchText) - let maxSearchResults = 500 + let maxSearchResults = maxSearchResults ?? 500 var searchResultCount = 0 let snippetOptions = YapDatabaseFullTextSearchSnippetOptions() snippetOptions.startMatchText = "" diff --git a/SessionUIKit/Components/SearchBar.swift b/SessionUIKit/Components/SearchBar.swift index 9897546d7..aab982f5f 100644 --- a/SessionUIKit/Components/SearchBar.swift +++ b/SessionUIKit/Components/SearchBar.swift @@ -33,12 +33,5 @@ public final class SearchBar : UISearchBar { setPositionAdjustment(UIOffset(horizontal: 4, vertical: 0), for: UISearchBar.Icon.search) searchTextPositionAdjustment = UIOffset(horizontal: 2, vertical: 0) setPositionAdjustment(UIOffset(horizontal: -4, vertical: 0), for: UISearchBar.Icon.clear) - searchTextField.removeConstraints(searchTextField.constraints) - searchTextField.pin(.leading, to: .leading, of: searchTextField.superview!, withInset: Values.mediumSpacing + 3) - searchTextField.pin(.top, to: .top, of: searchTextField.superview!, withInset: 10) - searchTextField.superview!.pin(.trailing, to: .trailing, of: searchTextField, withInset: Values.mediumSpacing + 3) - searchTextField.superview!.pin(.bottom, to: .bottom, of: searchTextField, withInset: 10) - searchTextField.set(.height, to: Values.searchBarHeight) - searchTextField.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing) } } diff --git a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift index 438a46212..4b1548eef 100644 --- a/SignalUtilitiesKit/Messaging/FullTextSearcher.swift +++ b/SignalUtilitiesKit/Messaging/FullTextSearcher.swift @@ -227,6 +227,7 @@ public class FullTextSearcher: NSObject { } public func searchForHomeScreen(searchText: String, + maxSearchResults: Int? = nil, transaction: YapDatabaseReadTransaction) -> HomeScreenSearchResultSet { var conversations: [ConversationSearchResult] = [] @@ -234,7 +235,7 @@ public class FullTextSearcher: NSObject { var existingConversationRecipientIds: Set = Set() - self.finder.enumerateObjects(searchText: searchText, transaction: transaction) { (match: Any, snippet: String?) in + self.finder.enumerateObjects(searchText: searchText, maxSearchResults: maxSearchResults, transaction: transaction) { (match: Any, snippet: String?) in if let thread = match as? TSThread { let threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)