From a8a2f0a26c0583a0918d9e3003a157f8573f109f Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 1 Oct 2023 21:40:53 -0400 Subject: [PATCH] Add search operators UI on Mastodon 4.2 Closes #433 --- .../InstanceFeatures/InstanceFeatures.swift | 4 + .../Pachyderm/Model/SearchOperatorType.swift | 19 ++ Tusker.xcodeproj/project.pbxproj | 8 + .../Explore/ExploreViewController.swift | 16 +- .../Explore/InlineTrendsViewController.swift | 8 +- .../Search/MastodonSearchController.swift | 174 ++++++++++++++++++ .../Search/SearchResultsViewController.swift | 146 ++++++++++++--- ...rchTokenSuggestionCollectionViewCell.swift | 42 +++++ 8 files changed, 366 insertions(+), 51 deletions(-) create mode 100644 Packages/Pachyderm/Sources/Pachyderm/Model/SearchOperatorType.swift create mode 100644 Tusker/Screens/Search/MastodonSearchController.swift create mode 100644 Tusker/Screens/Search/SearchTokenSuggestionCollectionViewCell.swift diff --git a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift index 69f977a8..0c2187af 100644 --- a/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift +++ b/Packages/InstanceFeatures/Sources/InstanceFeatures/InstanceFeatures.swift @@ -167,6 +167,10 @@ public class InstanceFeatures: ObservableObject { } } + public var searchOperators: Bool { + hasMastodonVersion(4, 2, 0) + } + public init() { } diff --git a/Packages/Pachyderm/Sources/Pachyderm/Model/SearchOperatorType.swift b/Packages/Pachyderm/Sources/Pachyderm/Model/SearchOperatorType.swift new file mode 100644 index 00000000..68459a24 --- /dev/null +++ b/Packages/Pachyderm/Sources/Pachyderm/Model/SearchOperatorType.swift @@ -0,0 +1,19 @@ +// +// SearchOperatorType.swift +// Pachyderm +// +// Created by Shadowfacts on 10/1/23. +// + +import Foundation + +public enum SearchOperatorType: String, CaseIterable, Equatable { + case has + case `is` + case language + case from + case before + case during + case after + case `in` +} diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 9b6d53d3..9d20444f 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -288,6 +288,7 @@ D6CA8CDE296387310050C433 /* SaveToPhotosActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */; }; D6CF5B832AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */; }; D6CF5B852AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */; }; + D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */; }; D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; }; D6D12B56292D57E800D528E1 /* AccountCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */; }; D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */; }; @@ -339,6 +340,7 @@ D6EE63FB2551F7F60065485C /* StatusCollapseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */; }; D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */; }; D6F0B17524A3A1AA001E48C3 /* MainSidebarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */; }; + D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */; }; D6F2E965249E8BFD005846BB /* IssueReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */; }; D6F2E966249E8BFD005846BB /* IssueReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */; }; D6F4D79429ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */; }; @@ -689,6 +691,7 @@ D6CA8CDD296387310050C433 /* SaveToPhotosActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SaveToPhotosActivity.swift; sourceTree = ""; }; D6CF5B822AC65DDF00F15D83 /* NSCollectionLayoutSection+Readable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSCollectionLayoutSection+Readable.swift"; sourceTree = ""; }; D6CF5B842AC7C56F00F15D83 /* MultiColumnCollectionViewLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiColumnCollectionViewLayout.swift; sourceTree = ""; }; + D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonSearchController.swift; sourceTree = ""; }; D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = ""; }; D6D12B55292D57E800D528E1 /* AccountCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountCollectionViewCell.swift; sourceTree = ""; }; D6D12B57292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusActionAccountListCollectionViewController.swift; sourceTree = ""; }; @@ -750,6 +753,7 @@ D6EE63FA2551F7F60065485C /* StatusCollapseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCollapseButton.swift; sourceTree = ""; }; D6F0B12A24A3071C001E48C3 /* MainSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSplitViewController.swift; sourceTree = ""; }; D6F0B17424A3A1AA001E48C3 /* MainSidebarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainSidebarViewController.swift; sourceTree = ""; }; + D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenSuggestionCollectionViewCell.swift; sourceTree = ""; }; D6F2E963249E8BFD005846BB /* IssueReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueReporterViewController.swift; sourceTree = ""; }; D6F2E964249E8BFD005846BB /* IssueReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = IssueReporterViewController.xib; sourceTree = ""; }; D6F4D79329ECB0AF00351B87 /* UIBackgroundConfiguration+AppColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIBackgroundConfiguration+AppColors.swift"; sourceTree = ""; }; @@ -1364,6 +1368,8 @@ isa = PBXGroup; children = ( D6BC9DD9232D8BE5002CA326 /* SearchResultsViewController.swift */, + D6F253CE2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift */, + D6CF5B882AC9BA6E00F15D83 /* MastodonSearchController.swift */, ); path = Search; sourceTree = ""; @@ -2130,6 +2136,7 @@ D620483423D3801D008A63EF /* LinkTextView.swift in Sources */, D61F75882932DB6000C0B37F /* StatusSwipeActions.swift in Sources */, D68A76EA295285D0001DA1B3 /* AddHashtagPinnedTimelineView.swift in Sources */, + D6F253CF2AC9F86300806D83 /* SearchTokenSuggestionCollectionViewCell.swift in Sources */, D6D12B58292D5B2C00D528E1 /* StatusActionAccountListCollectionViewController.swift in Sources */, D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */, D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */, @@ -2212,6 +2219,7 @@ D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */, D6AEBB3E2321638100E5038B /* UIActivity+Types.swift in Sources */, D61AC1D5232E9FA600C54D2D /* InstanceSelectorTableViewController.swift in Sources */, + D6CF5B892AC9BA6E00F15D83 /* MastodonSearchController.swift in Sources */, D681E4D9246E346E0053414F /* AccountActivityItemSource.swift in Sources */, D68015422401A74600D6103B /* MediaPrefsView.swift in Sources */, D6093F9B25BDD4B9004811E6 /* HashtagSearchResultsViewController.swift in Sources */, diff --git a/Tusker/Screens/Explore/ExploreViewController.swift b/Tusker/Screens/Explore/ExploreViewController.swift index 98069a31..7c1f9384 100644 --- a/Tusker/Screens/Explore/ExploreViewController.swift +++ b/Tusker/Screens/Explore/ExploreViewController.swift @@ -59,14 +59,7 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController.exploreNavigationController = self.navigationController! - searchController = UISearchController(searchResultsController: resultsController) - searchController.searchResultsUpdater = resultsController - if #available(iOS 16.0, *) { - searchController.scopeBarActivation = .onSearchActivation - } - searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = resultsController - searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title) + searchController = MastodonSearchController(searchResultsController: resultsController) definesPresentationContext = true navigationItem.searchController = searchController @@ -88,13 +81,6 @@ class ExploreViewController: UIViewController, UICollectionViewDelegate, Collect ) .sink { [unowned self] in self.updateHashtagsSection(followed: $0) } .store(in: &cancellables) - - let a = PassthroughSubject() - let b = PassthroughSubject() - - a.merge(with: b) - .sink(receiveValue: { print($0) }) - .store(in: &cancellables) } override func viewWillAppear(_ animated: Bool) { diff --git a/Tusker/Screens/Explore/InlineTrendsViewController.swift b/Tusker/Screens/Explore/InlineTrendsViewController.swift index 9f6340b4..9eefaaab 100644 --- a/Tusker/Screens/Explore/InlineTrendsViewController.swift +++ b/Tusker/Screens/Explore/InlineTrendsViewController.swift @@ -34,14 +34,8 @@ class InlineTrendsViewController: UIViewController { resultsController = SearchResultsViewController(mastodonController: mastodonController) resultsController.exploreNavigationController = self.navigationController - searchController = UISearchController(searchResultsController: resultsController) + searchController = MastodonSearchController(searchResultsController: resultsController) searchController.obscuresBackgroundDuringPresentation = true - if #available(iOS 16.0, *) { - searchController.scopeBarActivation = .onSearchActivation - } - searchController.searchBar.autocapitalizationType = .none - searchController.searchBar.delegate = resultsController - searchController.searchBar.scopeButtonTitles = SearchResultsViewController.Scope.allCases.map(\.title) searchController.hidesNavigationBarDuringPresentation = false definesPresentationContext = true diff --git a/Tusker/Screens/Search/MastodonSearchController.swift b/Tusker/Screens/Search/MastodonSearchController.swift new file mode 100644 index 00000000..d227d0dc --- /dev/null +++ b/Tusker/Screens/Search/MastodonSearchController.swift @@ -0,0 +1,174 @@ +// +// 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) + })) + + // TODO: use default language from preferences + var langSuggestions = [String]() + if searchText.isEmpty || "language:en".contains(searchText) { + langSuggestions.append("language:en") + } + if searchText != "en", + 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", + 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 + } + } +} diff --git a/Tusker/Screens/Search/SearchResultsViewController.swift b/Tusker/Screens/Search/SearchResultsViewController.swift index 63180cf7..e4165f08 100644 --- a/Tusker/Screens/Search/SearchResultsViewController.swift +++ b/Tusker/Screens/Search/SearchResultsViewController.swift @@ -33,6 +33,7 @@ class SearchResultsViewController: UIViewController, CollectionViewController { weak var exploreNavigationController: UINavigationController? weak var delegate: SearchResultsViewControllerDelegate? + var tokenHandler: ((String, SearchOperatorType) -> Void)? var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! @@ -42,8 +43,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController { /// Whether to limit results to accounts the users is following. var following: Bool? = nil - let searchSubject = PassthroughSubject() - var currentQuery: String? + private let searchSubject = PassthroughSubject() + private var searchCancellable: AnyCancellable? + private var currentQuery: String? init(mastodonController: MastodonController, scope: Scope = .all) { self.mastodonController = mastodonController @@ -60,29 +62,43 @@ class SearchResultsViewController: UIViewController, CollectionViewController { override func loadView() { let layout = UICollectionViewCompositionalLayout { [unowned self] sectionIndex, environment in - var config = UICollectionLayoutListConfiguration(appearance: .grouped) - config.backgroundColor = .appGroupedBackground - config.headerMode = .supplementary - switch self.dataSource.sectionIdentifier(for: sectionIndex) { + let sectionIdentifier = self.dataSource.sectionIdentifier(for: sectionIndex)! + switch sectionIdentifier { + case .tokenSuggestions(_): + let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(100), heightDimension: .absolute(30)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: itemSize, subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + section.orthogonalScrollingBehavior = .continuousGroupLeadingBoundary + section.interGroupSpacing = 8 + section.contentInsets = NSDirectionalEdgeInsets(top: sectionIndex == 0 ? 16 : 4, leading: 16, bottom: 4, trailing: 16) + return section + case .loadingIndicator: + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.backgroundColor = .appGroupedBackground config.showsSeparators = false config.headerMode = .none - case .statuses: + return .list(using: config, layoutEnvironment: environment) + + case .accounts, .hashtags, .statuses: + var config = UICollectionLayoutListConfiguration(appearance: .grouped) + config.backgroundColor = .appGroupedBackground + config.headerMode = .supplementary config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (self.collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } - config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets - config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets - default: - break + if sectionIdentifier == .statuses { + config.separatorConfiguration.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.separatorConfiguration.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + } + // we don't use the readable content inset here, because it insets the entire cell, rather than just the content + // so the cell backgrounds not being full width looks weird + return .list(using: config, layoutEnvironment: environment) } - let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) - // we don't use the readable content inset here, because it insets the entire cell, rather than just the content - // so the cell backgrounds not being full width looks weird - return section } view = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self @@ -97,11 +113,10 @@ class SearchResultsViewController: UIViewController, CollectionViewController { override func viewDidLoad() { super.viewDidLoad() - _ = searchSubject - .debounce(for: .milliseconds(500), scheduler: RunLoop.main) + searchCancellable = searchSubject + .debounce(for: .seconds(1), scheduler: RunLoop.main) .map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { $0 != self.currentQuery } - .sink(receiveValue: performSearch(query:)) + .sink { [unowned self] in self.performSearch(query: $0) } userActivity = UserActivityManager.searchActivity(query: nil, accountID: mastodonController.accountInfo!.id) @@ -115,6 +130,9 @@ class SearchResultsViewController: UIViewController, CollectionViewController { config.text = section.displayName supplementaryView.contentConfiguration = config } + let tokenSuggestionCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in + cell.setText(itemIdentifier) + } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } @@ -132,6 +150,8 @@ class SearchResultsViewController: UIViewController, CollectionViewController { let dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in let cell: UICollectionViewCell switch itemIdentifier { + case .tokenSuggestion(let value): + return collectionView.dequeueConfiguredReusableCell(using: tokenSuggestionCell, for: indexPath, item: value) case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) case .account(let accountID): @@ -172,6 +192,45 @@ class SearchResultsViewController: UIViewController, CollectionViewController { return super.targetViewController(forAction: action, sender: sender) } + func updateTokenSuggestions(_ suggestions: [(SearchOperatorType, [String])], animated: Bool) { + var snapshot = dataSource.snapshot() + var prev: Section? + for (op, values) in suggestions { + let section = Section.tokenSuggestions(op) + if values.isEmpty { + if snapshot.sectionIdentifiers.contains(section) { + snapshot.deleteSections([section]) + } + } else { + if !snapshot.sectionIdentifiers.contains(section) { + if let prev { + snapshot.insertSections([section], afterSection: prev) + } else if let first = snapshot.sectionIdentifiers.first { + snapshot.insertSections([section], beforeSection: first) + } else { + snapshot.appendSections([section]) + } + } else { + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: section)) + } + snapshot.appendItems(values.map { .tokenSuggestion($0) }, toSection: section) + prev = section + } + } + + dataSource.apply(snapshot, animatingDifferences: animated) + } + + func removeResults() { + var snapshot = dataSource.snapshot() + removeResults(from: &snapshot) + dataSource.apply(snapshot, animatingDifferences: false) + } + + private func removeResults(from snapshot: inout NSDiffableDataSourceSnapshot) { + snapshot.deleteSections([Section.loadingIndicator, .accounts, .hashtags, .statuses].filter { snapshot.sectionIdentifiers.contains($0) }) + } + func loadResults(from source: SearchResultsViewController) { currentQuery = source.currentQuery if let sourceDataSource = source.dataSource { @@ -180,16 +239,18 @@ class SearchResultsViewController: UIViewController, CollectionViewController { } func performSearch(query: String?) { - guard isViewLoaded else { + guard isViewLoaded, + query != currentQuery else { return } guard let query = query, !query.isEmpty else { - self.dataSource.apply(NSDiffableDataSourceSnapshot()) + removeResults() return } self.currentQuery = query - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = dataSource.snapshot() + removeResults(from: &snapshot) snapshot.appendSections([.loadingIndicator]) snapshot.appendItems([.loadingIndicator]) dataSource.apply(snapshot) @@ -209,7 +270,8 @@ class SearchResultsViewController: UIViewController, CollectionViewController { } private func showSearchResults(_ results: SearchResults) { - var snapshot = NSDiffableDataSourceSnapshot() + var snapshot = dataSource.snapshot() + snapshot.deleteSections([.loadingIndicator]) self.mastodonController.persistentContainer.performBatchUpdates({ (context, addAccounts, addStatuses) in let resultTypes = self.scope.resultTypes @@ -304,7 +366,8 @@ extension SearchResultsViewController { } extension SearchResultsViewController { - enum Section: CaseIterable { + enum Section: Hashable { + case tokenSuggestions(SearchOperatorType) case loadingIndicator case accounts case hashtags @@ -312,6 +375,8 @@ extension SearchResultsViewController { var displayName: String? { switch self { + case .tokenSuggestions: + return nil case .loadingIndicator: return nil case .accounts: @@ -324,6 +389,7 @@ extension SearchResultsViewController { } } enum Item: Hashable { + case tokenSuggestion(String) case loadingIndicator case account(String) case hashtag(Hashtag) @@ -331,6 +397,9 @@ extension SearchResultsViewController { func hash(into hasher: inout Hasher) { switch self { + case let .tokenSuggestion(value): + hasher.combine("tokenSuggestion") + hasher.combine(value) case .loadingIndicator: hasher.combine("loadingIndicator") case let .account(id): @@ -347,6 +416,8 @@ extension SearchResultsViewController { static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { + case (.tokenSuggestion(let a), .tokenSuggestion(let b)): + return a == b case (.loadingIndicator, .loadingIndicator): return true case (.account(let a), .account(let b)): @@ -376,6 +447,12 @@ extension SearchResultsViewController: UICollectionViewDelegate { switch dataSource.itemIdentifier(for: indexPath) { case nil, .loadingIndicator: return + case .tokenSuggestion(let value): + guard case .tokenSuggestions(let op) = dataSource.sectionIdentifier(for: indexPath.section) else { + return + } + tokenHandler?(value, op) + collectionView.deselectItem(at: indexPath, animated: true) case let .account(id): if let delegate { delegate.selectedSearchResult(account: id) @@ -403,7 +480,7 @@ extension SearchResultsViewController: UICollectionViewDelegate { return nil } switch item { - case .loadingIndicator: + case .loadingIndicator, .tokenSuggestion(_): return nil case .account(let id): return UIContextMenuConfiguration { @@ -436,7 +513,7 @@ extension SearchResultsViewController: UICollectionViewDragDelegate { let url: URL let activity: NSUserActivity switch item { - case .loadingIndicator: + case .loadingIndicator, .tokenSuggestion(_): return [] case .account(let id): guard let account = mastodonController.persistentContainer.account(for: id) else { @@ -464,18 +541,29 @@ extension SearchResultsViewController: UICollectionViewDragDelegate { extension SearchResultsViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { - searchSubject.send(searchController.searchBar.text) + searchSubject.send(searchController.searchBar.searchQueryWithOperators) } } extension SearchResultsViewController: UISearchBarDelegate { func searchBarTextDidEndEditing(_ searchBar: UISearchBar) { + var snapshot = dataSource.snapshot() + let allSuggestionSections = snapshot.sectionIdentifiers.filter { + if case .tokenSuggestions(_) = $0 { + return true + } else { + return false + } + } + snapshot.deleteSections(allSuggestionSections) + dataSource.apply(snapshot, animatingDifferences: true) + // perform a search immedaitely when the search button is pressed - performSearch(query: searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines)) + performSearch(query: searchBar.searchQueryWithOperators) } func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { - let newQuery = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines) + let newQuery = searchBar.searchQueryWithOperators let newScope = Scope.allCases[selectedScope] if self.scope == .all && currentQuery == newQuery { self.scope = newScope diff --git a/Tusker/Screens/Search/SearchTokenSuggestionCollectionViewCell.swift b/Tusker/Screens/Search/SearchTokenSuggestionCollectionViewCell.swift new file mode 100644 index 00000000..9bd94f84 --- /dev/null +++ b/Tusker/Screens/Search/SearchTokenSuggestionCollectionViewCell.swift @@ -0,0 +1,42 @@ +// +// SearchTokenSuggestionCollectionViewCell.swift +// Tusker +// +// Created by Shadowfacts on 10/1/23. +// Copyright © 2023 Shadowfacts. All rights reserved. +// + +import UIKit +import Pachyderm + +class SearchTokenSuggestionCollectionViewCell: UICollectionViewCell { + private let label = UILabel() + + override init(frame: CGRect) { + super.init(frame: frame) + + label.textColor = .tintColor + + contentView.backgroundColor = .tintColor.withAlphaComponent(0.2) + contentView.layer.masksToBounds = true + contentView.layer.cornerRadius = 6 + contentView.layer.cornerCurve = .continuous + + label.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + label.topAnchor.constraint(equalTo: contentView.topAnchor), + label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func setText(_ text: String) { + label.text = text + } +}