176 lines
7.0 KiB
Swift
176 lines
7.0 KiB
Swift
//
|
|
// 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
|
|
}
|
|
}
|
|
}
|