// // MastodonSearchController.swift // Tusker // // Created by Shadowfacts on 10/1/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm private let acctRegex = try! NSRegularExpression(pattern: "[a-z0-9_]+(@[a-z0-9\\-\\.]+[a-z0-9]+)?$", options: .caseInsensitive) private let dateLikeRegex = try! NSRegularExpression(pattern: "\\d{4}(-\\d{2}(-\\d{2})?)?$") private let languageRegex = try! NSRegularExpression(pattern: "(?:language:)?(\\w{2,3})$", options: .caseInsensitive) class MastodonSearchController: UISearchController { override var delegate: UISearchControllerDelegate? { willSet { precondition(newValue === self) } } override var searchResultsController: SearchResultsViewController { super.searchResultsController as! SearchResultsViewController } init(searchResultsController: SearchResultsViewController) { super.init(searchResultsController: searchResultsController) searchResultsController.tokenHandler = { [unowned self] token, op in self.addToken(token, operator: op) } delegate = self searchResultsUpdater = searchResultsController automaticallyShowsSearchResultsController = false showsSearchResultsController = true if #available(iOS 16.0, *) { scopeBarActivation = .onSearchActivation } searchBar.autocapitalizationType = .none searchBar.delegate = self searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } private func updateTokenSuggestions(searchText: String, animated: Bool) { guard searchResultsController.mastodonController.instanceFeatures.searchOperators else { return } let searchText = searchText.trimmingCharacters(in: .whitespaces) var suggestions: [(SearchOperatorType, [String])] = [] suggestions.append((.has, ["has:media", "has:poll", "has:embed"].filter { searchText.isEmpty || $0.contains(searchText) })) suggestions.append((.is, ["is:reply", "is:sensitive"].filter { searchText.isEmpty || $0.contains(searchText) })) var langSuggestions = [String]() let defaultLanguage = searchResultsController.mastodonController.accountPreferences.serverDefaultLanguage ?? "en" let languageToken = "language:\(defaultLanguage)" if searchText.isEmpty || languageToken.contains(searchText) { langSuggestions.append(languageToken) } if searchText != defaultLanguage, let match = languageRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) { let identifier = (searchText as NSString).substring(with: match.range(at: 1)) if #available(iOS 16.0, *) { if Locale.LanguageCode.isoLanguageCodes.contains(where: { $0.identifier == identifier }) { langSuggestions.append("language:\(identifier)") } } else if searchText != "en" { langSuggestions.append("language:\(searchText)") } } suggestions.append((.language, langSuggestions)) var fromSuggestions = [String]() if searchText.isEmpty || "from:me".contains(searchText) { fromSuggestions.append("from:me") } if searchText != "me" && searchText != "from:me", let match = acctRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) { let matched = (searchText as NSString).substring(with: match.range) fromSuggestions.append("from:\(matched)") } suggestions.append((.from, fromSuggestions)) let components = Calendar.current.dateComponents([.year, .month], from: Date()) for op in [SearchOperatorType.before, .during, .after] { if searchText.isEmpty { suggestions.append((op, ["\(op.rawValue):\(components.year!)-\(components.month!)"])) } else if let match = dateLikeRegex.firstMatch(in: searchText, range: NSRange(location: 0, length: searchText.utf16.count)) { let matched = (searchText as NSString).substring(with: match.range) suggestions.append((op, ["\(op.rawValue):\(matched)"])) } } suggestions.append((.in, ["in:all", "in:library"].filter { searchText.isEmpty || $0.contains(searchText) })) searchResultsController.updateTokenSuggestions(suggestions, animated: animated) } private func addToken(_ token: String, operator: SearchOperatorType) { let field = searchBar.searchTextField if field.tokens.contains(where: { ($0.representedObject as? String) == token }) { return } let searchToken = UISearchToken(icon: nil, text: token) searchToken.representedObject = token field.insertToken(searchToken, at: field.tokens.count) field.text = "" let tokenPos = field.positionOfToken(at: field.tokens.count - 1) field.selectedTextRange = field.textRange(from: tokenPos, to: field.endOfDocument) if let requiredScope = `operator`.requiredScope, let index = searchBar.scopeButtonTitles?.firstIndex(of: requiredScope.title) { searchBar.selectedScopeButtonIndex = index searchBar(searchBar, selectedScopeButtonIndexDidChange: index) } } } extension MastodonSearchController: UISearchControllerDelegate { func willPresentSearchController(_ searchController: UISearchController) { updateTokenSuggestions(searchText: "", animated: false) } } extension MastodonSearchController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { updateTokenSuggestions(searchText: searchText, animated: true) } func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { searchResultsController.searchBarTextDidEndEditing(searchBar) } func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { searchResultsController.searchBar(searchBar, selectedScopeButtonIndexDidChange: selectedScope) } } extension UISearchBar { var searchQueryWithOperators: String { var parts = searchTextField.tokens.compactMap { $0.representedObject as? String } let query = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !query.isEmpty { parts.append(query) } return parts.joined(separator: " ") } } private extension SearchOperatorType { var requiredScope: SearchResultsViewController.Scope? { switch self { case .is, .from, .in: return .posts default: return nil } } }