// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. @objc class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource { @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 } var isLoading = false enum SearchSection: Int { case noResults case contacts case messages } // MARK: UI Components internal lazy var searchBar: SearchBar = { let result = SearchBar() result.tintColor = Colors.text result.delegate = self return result }() internal lazy var tableView: UITableView = { let result = UITableView(frame: .zero, style: .grouped) result.rowHeight = UITableView.automaticDimension result.estimatedRowHeight = 60 result.separatorStyle = .none result.keyboardDismissMode = .onDrag result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier) result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier) result.showsVerticalScrollIndicator = false return result }() // MARK: Dependencies var dbReadConnection: YapDatabaseConnection { return OWSPrimaryStorage.shared().dbReadConnection } // MARK: View Lifecycle public override func viewDidLoad() { super.viewDidLoad() setUpGradientBackground() tableView.dataSource = self tableView.delegate = self view.addSubview(tableView) tableView.pin(.leading, to: .leading, of: view) tableView.pin(.top, to: .top, of: view, withInset: Values.smallSpacing) tableView.pin(.trailing, to: .trailing, of: view) tableView.pin(.bottom, to: .bottom, of: view) navigationItem.hidesBackButton = true setupNavigationBar() } public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) searchBar.becomeFirstResponder() } private func setupNavigationBar() { // This is a workaround for a UI issue that the navigation bar can be a bit higher if // the search bar is put directly to be the titleView. And this can cause the tableView // in home screen doing a weird scrolling when going back to home screen. let searchBarContainer = UIView() searchBarContainer.layoutMargins = UIEdgeInsets.zero searchBar.sizeToFit() searchBar.layoutMargins = UIEdgeInsets.zero searchBarContainer.set(.height, to: 44) searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32) searchBarContainer.addSubview(searchBar) searchBar.autoPinEdgesToSuperviewMargins() navigationItem.titleView = searchBarContainer } 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 self = self else { return } self.updateSearchResults(searchText: self.searchText) self.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 { return } lastSearchText = searchText var searchResults: HomeScreenSearchResultSet? self.dbReadConnection.asyncRead({[weak self] transaction in guard let self = self else { return } self.isLoading = true // The max search result count is set according to the keyword length. This is just a workaround for performance issue. // The longer and more accurate the keyword is, the less search results should there be. searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: searchText.count * 50, transaction: transaction) }, completionBlock: { [weak self] in AssertIsOnMainThread() guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return } self.searchResultSet = results self.isLoading = false self.reloadTableData() }) } } // MARK: - UISearchBarDelegate extension GlobalSearchViewController: UISearchBarDelegate { public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) { self.updateSearchText() self.ensureSearchBarCancelButton() } public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { self.updateSearchText() self.ensureSearchBarCancelButton() } public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { self.updateSearchText() self.ensureSearchBarCancelButton() } public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { searchBar.text = nil searchBar.resignFirstResponder() self.ensureSearchBarCancelButton() self.navigationController?.popViewController(animated: true) } func ensureSearchBarCancelButton() { let shouldShowCancelButton = searchBar.isFirstResponder || (searchBar.text ?? "").count > 0 guard searchBar.showsCancelButton != shouldShowCancelButton else { return } self.searchBar.setShowsCancelButton(shouldShowCancelButton, animated: true) } func updateSearchText() { guard let searchText = searchBar.text?.ows_stripped() else { return } self.searchText = searchText } } // MARK: - UITableViewDelegate & UITableViewDataSource extension GlobalSearchViewController { // MARK: UITableViewDelegate public 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], let threadId = searchResult.thread.threadRecord.uniqueId, let thread = TSThread.fetch(uniqueId: threadId) else { return } show(thread, highlightedMessageID: nil, animated: true) case .messages: let sectionResults = searchResultSet.messages guard let searchResult = sectionResults[safe: indexPath.row], let threadId = searchResult.thread.threadRecord.uniqueId, let thread = TSThread.fetch(uniqueId: threadId) else { return } show(thread, highlightedMessageID: searchResult.messageId, animated: true) } tableView.deselectRow(at: indexPath, animated: true) } private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool) { DispatchMainThreadSafe { if let presentedVC = self.presentedViewController { presentedVC.dismiss(animated: false, completion: nil) } let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID) self.navigationController?.pushViewController(conversationVC, animated: true) } } // MARK: UITableViewDataSource public func numberOfSections(in tableView: UITableView) -> Int { return 3 } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { UIView() } public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { .leastNonzeroMagnitude } public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { guard nil != self.tableView(tableView, titleForHeaderInSection: section) else { return .leastNonzeroMagnitude } return UITableView.automaticDimension } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { guard let title = self.tableView(tableView, titleForHeaderInSection: section) else { return UIView() } let titleLabel = UILabel() titleLabel.text = title titleLabel.textColor = Colors.text titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize) let container = UIView() container.backgroundColor = Colors.cellBackground container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing) container.addSubview(titleLabel) titleLabel.autoPinEdgesToSuperviewMargins() return container } public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { guard let searchSection = SearchSection(rawValue: section) else { return nil } switch searchSection { case .noResults: return nil case .contacts: if searchResultSet.conversations.count > 0 { return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "") } else { return nil } case .messages: if searchResultSet.messages.count > 0 { return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "") } else { return nil } } } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { guard let searchSection = SearchSection(rawValue: section) else { return 0 } switch searchSection { case .noResults: return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0 case .contacts: return searchResultSet.conversations.count case .messages: return searchResultSet.messages.count } } public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } public 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(isLoading: isLoading) return cell case .contacts: let sectionResults = searchResultSet.conversations let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell cell.isShowingGlobalSearchResult = true let searchResult = sectionResults[safe: indexPath.row] cell.threadViewModel = searchResult?.thread cell.configure(messageDate: searchResult?.messageDate, snippet: searchResult?.snippet, searchText: searchResultSet.searchText) return cell case .messages: let sectionResults = searchResultSet.messages let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell cell.isShowingGlobalSearchResult = true let searchResult = sectionResults[safe: indexPath.row] cell.threadViewModel = searchResult?.thread cell.configure(messageDate: searchResult?.messageDate, snippet: searchResult?.snippet, searchText: searchResultSet.searchText) return cell } } }