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.
		
		
		
		
		
			
		
			
				
	
	
		
			423 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			423 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 {
 | 
						|
                    // Don't log the 'interrupt' error as that's just the user typing too fast
 | 
						|
                    if (error as? DatabaseError)?.resultCode != DatabaseError.SQLITE_INTERRUPT {
 | 
						|
                        SNLog("[GlobalSearch] Failed to find results due to error: \(error)")
 | 
						|
                    }
 | 
						|
                    
 | 
						|
                    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
 | 
						|
        }
 | 
						|
        
 | 
						|
        // If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the
 | 
						|
        // contact has been hidden)
 | 
						|
        if threadVariant == .contact {
 | 
						|
            Storage.shared.write { db in
 | 
						|
                try SessionThread.fetchOrCreate(
 | 
						|
                    db,
 | 
						|
                    id: threadId,
 | 
						|
                    variant: threadVariant,
 | 
						|
                    shouldBeVisible: nil    // Don't change current state
 | 
						|
                )
 | 
						|
            }
 | 
						|
        }
 | 
						|
        
 | 
						|
        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
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |