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.
		
		
		
		
		
			
		
			
				
	
	
		
			405 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			405 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionUIKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| import SignalUtilitiesKit
 | |
| import SignalCoreKit
 | |
| 
 | |
| class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
 | |
|     fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
 | |
|     
 | |
|     // MARK: - SearchSection
 | |
|     
 | |
|     enum SearchSection: Int, Differentiable {
 | |
|         case noResults
 | |
|         case contactsAndGroups
 | |
|         case messages
 | |
|     }
 | |
|     
 | |
|     // MARK: - SessionUtilRespondingViewController
 | |
|     
 | |
|     let isConversationList: Bool = true
 | |
|     
 | |
|     func forceRefreshIfNeeded() {
 | |
|         // Need to do this as the 'GlobalSearchViewController' doesn't observe database changes
 | |
|         updateSearchResults(searchText: searchText, force: true)
 | |
|     }
 | |
|     
 | |
|     // MARK: - Variables
 | |
|     
 | |
|     private lazy var defaultSearchResults: [SectionModel] = {
 | |
|         let result: SessionThreadViewModel? = Storage.shared.read { db -> SessionThreadViewModel? in
 | |
|             try SessionThreadViewModel
 | |
|                 .noteToSelfOnlyQuery(userPublicKey: getUserHexEncodedPublicKey(db))
 | |
|                 .fetchOne(db)
 | |
|         }
 | |
|         
 | |
|         return [ result.map { ArraySection(model: .contactsAndGroups, elements: [$0]) } ]
 | |
|             .compactMap { $0 }
 | |
|     }()
 | |
|     private var readConnection: Atomic<Database?> = Atomic(nil)
 | |
|     private lazy var searchResultSet: [SectionModel] = self.defaultSearchResults
 | |
|     private var termForCurrentSearchResultSet: String = ""
 | |
|     private var lastSearchText: String?
 | |
|     private var refreshTimer: Timer?
 | |
|     
 | |
|     var isLoading = false
 | |
|     
 | |
|     @objc public var searchText = "" {
 | |
|         didSet {
 | |
|             AssertIsOnMainThread()
 | |
|             // Use a slight delay to debounce updates.
 | |
|             refreshSearchResults()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - UI Components
 | |
| 
 | |
|     internal lazy var searchBar: SearchBar = {
 | |
|         let result: SearchBar = SearchBar()
 | |
|         result.themeTintColor = .textPrimary
 | |
|         result.delegate = self
 | |
|         result.showsCancelButton = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private var searchBarWidth: NSLayoutConstraint?
 | |
| 
 | |
|     internal lazy var tableView: UITableView = {
 | |
|         let result: UITableView = UITableView(frame: .zero, style: .grouped)
 | |
|         result.themeBackgroundColor = .clear
 | |
|         result.rowHeight = UITableView.automaticDimension
 | |
|         result.estimatedRowHeight = 60
 | |
|         result.separatorStyle = .none
 | |
|         result.keyboardDismissMode = .onDrag
 | |
|         result.register(view: EmptySearchResultCell.self)
 | |
|         result.register(view: FullConversationCell.self)
 | |
|         result.showsVerticalScrollIndicator = false
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     // MARK: - View Lifecycle
 | |
|     
 | |
|     public override func viewDidLoad() {
 | |
|         super.viewDidLoad()
 | |
|         
 | |
|         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 viewDidAppear(_ animated: Bool) {
 | |
|         super.viewDidAppear(animated)
 | |
|         searchBar.becomeFirstResponder()
 | |
|     }
 | |
| 
 | |
|     public override func viewWillDisappear(_ animated: Bool) {
 | |
|         super.viewWillDisappear(animated)
 | |
|         
 | |
|         UIView.performWithoutAnimation {
 | |
|             searchBar.resignFirstResponder()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
 | |
|         super.viewWillTransition(to: size, with: coordinator)
 | |
|         searchBarWidth?.constant = size.width - 32
 | |
|     }
 | |
| 
 | |
|     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 = UIView()
 | |
|         searchBarContainer.layoutMargins = UIEdgeInsets.zero
 | |
|         searchBar.sizeToFit()
 | |
|         searchBar.layoutMargins = UIEdgeInsets.zero
 | |
|         searchBarContainer.set(.height, to: 44)
 | |
|         searchBarWidth = searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32)
 | |
|         searchBarContainer.addSubview(searchBar)
 | |
|         navigationItem.titleView = searchBarContainer
 | |
|         
 | |
|         // On iPad, the cancel button won't show
 | |
|         // See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc
 | |
|         if UIDevice.current.isIPad {
 | |
|             let ipadCancelButton = UIButton()
 | |
|             ipadCancelButton.setTitle("Cancel", for: .normal)
 | |
|             ipadCancelButton.setThemeTitleColor(.textPrimary, for: .normal)
 | |
|             ipadCancelButton.addTarget(self, action: #selector(cancel), for: .touchUpInside)
 | |
|             searchBarContainer.addSubview(ipadCancelButton)
 | |
|             
 | |
|             ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer)
 | |
|             ipadCancelButton.autoVCenterInSuperview()
 | |
|             searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing)
 | |
|             searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
 | |
|         }
 | |
|         else {
 | |
|             searchBar.autoPinEdgesToSuperviewMargins()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // MARK: - Update Search Results
 | |
| 
 | |
|     private func refreshSearchResults() {
 | |
|         refreshTimer?.invalidate()
 | |
|         refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
 | |
|             self?.updateSearchResults(searchText: (self?.searchText ?? ""))
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private func updateSearchResults(searchText rawSearchText: String, force: Bool = false) {
 | |
|         let searchText = rawSearchText.stripped
 | |
|         
 | |
|         guard searchText.count > 0 else {
 | |
|             guard searchText != (lastSearchText ?? "") else { return }
 | |
|             
 | |
|             searchResultSet = defaultSearchResults
 | |
|             lastSearchText = nil
 | |
|             tableView.reloadData()
 | |
|             return
 | |
|         }
 | |
|         guard force || lastSearchText != searchText else { return }
 | |
| 
 | |
|         lastSearchText = searchText
 | |
| 
 | |
|         DispatchQueue.global(qos: .default).async { [weak self] in
 | |
|             self?.readConnection.wrappedValue?.interrupt()
 | |
|             
 | |
|             let result: Result<[SectionModel], Error>? = Storage.shared.read { db -> Result<[SectionModel], Error> in
 | |
|                 self?.readConnection.mutate { $0 = db }
 | |
|                 
 | |
|                 do {
 | |
|                     let userPublicKey: String = getUserHexEncodedPublicKey(db)
 | |
|                     let contactsAndGroupsResults: [SessionThreadViewModel] = try SessionThreadViewModel
 | |
|                         .contactsAndGroupsQuery(
 | |
|                             userPublicKey: userPublicKey,
 | |
|                             pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText),
 | |
|                             searchTerm: searchText
 | |
|                         )
 | |
|                         .fetchAll(db)
 | |
|                     let messageResults: [SessionThreadViewModel] = try SessionThreadViewModel
 | |
|                         .messagesQuery(
 | |
|                             userPublicKey: userPublicKey,
 | |
|                             pattern: try SessionThreadViewModel.pattern(db, searchTerm: searchText)
 | |
|                         )
 | |
|                         .fetchAll(db)
 | |
|                     
 | |
|                     return .success([
 | |
|                         ArraySection(model: .contactsAndGroups, elements: contactsAndGroupsResults),
 | |
|                         ArraySection(model: .messages, elements: messageResults)
 | |
|                     ])
 | |
|                 }
 | |
|                 catch {
 | |
|                     return .failure(error)
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             DispatchQueue.main.async {
 | |
|                 switch result {
 | |
|                     case .success(let sections):
 | |
|                         let hasResults: Bool = (
 | |
|                             !searchText.isEmpty &&
 | |
|                             (sections.map { $0.elements.count }.reduce(0, +) > 0)
 | |
|                         )
 | |
|                         
 | |
|                         self?.termForCurrentSearchResultSet = searchText
 | |
|                         self?.searchResultSet = [
 | |
|                             (hasResults ? nil : [
 | |
|                                 ArraySection(
 | |
|                                     model: .noResults,
 | |
|                                     elements: [
 | |
|                                         SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId)
 | |
|                                     ]
 | |
|                                 )
 | |
|                             ]),
 | |
|                             (hasResults ? sections : nil)
 | |
|                         ]
 | |
|                         .compactMap { $0 }
 | |
|                         .flatMap { $0 }
 | |
|                         self?.isLoading = false
 | |
|                         self?.tableView.reloadData()
 | |
|                         self?.refreshTimer = nil
 | |
|                         
 | |
|                     default: break
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func cancel() {
 | |
|         self.navigationController?.popViewController(animated: true)
 | |
|     }
 | |
| }
 | |
| 
 | |
| // 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)
 | |
|         
 | |
|         let section: SectionModel = self.searchResultSet[indexPath.section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .noResults: break
 | |
|             case .contactsAndGroups, .messages:
 | |
|                 show(
 | |
|                     threadId: section.elements[indexPath.row].threadId,
 | |
|                     threadVariant: section.elements[indexPath.row].threadVariant,
 | |
|                     focusedInteractionInfo: {
 | |
|                         guard
 | |
|                             let interactionId: Int64 = section.elements[indexPath.row].interactionId,
 | |
|                             let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs
 | |
|                         else { return nil }
 | |
|                         
 | |
|                         return Interaction.TimestampInfo(
 | |
|                             id: interactionId,
 | |
|                             timestampMs: timestampMs
 | |
|                         )
 | |
|                     }()
 | |
|                 )
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) {
 | |
|         guard Thread.isMainThread else {
 | |
|             DispatchQueue.main.async { [weak self] in
 | |
|                 self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated)
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let viewController: ConversationVC = ConversationVC(
 | |
|             threadId: threadId,
 | |
|             threadVariant: threadVariant,
 | |
|             focusedInteractionInfo: focusedInteractionInfo
 | |
|         )
 | |
|         self.navigationController?.pushViewController(viewController, animated: true)
 | |
|     }
 | |
| 
 | |
|     // MARK: - UITableViewDataSource
 | |
| 
 | |
|     public func numberOfSections(in tableView: UITableView) -> Int {
 | |
|         return self.searchResultSet.count
 | |
|     }
 | |
|     
 | |
|     public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
 | |
|         return self.searchResultSet[section].elements.count
 | |
|     }
 | |
| 
 | |
|     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: String = self.tableView(tableView, titleForHeaderInSection: section) else {
 | |
|             return UIView()
 | |
|         }
 | |
| 
 | |
|         let titleLabel = UILabel()
 | |
|         titleLabel.font = .boldSystemFont(ofSize: Values.largeFontSize)
 | |
|         titleLabel.text = title
 | |
|         titleLabel.themeTextColor = .textPrimary
 | |
| 
 | |
|         let container = UIView()
 | |
|         container.themeBackgroundColor = .backgroundPrimary
 | |
|         container.addSubview(titleLabel)
 | |
|         
 | |
|         titleLabel.pin(.top, to: .top, of: container, withInset: Values.mediumSpacing)
 | |
|         titleLabel.pin(.bottom, to: .bottom, of: container, withInset: -Values.mediumSpacing)
 | |
|         titleLabel.pin(.left, to: .left, of: container, withInset: Values.largeSpacing)
 | |
|         titleLabel.pin(.right, to: .right, of: container, withInset: -Values.largeSpacing)
 | |
|         
 | |
|         return container
 | |
|     }
 | |
| 
 | |
|     public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
 | |
|         let section: SectionModel = self.searchResultSet[section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .noResults: return nil
 | |
|             case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized())
 | |
|             case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized())
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
 | |
|         return UITableView.automaticDimension
 | |
|     }
 | |
| 
 | |
|     public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
 | |
|         let section: SectionModel = self.searchResultSet[indexPath.section]
 | |
|         
 | |
|         switch section.model {
 | |
|             case .noResults:
 | |
|                 let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath)
 | |
|                 cell.configure(isLoading: isLoading)
 | |
|                 return cell
 | |
|                 
 | |
|             case .contactsAndGroups:
 | |
|                 let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
 | |
|                 cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
 | |
|                 return cell
 | |
|                 
 | |
|             case .messages:
 | |
|                 let cell: FullConversationCell = tableView.dequeue(type: FullConversationCell.self, for: indexPath)
 | |
|                 cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
 | |
|                 return cell
 | |
|         }
 | |
|     }
 | |
| }
 |