mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			361 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			361 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| @objc
 | |
| class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
 | |
|     
 | |
|     let isRecentSearchResultsEnabled = false
 | |
|     
 | |
|     @objc public var searchText = "" {
 | |
|         didSet {
 | |
|             AssertIsOnMainThread()
 | |
|             // Use a slight delay to debounce updates.
 | |
|             refreshSearchResults()
 | |
|         }
 | |
|     }
 | |
|     var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed())
 | |
|     var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly
 | |
|     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
 | |
|         case recent
 | |
|     }
 | |
|     
 | |
|     // MARK: UI Components
 | |
|     
 | |
|     internal lazy var searchBar: SearchBar = {
 | |
|         let result = SearchBar()
 | |
|         result.tintColor = Colors.text
 | |
|         result.delegate = self
 | |
|         result.showsCancelButton = true
 | |
|         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()
 | |
|     }
 | |
|     
 | |
|     public override func viewWillDisappear(_ animated: Bool) {
 | |
|         super.viewWillDisappear(animated)
 | |
|         searchBar.resignFirstResponder()
 | |
|     }
 | |
|     
 | |
|     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() {
 | |
|         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)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func updateSearchResults(searchText rawSearchText: String) {
 | |
| 
 | |
|         let searchText = rawSearchText.stripped
 | |
|         guard searchText.count > 0 else {
 | |
|             searchResultSet = defaultSearchResults
 | |
|             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: 500,  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()
 | |
|             self.refreshTimer = nil
 | |
|         })
 | |
|     }
 | |
|     
 | |
|     // MARK: Interaction
 | |
|     @objc func clearRecentSearchResults() {
 | |
|         recentSearchResults = []
 | |
|         tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
 | |
|         Storage.shared.clearRecentSearchResults()
 | |
|     }
 | |
| 
 | |
| }
 | |
| 
 | |
| // MARK: - UISearchBarDelegate
 | |
| extension GlobalSearchViewController: UISearchBarDelegate {
 | |
|     public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
 | |
|         self.updateSearchText()
 | |
|     }
 | |
|     
 | |
|     public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
 | |
|         self.updateSearchText()
 | |
|     }
 | |
|     
 | |
|     public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
 | |
|         self.updateSearchText()
 | |
|     }
 | |
|     
 | |
|     public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
 | |
|         searchBar.text = nil
 | |
|         searchBar.resignFirstResponder()
 | |
|         self.navigationController?.popViewController(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] else { return }
 | |
|             show(searchResult.thread.threadRecord, highlightedMessageID: nil, animated: true)
 | |
|         case .messages:
 | |
|             let sectionResults = searchResultSet.messages
 | |
|             guard let searchResult = sectionResults[safe: indexPath.row] else { return }
 | |
|             show(searchResult.thread.threadRecord, highlightedMessageID: searchResult.message?.uniqueId, animated: true)
 | |
|         case .recent:
 | |
|             guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return }
 | |
|             show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true)
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) {
 | |
|         if let threadId = thread.uniqueId {
 | |
|             recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed())
 | |
|         }
 | |
|         
 | |
|         DispatchMainThreadSafe {
 | |
|             if let presentedVC = self.presentedViewController {
 | |
|                 presentedVC.dismiss(animated: false, completion: nil)
 | |
|             }
 | |
|             let conversationVC = ConversationVC(thread: thread, focusedMessageID: highlightedMessageID)
 | |
|             var viewControllers = self.navigationController?.viewControllers
 | |
|             if isFromRecent, let index = viewControllers?.firstIndex(of: self) { viewControllers?.remove(at: index) }
 | |
|             viewControllers?.append(conversationVC)
 | |
|             self.navigationController?.setViewControllers(viewControllers!, animated: true)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: UITableViewDataSource
 | |
|     
 | |
|     public func numberOfSections(in tableView: UITableView) -> Int {
 | |
|         return 4
 | |
|     }
 | |
|     
 | |
|     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 searchSection = SearchSection(rawValue: section) else { return nil }
 | |
|         
 | |
|         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()
 | |
|         
 | |
|         if searchSection == .recent {
 | |
|             let clearButton = UIButton()
 | |
|             clearButton.setTitle("Clear", for: .normal)
 | |
|             clearButton.setTitleColor(Colors.text, for: UIControl.State.normal)
 | |
|             clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
 | |
|             clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside)
 | |
|             container.addSubview(clearButton)
 | |
|             clearButton.autoPinTrailingToSuperviewMargin()
 | |
|             clearButton.autoVCenterInSuperview()
 | |
|         }
 | |
| 
 | |
|         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
 | |
|             }
 | |
|         case .recent:
 | |
|             if recentSearchResults.count > 0  && searchText.isEmpty && isRecentSearchResultsEnabled {
 | |
|                 return NSLocalizedString("SEARCH_SECTION_RECENT", 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
 | |
|         case .recent:
 | |
|             return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     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(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(snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: searchResult?.message)
 | |
|             return cell
 | |
|         case .recent:
 | |
|             let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
 | |
|             cell.isShowingGlobalSearchResult = true
 | |
|             dbReadConnection.read { transaction in
 | |
|                 guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return }
 | |
|                 cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
 | |
|             }
 | |
|             cell.configureForRecent()
 | |
|             return cell
 | |
|         }
 | |
|     }
 | |
| }
 |