From 2e384f3cb58895512d78d24d9d57fc38ff9f9e9b Mon Sep 17 00:00:00 2001 From: Nathan Mattes Date: Fri, 15 Sep 2023 17:45:22 +0200 Subject: [PATCH] WIP: Add some search-implementation and clean stuff (IOS-141) Shame on me for such a big commit. I'm new to iOS-development, sorry :nerd: --- Mastodon.xcodeproj/project.pbxproj | 28 +++ .../HomeTimelineViewModel+Diffable.swift | 7 +- .../Search/Search/SearchViewController.swift | 60 ------ .../Scene/Search/Search/SearchViewModel.swift | 27 --- ...rchResultDefaultSectionTableViewCell.swift | 26 +++ .../SearchResultOverviewSection.swift | 71 +++++++ ...chResultsOverviewTableViewController.swift | 166 ++++++++++++++++ .../SearchDetailViewController.swift | 188 ++++-------------- .../SearchDetail/SearchDetailViewModel.swift | 34 +--- .../Cell/HashtagTableViewCell.swift | 2 + .../SearchResultViewController.swift | 2 - .../SearchResultViewModel+State.swift | 1 - .../SearchResult/SearchResultViewModel.swift | 4 +- .../TableviewCell/StatusTableViewCell.swift | 2 + .../TableviewCell/UserTableViewCell.swift | 3 +- .../UserFetchedResultsController.swift | 1 - .../Entity/Mastodon+Entity+History.swift | 2 +- .../View/Content/StatusView+ViewModel.swift | 3 - 18 files changed, 355 insertions(+), 272 deletions(-) create mode 100644 Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift create mode 100644 Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift create mode 100644 Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 40a6befb6..0636ceb29 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -139,6 +139,9 @@ D8099078294BC8A30050219F /* PrivacyTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099077294BC8A30050219F /* PrivacyTableViewController.swift */; }; D809907A294BC9390050219F /* PrivacyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099079294BC9390050219F /* PrivacyTableViewCell.swift */; }; D809907C294D25510050219F /* PrivacyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D809907B294D25510050219F /* PrivacyViewModel.swift */; }; + D81A22752AB4643200905D71 /* SearchResultsOverviewTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */; }; + D81A22782AB4782400905D71 /* SearchResultOverviewSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */; }; + D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */; }; D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8363B1529469CE200A74079 /* OnboardingNextView.swift */; }; D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; }; D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.swift */; }; @@ -774,6 +777,9 @@ D8099077294BC8A30050219F /* PrivacyTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewController.swift; sourceTree = ""; }; D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = ""; }; D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = ""; }; + D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsOverviewTableViewController.swift; sourceTree = ""; }; + D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewSection.swift; sourceTree = ""; }; + D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultDefaultSectionTableViewCell.swift; sourceTree = ""; }; D82463522A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Intents.strings; sourceTree = ""; }; D82463532A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/WidgetExtension.strings; sourceTree = ""; }; D82463542A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -1791,6 +1797,24 @@ path = Privacy; sourceTree = ""; }; + D81A22732AB4641F00905D71 /* Search Results Overview */ = { + isa = PBXGroup; + children = ( + D81A22792AB47B8400905D71 /* Cells */, + D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */, + D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */, + ); + path = "Search Results Overview"; + sourceTree = ""; + }; + D81A22792AB47B8400905D71 /* Cells */ = { + isa = PBXGroup; + children = ( + D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; D8A6AB68291C50F3003AB663 /* Login */ = { isa = PBXGroup; children = ( @@ -2898,6 +2922,7 @@ DBF1D24F269DAF6100C1C08A /* SearchDetail */ = { isa = PBXGroup; children = ( + D81A22732AB4641F00905D71 /* Search Results Overview */, DB4F0964269ED06700D62E92 /* SearchResult */, DBF1D252269DB01700C1C08A /* SearchHistory */, DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */, @@ -3538,6 +3563,7 @@ DB3E6FE02806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */, + D81A22782AB4782400905D71 /* SearchResultOverviewSection.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */, DB023D26279FFB0A005AC798 /* ShareActivityProvider.swift in Sources */, @@ -3692,6 +3718,7 @@ 6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */, 5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, + D81A22752AB4643200905D71 /* SearchResultsOverviewTableViewController.swift in Sources */, 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */, DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */, @@ -3790,6 +3817,7 @@ 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */, + D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */, 6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */, DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */, diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index 24cf2258a..9feee053e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -41,13 +41,8 @@ extension HomeTimelineViewModel { .sink { [weak self] records in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): incoming \(records.count) objects") + Task { @MainActor in - let start = CACurrentMediaTime() - defer { - let end = CACurrentMediaTime() - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cost \(end - start, format: .fixed(precision: 4))s to process \(records.count) feeds") - } let oldSnapshot = diffableDataSource.snapshot() var newSnapshot: NSDiffableDataSourceSnapshot = { let newItems = records.map { record in diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index 41bc55ee0..093976e63 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -21,9 +21,6 @@ final class HeightFixedSearchBar: UISearchBar { } final class SearchViewController: UIViewController, NeedsDependency { - - let logger = Logger(subsystem: "SearchViewController", category: "ViewController") - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } @@ -37,16 +34,6 @@ final class SearchViewController: UIViewController, NeedsDependency { let titleViewContainer = UIView() let searchBar = HeightFixedSearchBar() -// let collectionView: UICollectionView = { -// var configuration = UICollectionLayoutListConfiguration(appearance: .insetGrouped) -// configuration.backgroundColor = .clear -// configuration.headerMode = .supplementary -// let layout = UICollectionViewCompositionalLayout.list(using: configuration) -// let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) -// collectionView.backgroundColor = .clear -// return collectionView -// }() - // value is the initial search text to set let searchBarTapPublisher = PassthroughSubject() @@ -62,11 +49,6 @@ final class SearchViewController: UIViewController, NeedsDependency { ) return viewController }() - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - } extension SearchViewController { @@ -85,30 +67,12 @@ extension SearchViewController { title = L10n.Scene.Search.title setupSearchBar() - -// collectionView.translatesAutoresizingMaskIntoConstraints = false -// view.addSubview(collectionView) -// NSLayoutConstraint.activate([ -// collectionView.topAnchor.constraint(equalTo: view.topAnchor), -// collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), -// collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), -// collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), -// ]) -// -// collectionView.delegate = self -// viewModel.setupDiffableDataSource( -// collectionView: collectionView -// ) - guard let discoveryViewController = self.discoveryViewController else { return } addChild(discoveryViewController) discoveryViewController.view.translatesAutoresizingMaskIntoConstraints = false view.addSubview(discoveryViewController.view) discoveryViewController.view.pinToParent() - -// discoveryViewController.view.isHidden = true - } override func viewDidAppear(_ animated: Bool) { @@ -183,12 +147,8 @@ extension SearchViewController: UISearchBarDelegate { // MARK: - UISearchControllerDelegate extension SearchViewController: UISearchControllerDelegate { func willDismissSearchController(_ searchController: UISearchController) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") searchController.isActive = true } - func didPresentSearchController(_ searchController: UISearchController) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") - } } // MARK: - ScrollViewContainer @@ -200,23 +160,3 @@ extension SearchViewController: ScrollViewContainer { discoveryViewController?.scrollToTop(animated: animated) } } - -// MARK: - UICollectionViewDelegate -//extension SearchViewController: UICollectionViewDelegate { -// func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { -// logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): select item at: \(indexPath.debugDescription)") -// -// defer { -// collectionView.deselectItem(at: indexPath, animated: true) -// } -// -// guard let diffableDataSource = viewModel.diffableDataSource else { return } -// guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return } -// -// switch item { -// case .trend(let hashtag): -// let viewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag.name) -// coordinator.present(scene: .hashtagTimeline(viewModel: viewModel), from: self, transition: .show) -// } -// } -//} diff --git a/Mastodon/Scene/Search/Search/SearchViewModel.swift b/Mastodon/Scene/Search/Search/SearchViewModel.swift index 51d614280..620099bfc 100644 --- a/Mastodon/Scene/Search/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/Search/SearchViewModel.swift @@ -31,32 +31,5 @@ final class SearchViewModel: NSObject { self.context = context self.authContext = authContext super.init() - -// Publishers.CombineLatest( -// context.authenticationService.activeMastodonAuthenticationBox, -// viewDidAppeared -// ) -// .compactMap { authenticationBox, _ -> MastodonAuthenticationBox? in -// return authenticationBox -// } -// .throttle(for: 3, scheduler: DispatchQueue.main, latest: true) -// .asyncMap { authenticationBox in -// try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil) -// } -// .retry(3) -// .map { response in Result, Error> { response } } -// .catch { error in Just(Result, Error> { throw error }) } -// .receive(on: DispatchQueue.main) -// .sink { [weak self] result in -// guard let self = self else { return } -// switch result { -// case .success(let response): -// self.hashtags = response.value -// case .failure: -// break -// } -// } -// .store(in: &disposeBag) } - } diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift new file mode 100644 index 000000000..49ba6d111 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/Cells/SearchResultDefaultSectionTableViewCell.swift @@ -0,0 +1,26 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonAsset + +class SearchResultDefaultSectionTableViewCell: UITableViewCell { + static let reuseIdentifier = "SearchResultDefaultSectionTableViewCell" + + func configure(item: SearchResultOverviewItem.DefaultSectionEntry) { + var content = UIListContentConfiguration.cell() + content.image = item.icon + content.text = item.title + content.imageProperties.tintColor = Asset.Colors.Brand.blurple.color + + self.contentConfiguration = content + } + + func configure(item: SearchResultOverviewItem.SuggestionSectionEntry) { + var content = UIListContentConfiguration.cell() + content.image = item.icon + content.text = item.title + content.imageProperties.tintColor = Asset.Colors.Brand.blurple.color + + self.contentConfiguration = content + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift new file mode 100644 index 000000000..895f26ff1 --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultOverviewSection.swift @@ -0,0 +1,71 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonSDK +import CoreDataStack + +enum SearchResultOverviewSection: Hashable { + case `default` + case suggestions +} + +enum SearchResultOverviewItem: Hashable { + case `default`(DefaultSectionEntry) + case suggestion(SuggestionSectionEntry) + + enum DefaultSectionEntry: Hashable { + case posts(String) + case people(String) + case profile(String, String) + case openLink(String) + + var title: String { + switch self { + //TODO: Add localization + case .posts(let text): + return "Posts with \(text)" + case .people(let username): + return "People with \(username)" + case .profile(let username, let instanceName): + return "Go to @\(username)@\(instanceName)" + case .openLink(_): + return "Open Link" + } + } + + var icon: UIImage? { + switch self { + case .posts(_): + return UIImage(systemName: "number") + case .people(_): + return UIImage(systemName: "person.2") + case .profile(_, _): + return UIImage(systemName: "person.crop.circle") + case .openLink(_): + return UIImage(systemName: "link") + } + } + } + + enum SuggestionSectionEntry: Hashable { + //TODO: Use User instead + case hashtag(tag: Mastodon.Entity.Tag) + case profile(ManagedObjectRecord) + + var title: String? { + if case let .hashtag(tag) = self { + return tag.name + } else { + return nil + } + } + + var icon: UIImage? { + if case let .hashtag(tag) = self { + return UIImage(systemName: "number") + } else { + return nil + } + } + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift new file mode 100644 index 000000000..60adc503e --- /dev/null +++ b/Mastodon/Scene/Search/SearchDetail/Search Results Overview/SearchResultsOverviewTableViewController.swift @@ -0,0 +1,166 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import UIKit +import MastodonCore +import MastodonSDK + +protocol SearchResultsOverviewTableViewControllerDeleagte: AnyObject { + func showPosts(_ viewController: UIViewController) + func showPeople(_ viewController: UIViewController) + func showProfile(_ viewController: UIViewController) + func openLink(_ viewController: UIViewController) +} + +// we could move lots of this stuff to a coordinator, it's too much for work a viewcontroller +class SearchResultsOverviewTableViewController: UIViewController { + // similar to the other search results view controller but without the whole statemachine bullshit + // with scope all + + let appContext: AppContext + let authContext: AuthContext + + private let tableView: UITableView + var dataSource: UITableViewDiffableDataSource? + + weak var delegate: SearchResultsOverviewTableViewControllerDeleagte? + + init(appContext: AppContext, authContext: AuthContext) { + + self.appContext = appContext + self.authContext = authContext + + tableView = UITableView(frame: .zero, style: .insetGrouped) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.backgroundColor = .systemGroupedBackground + tableView.register(SearchResultDefaultSectionTableViewCell.self, forCellReuseIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier) + tableView.register(StatusTableViewCell.self, forCellReuseIdentifier: StatusTableViewCell.reuseIdentifier) + tableView.register(HashtagTableViewCell.self, forCellReuseIdentifier: HashtagTableViewCell.reuseIdentifier) + tableView.register(UserTableViewCell.self, forCellReuseIdentifier: UserTableViewCell.reuseIdentifier) + + let dataSource = UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, itemIdentifier in + switch itemIdentifier { + + case .default(let item): + guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultDefaultSectionTableViewCell else { fatalError() } + + cell.configure(item: item) + + return cell + + case .suggestion(let suggestion): + switch suggestion { + + case .hashtag(let hashtag): + guard let cell = tableView.dequeueReusableCell(withIdentifier: SearchResultDefaultSectionTableViewCell.reuseIdentifier, for: indexPath) as? SearchResultDefaultSectionTableViewCell else { fatalError() } + + cell.configure(item: .hashtag(tag: hashtag)) + return cell + + case .profile(let profile): + guard let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as? UserTableViewCell else { fatalError() } + +// cell.configure(me: <#T##MastodonUser?#>, tableView: <#T##UITableView#>, viewModel: <#T##UserTableViewCell.ViewModel#>, delegate: <#T##UserTableViewCellDelegate?#>) + + return cell + } + + } + } + + super.init(nibName: nil, bundle: nil) + tableView.dataSource = dataSource + tableView.delegate = self + self.dataSource = dataSource + + + view.addSubview(tableView) + tableView.pinToParent() + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func showStandardSearch(for searchText: String) { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.default, .suggestions]) + snapshot.appendItems([.default(.posts(searchText)), + .default(.people(searchText)), + .default(.profile(searchText, authContext.mastodonAuthenticationBox.domain))], toSection: .default) + + if URL(string: searchText) != nil { + //TODO: Check if Mastodon-URL + snapshot.appendItems([.default(.openLink(searchText))], toSection: .default) + } + dataSource?.apply(snapshot, animatingDifferences: false) + } + + func searchForSuggestions(for searchText: String) { + + let query = Mastodon.API.V2.Search.Query( + q: searchText, + type: .default, + resolve: true + ) + + Task { + do { + let searchResult = try await appContext.apiService.search( + query: query, + authenticationBox: authContext.mastodonAuthenticationBox + ).value + + let firstThreeHashtags = searchResult.hashtags.prefix(3) + let firstThreeUsers = searchResult.accounts.prefix(3) + + guard var snapshot = dataSource?.snapshot() else { return } + + snapshot.deleteItems(snapshot.itemIdentifiers(inSection: .suggestions)) + snapshot.appendItems(firstThreeHashtags.map { .suggestion(.hashtag(tag: $0)) }, toSection: .suggestions ) +// snapshot.appendItems(firstThreeUsers.map { .suggestion(.profile($0.displayName)) }, toSection: .suggestions ) + + await MainActor.run { + dataSource?.apply(snapshot, animatingDifferences: false) + } + + + + } catch { + // do nothing + } + } + } +} + +//MARK: UITableViewDelegate +extension SearchResultsOverviewTableViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + + //TODO: Implement properly! + guard let snapshot = dataSource?.snapshot() else { return } + let section = snapshot.sectionIdentifiers[indexPath.section] + let item = snapshot.itemIdentifiers(inSection: section)[indexPath.row] + + switch item { + case .default(let defaultSectionEntry): + switch defaultSectionEntry { + case .posts(let string): + delegate?.showPosts(self) + case .people(let string): + delegate?.showPeople(self) + case .profile(let profile, let instanceName): + delegate?.showProfile(self) + case .openLink(let string): + delegate?.openLink(self) + } + case .suggestion(let suggestionSectionEntry): + switch suggestionSectionEntry { + + case .hashtag(_): + delegate?.showPosts(self) + case .profile(_): + delegate?.showProfile(self) + } + } + + tableView.deselectRow(at: indexPath, animated: true) + } +} diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift index 09889fc2d..3868e02dc 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewController.swift @@ -8,7 +8,6 @@ import os.log import UIKit import Combine -import Pageboy import MastodonAsset import MastodonCore import MastodonLocalization @@ -23,10 +22,7 @@ final class CustomSearchController: UISearchController { // Fake search bar not works on iPad with UISplitViewController // check device and fallback to standard UISearchController -final class SearchDetailViewController: PageboyViewController, NeedsDependency { - - let logger = Logger(subsystem: "SearchDetail", category: "UI") - +final class SearchDetailViewController: UIViewController, NeedsDependency { var disposeBag = Set() var observations = Set() @@ -38,7 +34,6 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency { }() var viewModel: SearchDetailViewModel! - var viewControllers: [SearchResultViewController]! let navigationBarVisualEffectBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let navigationBarBackgroundView = UIView() @@ -73,9 +68,7 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency { searchController.searchBar.setShowsScope(true, animated: false) } searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder - searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle } searchBar.sizeToFit() - searchBar.scopeBarBackgroundImage = UIImage() return searchBar }() @@ -86,9 +79,11 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency { searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context, authContext: viewModel.authContext) return searchHistoryViewController }() -} -extension SearchDetailViewController { + private(set) lazy var searchResultsOverviewViewController: SearchResultsOverviewTableViewController = { + let searchResultsOverviewViewController = SearchResultsOverviewTableViewController(appContext: context, authContext: viewModel.authContext) + return searchResultsOverviewViewController + }() override func viewDidLoad() { super.viewDidLoad() @@ -119,81 +114,43 @@ extension SearchDetailViewController { searchHistoryViewController.view.pinToParent() } - transition = Transition(style: .fade, duration: 0.1) - isScrollEnabled = false + searchResultsOverviewViewController.delegate = self - viewControllers = viewModel.searchScopes.map { scope in - let searchResultViewController = SearchResultViewController() - searchResultViewController.context = context - searchResultViewController.coordinator = coordinator - searchResultViewController.viewModel = SearchResultViewModel(context: context, authContext: viewModel.authContext, searchScope: scope) - - // bind searchText - viewModel.searchText - .assign(to: \.value, on: searchResultViewController.viewModel.searchText) - .store(in: &searchResultViewController.disposeBag) - - // bind navigationBarFrame - viewModel.navigationBarFrame - .receive(on: DispatchQueue.main) - .assign(to: \.value, on: searchResultViewController.viewModel.navigationBarFrame) - .store(in: &searchResultViewController.disposeBag) - return searchResultViewController + addChild(searchResultsOverviewViewController) + searchResultsOverviewViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(searchResultsOverviewViewController.view) + searchResultsOverviewViewController.didMove(toParent: self) + if isPhoneDevice { + NSLayoutConstraint.activate([ + searchResultsOverviewViewController.view.topAnchor.constraint(equalTo: navigationBarBackgroundView.bottomAnchor), + searchResultsOverviewViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + searchResultsOverviewViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + searchResultsOverviewViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } else { + searchResultsOverviewViewController.view.pinToParent() } - // set initial items from "all" search scope for non-appeared lists - if let allSearchScopeViewController = viewControllers.first(where: { $0.viewModel.searchScope == .all }) { - allSearchScopeViewController.viewModel.$items - .receive(on: DispatchQueue.main) - .sink { [weak self] items in - guard let self = self else { return } - guard self.currentViewController === allSearchScopeViewController else { return } - for viewController in self.viewControllers where viewController != allSearchScopeViewController { - // do not change appeared list - guard !viewController.viewModel.viewDidAppear.value else { continue } - // set initial items - switch viewController.viewModel.searchScope { - case .all: - assertionFailure() - break - case .people: - viewController.viewModel.userFetchedResultsController.userIDs = allSearchScopeViewController.viewModel.userFetchedResultsController.userIDs - case .hashtags: - viewController.viewModel.hashtags = allSearchScopeViewController.viewModel.hashtags - case .posts: - viewController.viewModel.statusFetchedResultsController.statusIDs = allSearchScopeViewController.viewModel.statusFetchedResultsController.statusIDs - } - } - } - .store(in: &allSearchScopeViewController.disposeBag) - } - - dataSource = self - delegate = self - - // bind search bar scope - viewModel.selectedSearchScope + // bind search trigger + // "local" search + viewModel.searchText + .removeDuplicates() .receive(on: DispatchQueue.main) - .sink { [weak self] searchScope in - guard let self = self else { return } - if let index = self.viewModel.searchScopes.firstIndex(of: searchScope) { - self.searchBar.selectedScopeButtonIndex = index - self.scrollToPage(.at(index: index), animated: true) - } + .sink { [weak self] searchText in + guard let self else { return } + + self.searchResultsOverviewViewController.showStandardSearch(for: searchText) } .store(in: &disposeBag) - // bind search trigger + // delayed search on server viewModel.searchText .removeDuplicates() .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) .sink { [weak self] searchText in - guard let self = self else { return } - guard let searchResultViewController = self.currentViewController as? SearchResultViewController else { - return - } - self.logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): trigger search \(searchText)") - searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self) + guard let self else { return } + + self.searchResultsOverviewViewController.searchForSuggestions(for: searchText) } .store(in: &disposeBag) @@ -203,7 +160,9 @@ extension SearchDetailViewController { .receive(on: DispatchQueue.main) .sink { [weak self] searchText in guard let self = self else { return } + self.searchHistoryViewController.view.isHidden = !searchText.isEmpty + self.searchResultsOverviewViewController.view.isHidden = searchText.isEmpty } .store(in: &disposeBag) } @@ -253,7 +212,6 @@ extension SearchDetailViewController { } } } - } extension SearchDetailViewController { @@ -292,7 +250,6 @@ extension SearchDetailViewController { searchController.searchBar.sizeToFit() } - searchBar.text = viewModel.searchText.value searchBar.delegate = self } @@ -305,12 +262,7 @@ extension SearchDetailViewController { // MARK: - UISearchBarDelegate extension SearchDetailViewController: UISearchBarDelegate { - func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) { - viewModel.selectedSearchScope.value = viewModel.searchScopes[selectedScope] - } - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): searchTest \(searchText)") viewModel.searchText.value = searchText.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -322,77 +274,23 @@ extension SearchDetailViewController: UISearchBarDelegate { navigationController?.popViewController(animated: false) } } - } -// MARK: - PageboyViewControllerDataSource -extension SearchDetailViewController: PageboyViewControllerDataSource { - - func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { - return viewControllers.count +//MARK: SearchResultsOverviewViewControllerDelegate +extension SearchDetailViewController: SearchResultsOverviewTableViewControllerDeleagte { + func showPosts(_ viewController: UIViewController) { + //TODO: Implement } - func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { - guard index < viewControllers.count else { return nil } - return viewControllers[index] + func showPeople(_ viewController: UIViewController) { + //TODO: Implement } - func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { - return .first + func showProfile(_ viewController: UIViewController) { + //TODO: Implement } -} - -// MARK: - PageboyViewControllerDelegate -extension SearchDetailViewController: PageboyViewControllerDelegate { - - func pageboyViewController( - _ pageboyViewController: PageboyViewController, - willScrollToPageAt index: PageboyViewController.PageIndex, - direction: PageboyViewController.NavigationDirection, - animated: Bool - ) { - // do nothing - } - - func pageboyViewController( - _ pageboyViewController: PageboyViewController, - didScrollTo position: CGPoint, - direction: PageboyViewController.NavigationDirection, - animated: Bool - ) { - // do nothing - } - - func pageboyViewController( - _ pageboyViewController: PageboyViewController, - didCancelScrollToPageAt index: PageboyViewController.PageIndex, - returnToPageAt previousIndex: PageboyViewController.PageIndex - ) { - // do nothing - } - - func pageboyViewController( - _ pageboyViewController: PageboyViewController, - didScrollToPageAt index: PageboyViewController.PageIndex, - direction: PageboyViewController.NavigationDirection, - animated: Bool - ) { - logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): index \(index)") - - let searchResultViewController = viewControllers[index] - viewModel.selectedSearchScope.value = searchResultViewController.viewModel.searchScope - - // trigger fetch - searchResultViewController.viewModel.stateMachine.enter(SearchResultViewModel.State.Loading.self) - } - - - func pageboyViewController( - _ pageboyViewController: PageboyViewController, - didReloadWith currentViewController: UIViewController, - currentPageIndex: PageboyViewController.PageIndex - ) { - // do nothing + func openLink(_ viewController: UIViewController) { + //TODO: Implement } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift index 425722e37..04228ba5a 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchDetailViewModel.swift @@ -5,7 +5,6 @@ // Created by MainasuK Cirno on 2021-7-13. // -import os.log import Foundation import CoreGraphics import Combine @@ -15,48 +14,37 @@ import MastodonAsset import MastodonLocalization final class SearchDetailViewModel { - + // input let authContext: AuthContext var needsBecomeFirstResponder = false let viewDidAppear = PassthroughSubject() let navigationBarFrame = CurrentValueSubject(.zero) - + // output let searchScopes = SearchScope.allCases let selectedSearchScope = CurrentValueSubject(.all) let searchText: CurrentValueSubject let searchActionPublisher = PassthroughSubject() - + init(authContext: AuthContext, initialSearchText: String = "") { self.authContext = authContext self.searchText = CurrentValueSubject(initialSearchText) } } -extension SearchDetailViewModel { - enum SearchScope: CaseIterable { - case all - case people - case hashtags - case posts - - var segmentedControlTitle: String { - switch self { - case .all: return L10n.Scene.Search.Searching.Segment.all - case .people: return L10n.Scene.Search.Searching.Segment.people - case .hashtags: return L10n.Scene.Search.Searching.Segment.hashtags - case .posts: return L10n.Scene.Search.Searching.Segment.posts - } - } - - var searchType: Mastodon.API.V2.Search.SearchType { - switch self { +enum SearchScope: CaseIterable { + case all + case people + case hashtags + case posts + + var searchType: Mastodon.API.V2.Search.SearchType { + switch self { case .all: return .default case .people: return .accounts case .hashtags: return .hashtags case .posts: return .statuses - } } } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift index c8938c549..ccb74e31e 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/Cell/HashtagTableViewCell.swift @@ -9,6 +9,8 @@ import UIKit import MetaTextKit final class HashtagTableViewCell: UITableViewCell { + + static let reuseIdentifier = "HashtagTableViewCell" let primaryLabel = MetaLabel(style: .statusName) diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift index 093e0f971..cf7a6a0f6 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewController.swift @@ -14,8 +14,6 @@ import MastodonUI final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { - let logger = Logger(subsystem: "SearchResultViewController", category: "ViewController") - weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift index 3fdf65436..47832e156 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel+State.swift @@ -5,7 +5,6 @@ // Created by MainasuK Cirno on 2021-7-14. // -import os.log import Foundation import GameplayKit import MastodonSDK diff --git a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift index cd9e866bc..0c5c5868f 100644 --- a/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchDetail/SearchResult/SearchResultViewModel.swift @@ -20,7 +20,7 @@ final class SearchResultViewModel { // input let context: AppContext let authContext: AuthContext - let searchScope: SearchDetailViewModel.SearchScope + let searchScope: SearchScope let searchText = CurrentValueSubject("") @Published var hashtags: [Mastodon.Entity.Tag] = [] let userFetchedResultsController: UserFetchedResultsController @@ -48,7 +48,7 @@ final class SearchResultViewModel { }() let didDataSourceUpdate = PassthroughSubject() - init(context: AppContext, authContext: AuthContext, searchScope: SearchDetailViewModel.SearchScope) { + init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all) { self.context = context self.authContext = authContext self.searchScope = searchScope diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift index 16285ebeb..6c8c82527 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCell.swift @@ -13,6 +13,8 @@ import MastodonLocalization import MastodonUI final class StatusTableViewCell: UITableViewCell { + + static let reuseIdentifier = "StatusTableViewCell" static let marginForRegularHorizontalSizeClass: CGFloat = 64 diff --git a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift index a05b80e9c..0f316bad8 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/UserTableViewCell.swift @@ -16,7 +16,8 @@ import MastodonSDK protocol UserTableViewCellDelegate: UserViewDelegate, AnyObject { } final class UserTableViewCell: UITableViewCell { - + + static let reuseIdentifier = "UserTableViewCell" weak var delegate: UserTableViewCellDelegate? let userView = UserView() diff --git a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift index d95a62bbb..cf4f1fc07 100644 --- a/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift +++ b/MastodonSDK/Sources/MastodonCore/FetchedResultsController/UserFetchedResultsController.swift @@ -97,7 +97,6 @@ extension UserFetchedResultsController { // MARK: - NSFetchedResultsControllerDelegate extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { public func controller(_ controller: NSFetchedResultsController, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) let indexes = userIDs let objects = fetchedResultsController.fetchedObjects ?? [] diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift index 5f49d80f0..1acd3e84c 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+History.swift @@ -16,7 +16,7 @@ extension Mastodon.Entity { /// 2021/1/28 /// # Reference /// [Document](https://docs.joinmastodon.org/entities/history/) - public struct History: Codable, Sendable { + public struct History: Hashable, Codable, Sendable { /// UNIX timestamp on midnight of the given day public let day: Date public let uses: String diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index d7d2faf48..ebae0d12e 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -361,8 +361,6 @@ extension StatusView.ViewModel { statusView.statusCardControl.alpha = isContentReveal ? 1 : 0 statusView.setSpoilerOverlayViewHidden(isHidden: isContentReveal) - - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): isContentReveal: \(isContentReveal)") } .store(in: &disposeBag) @@ -400,7 +398,6 @@ extension StatusView.ViewModel { $mediaViewConfigurations .sink { [weak self] configurations in guard let self = self else { return } - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): configure media") statusView.mediaGridContainerView.prepareForReuse()