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:
This commit is contained in:
Nathan Mattes 2023-09-15 17:45:22 +02:00
parent e8509a063d
commit 2e384f3cb5
18 changed files with 355 additions and 272 deletions

View File

@ -139,6 +139,9 @@
D8099078294BC8A30050219F /* PrivacyTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099077294BC8A30050219F /* PrivacyTableViewController.swift */; }; D8099078294BC8A30050219F /* PrivacyTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099077294BC8A30050219F /* PrivacyTableViewController.swift */; };
D809907A294BC9390050219F /* PrivacyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099079294BC9390050219F /* PrivacyTableViewCell.swift */; }; D809907A294BC9390050219F /* PrivacyTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8099079294BC9390050219F /* PrivacyTableViewCell.swift */; };
D809907C294D25510050219F /* PrivacyViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D809907B294D25510050219F /* PrivacyViewModel.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 */; }; D8363B1629469CE200A74079 /* OnboardingNextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8363B1529469CE200A74079 /* OnboardingNextView.swift */; };
D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; }; D87BFC8B291D5C6B00FEE264 /* MastodonLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8A291D5C6B00FEE264 /* MastodonLoginView.swift */; };
D87BFC8D291EB81200FEE264 /* MastodonLoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D87BFC8C291EB81200FEE264 /* MastodonLoginViewModel.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 = "<group>"; }; D8099077294BC8A30050219F /* PrivacyTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewController.swift; sourceTree = "<group>"; };
D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = "<group>"; }; D8099079294BC9390050219F /* PrivacyTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyTableViewCell.swift; sourceTree = "<group>"; };
D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = "<group>"; }; D809907B294D25510050219F /* PrivacyViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyViewModel.swift; sourceTree = "<group>"; };
D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsOverviewTableViewController.swift; sourceTree = "<group>"; };
D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultOverviewSection.swift; sourceTree = "<group>"; };
D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultDefaultSectionTableViewCell.swift; sourceTree = "<group>"; };
D82463522A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Intents.strings; sourceTree = "<group>"; }; D82463522A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/Intents.strings; sourceTree = "<group>"; };
D82463532A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/WidgetExtension.strings; sourceTree = "<group>"; }; D82463532A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/WidgetExtension.strings; sourceTree = "<group>"; };
D82463542A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/InfoPlist.strings; sourceTree = "<group>"; }; D82463542A52B47B00A3DBDD /* be */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = be; path = be.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@ -1791,6 +1797,24 @@
path = Privacy; path = Privacy;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D81A22732AB4641F00905D71 /* Search Results Overview */ = {
isa = PBXGroup;
children = (
D81A22792AB47B8400905D71 /* Cells */,
D81A22742AB4643200905D71 /* SearchResultsOverviewTableViewController.swift */,
D81A22772AB4782400905D71 /* SearchResultOverviewSection.swift */,
);
path = "Search Results Overview";
sourceTree = "<group>";
};
D81A22792AB47B8400905D71 /* Cells */ = {
isa = PBXGroup;
children = (
D81A227A2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift */,
);
path = Cells;
sourceTree = "<group>";
};
D8A6AB68291C50F3003AB663 /* Login */ = { D8A6AB68291C50F3003AB663 /* Login */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -2898,6 +2922,7 @@
DBF1D24F269DAF6100C1C08A /* SearchDetail */ = { DBF1D24F269DAF6100C1C08A /* SearchDetail */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D81A22732AB4641F00905D71 /* Search Results Overview */,
DB4F0964269ED06700D62E92 /* SearchResult */, DB4F0964269ED06700D62E92 /* SearchResult */,
DBF1D252269DB01700C1C08A /* SearchHistory */, DBF1D252269DB01700C1C08A /* SearchHistory */,
DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */, DBF1D24D269DAF5D00C1C08A /* SearchDetailViewController.swift */,
@ -3538,6 +3563,7 @@
DB3E6FE02806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift in Sources */, DB3E6FE02806A4ED00B035AE /* DiscoveryHashtagsViewModel.swift in Sources */,
2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */, 2D38F1F725CD47AC00561493 /* HomeTimelineViewModel+LoadOldestState.swift in Sources */,
DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */, DB0FCB7427956939006C02E2 /* DataSourceFacade+Status.swift in Sources */,
D81A22782AB4782400905D71 /* SearchResultOverviewSection.swift in Sources */,
DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */, DBB525502611ED6D002F1F29 /* ProfileHeaderView.swift in Sources */,
DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */, DB63F75A279953F200455B82 /* SearchHistoryUserCollectionViewCell+ViewModel.swift in Sources */,
DB023D26279FFB0A005AC798 /* ShareActivityProvider.swift in Sources */, DB023D26279FFB0A005AC798 /* ShareActivityProvider.swift in Sources */,
@ -3692,6 +3718,7 @@
6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */, 6213AF5A28939C8400BCADB6 /* BookmarkViewModel.swift in Sources */,
5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */, 5B24BBDB262DB14800A9381B /* ReportStatusViewModel+Diffable.swift in Sources */,
5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */, 5DA732CC2629CEF500A92342 /* UIView+Remove.swift in Sources */,
D81A22752AB4643200905D71 /* SearchResultsOverviewTableViewController.swift in Sources */,
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */, 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */,
DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */,
DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */, DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */,
@ -3790,6 +3817,7 @@
2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */, 2DAC9E46262FC9FD0062E1A6 /* SuggestionAccountTableViewCell.swift in Sources */,
DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */, DB4FFC2C269EC39600D62E92 /* SearchTransitionController.swift in Sources */,
2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */, 2A3D9B7E29A8F33A00F30313 /* StatusHistoryView.swift in Sources */,
D81A227B2AB47B9A00905D71 /* SearchResultDefaultSectionTableViewCell.swift in Sources */,
6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */, 6213AF5E2893A8B200BCADB6 /* DataSourceFacade+Bookmark.swift in Sources */,
DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */, DBA5E7A9263BD3A4004598BB /* ContextMenuImagePreviewViewController.swift in Sources */,
DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */, DBF156E22702DA6900EC00B7 /* UIStatusBarManager+HandleTapAction.m in Sources */,

View File

@ -41,13 +41,8 @@ extension HomeTimelineViewModel {
.sink { [weak self] records in .sink { [weak self] records in
guard let self = self else { return } guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource 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 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() let oldSnapshot = diffableDataSource.snapshot()
var newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> = { var newSnapshot: NSDiffableDataSourceSnapshot<StatusSection, StatusItem> = {
let newItems = records.map { record in let newItems = records.map { record in

View File

@ -21,9 +21,6 @@ final class HeightFixedSearchBar: UISearchBar {
} }
final class SearchViewController: UIViewController, NeedsDependency { final class SearchViewController: UIViewController, NeedsDependency {
let logger = Logger(subsystem: "SearchViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }
@ -37,16 +34,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
let titleViewContainer = UIView() let titleViewContainer = UIView()
let searchBar = HeightFixedSearchBar() 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 // value is the initial search text to set
let searchBarTapPublisher = PassthroughSubject<String, Never>() let searchBarTapPublisher = PassthroughSubject<String, Never>()
@ -62,11 +49,6 @@ final class SearchViewController: UIViewController, NeedsDependency {
) )
return viewController return viewController
}() }()
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
} }
extension SearchViewController { extension SearchViewController {
@ -85,30 +67,12 @@ extension SearchViewController {
title = L10n.Scene.Search.title title = L10n.Scene.Search.title
setupSearchBar() 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 } guard let discoveryViewController = self.discoveryViewController else { return }
addChild(discoveryViewController) addChild(discoveryViewController)
discoveryViewController.view.translatesAutoresizingMaskIntoConstraints = false discoveryViewController.view.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(discoveryViewController.view) view.addSubview(discoveryViewController.view)
discoveryViewController.view.pinToParent() discoveryViewController.view.pinToParent()
// discoveryViewController.view.isHidden = true
} }
override func viewDidAppear(_ animated: Bool) { override func viewDidAppear(_ animated: Bool) {
@ -183,12 +147,8 @@ extension SearchViewController: UISearchBarDelegate {
// MARK: - UISearchControllerDelegate // MARK: - UISearchControllerDelegate
extension SearchViewController: UISearchControllerDelegate { extension SearchViewController: UISearchControllerDelegate {
func willDismissSearchController(_ searchController: UISearchController) { func willDismissSearchController(_ searchController: UISearchController) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
searchController.isActive = true searchController.isActive = true
} }
func didPresentSearchController(_ searchController: UISearchController) {
logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)")
}
} }
// MARK: - ScrollViewContainer // MARK: - ScrollViewContainer
@ -200,23 +160,3 @@ extension SearchViewController: ScrollViewContainer {
discoveryViewController?.scrollToTop(animated: animated) 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)
// }
// }
//}

View File

@ -31,32 +31,5 @@ final class SearchViewModel: NSObject {
self.context = context self.context = context
self.authContext = authContext self.authContext = authContext
super.init() 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<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }
// .catch { error in Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, 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)
} }
} }

View File

@ -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
}
}

View File

@ -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<MastodonUser>)
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
}
}
}
}

View File

@ -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<SearchResultOverviewSection, SearchResultOverviewItem>?
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<SearchResultOverviewSection, SearchResultOverviewItem>(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<SearchResultOverviewSection, SearchResultOverviewItem>()
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)
}
}

View File

@ -8,7 +8,6 @@
import os.log import os.log
import UIKit import UIKit
import Combine import Combine
import Pageboy
import MastodonAsset import MastodonAsset
import MastodonCore import MastodonCore
import MastodonLocalization import MastodonLocalization
@ -23,10 +22,7 @@ final class CustomSearchController: UISearchController {
// Fake search bar not works on iPad with UISplitViewController // Fake search bar not works on iPad with UISplitViewController
// check device and fallback to standard UISearchController // check device and fallback to standard UISearchController
final class SearchDetailViewController: PageboyViewController, NeedsDependency { final class SearchDetailViewController: UIViewController, NeedsDependency {
let logger = Logger(subsystem: "SearchDetail", category: "UI")
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>() var observations = Set<NSKeyValueObservation>()
@ -38,7 +34,6 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
}() }()
var viewModel: SearchDetailViewModel! var viewModel: SearchDetailViewModel!
var viewControllers: [SearchResultViewController]!
let navigationBarVisualEffectBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) let navigationBarVisualEffectBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
let navigationBarBackgroundView = UIView() let navigationBarBackgroundView = UIView()
@ -73,9 +68,7 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
searchController.searchBar.setShowsScope(true, animated: false) searchController.searchBar.setShowsScope(true, animated: false)
} }
searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder searchBar.placeholder = L10n.Scene.Search.SearchBar.placeholder
searchBar.scopeButtonTitles = SearchDetailViewModel.SearchScope.allCases.map { $0.segmentedControlTitle }
searchBar.sizeToFit() searchBar.sizeToFit()
searchBar.scopeBarBackgroundImage = UIImage()
return searchBar return searchBar
}() }()
@ -86,9 +79,11 @@ final class SearchDetailViewController: PageboyViewController, NeedsDependency {
searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context, authContext: viewModel.authContext) searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context, authContext: viewModel.authContext)
return searchHistoryViewController return searchHistoryViewController
}() }()
}
extension SearchDetailViewController { private(set) lazy var searchResultsOverviewViewController: SearchResultsOverviewTableViewController = {
let searchResultsOverviewViewController = SearchResultsOverviewTableViewController(appContext: context, authContext: viewModel.authContext)
return searchResultsOverviewViewController
}()
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
@ -119,81 +114,43 @@ extension SearchDetailViewController {
searchHistoryViewController.view.pinToParent() searchHistoryViewController.view.pinToParent()
} }
transition = Transition(style: .fade, duration: 0.1) searchResultsOverviewViewController.delegate = self
isScrollEnabled = false
viewControllers = viewModel.searchScopes.map { scope in addChild(searchResultsOverviewViewController)
let searchResultViewController = SearchResultViewController() searchResultsOverviewViewController.view.translatesAutoresizingMaskIntoConstraints = false
searchResultViewController.context = context view.addSubview(searchResultsOverviewViewController.view)
searchResultViewController.coordinator = coordinator searchResultsOverviewViewController.didMove(toParent: self)
searchResultViewController.viewModel = SearchResultViewModel(context: context, authContext: viewModel.authContext, searchScope: scope) 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()
}
// bind searchText // bind search trigger
// "local" search
viewModel.searchText viewModel.searchText
.assign(to: \.value, on: searchResultViewController.viewModel.searchText) .removeDuplicates()
.store(in: &searchResultViewController.disposeBag)
// bind navigationBarFrame
viewModel.navigationBarFrame
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: \.value, on: searchResultViewController.viewModel.navigationBarFrame) .sink { [weak self] searchText in
.store(in: &searchResultViewController.disposeBag) guard let self else { return }
return searchResultViewController
}
// set initial items from "all" search scope for non-appeared lists self.searchResultsOverviewViewController.showStandardSearch(for: searchText)
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
.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)
}
} }
.store(in: &disposeBag) .store(in: &disposeBag)
// bind search trigger // delayed search on server
viewModel.searchText viewModel.searchText
.removeDuplicates() .removeDuplicates()
.throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true) .throttle(for: 0.5, scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] searchText in .sink { [weak self] searchText in
guard let self = self else { return } guard let self else { return }
guard let searchResultViewController = self.currentViewController as? SearchResultViewController else {
return self.searchResultsOverviewViewController.searchForSuggestions(for: searchText)
}
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)
} }
.store(in: &disposeBag) .store(in: &disposeBag)
@ -203,7 +160,9 @@ extension SearchDetailViewController {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] searchText in .sink { [weak self] searchText in
guard let self = self else { return } guard let self = self else { return }
self.searchHistoryViewController.view.isHidden = !searchText.isEmpty self.searchHistoryViewController.view.isHidden = !searchText.isEmpty
self.searchResultsOverviewViewController.view.isHidden = searchText.isEmpty
} }
.store(in: &disposeBag) .store(in: &disposeBag)
} }
@ -253,7 +212,6 @@ extension SearchDetailViewController {
} }
} }
} }
} }
extension SearchDetailViewController { extension SearchDetailViewController {
@ -292,7 +250,6 @@ extension SearchDetailViewController {
searchController.searchBar.sizeToFit() searchController.searchBar.sizeToFit()
} }
searchBar.text = viewModel.searchText.value
searchBar.delegate = self searchBar.delegate = self
} }
@ -305,12 +262,7 @@ extension SearchDetailViewController {
// MARK: - UISearchBarDelegate // MARK: - UISearchBarDelegate
extension SearchDetailViewController: UISearchBarDelegate { extension SearchDetailViewController: UISearchBarDelegate {
func searchBar(_ searchBar: UISearchBar, selectedScopeButtonIndexDidChange selectedScope: Int) {
viewModel.selectedSearchScope.value = viewModel.searchScopes[selectedScope]
}
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { 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) viewModel.searchText.value = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
} }
@ -322,77 +274,23 @@ extension SearchDetailViewController: UISearchBarDelegate {
navigationController?.popViewController(animated: false) navigationController?.popViewController(animated: false)
} }
} }
} }
// MARK: - PageboyViewControllerDataSource //MARK: SearchResultsOverviewViewControllerDelegate
extension SearchDetailViewController: PageboyViewControllerDataSource { extension SearchDetailViewController: SearchResultsOverviewTableViewControllerDeleagte {
func showPosts(_ viewController: UIViewController) {
func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { //TODO: Implement
return viewControllers.count
} }
func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { func showPeople(_ viewController: UIViewController) {
guard index < viewControllers.count else { return nil } //TODO: Implement
return viewControllers[index]
} }
func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { func showProfile(_ viewController: UIViewController) {
return .first //TODO: Implement
} }
} func openLink(_ 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
} }
} }

View File

@ -5,7 +5,6 @@
// Created by MainasuK Cirno on 2021-7-13. // Created by MainasuK Cirno on 2021-7-13.
// //
import os.log
import Foundation import Foundation
import CoreGraphics import CoreGraphics
import Combine import Combine
@ -34,22 +33,12 @@ final class SearchDetailViewModel {
} }
} }
extension SearchDetailViewModel { enum SearchScope: CaseIterable {
enum SearchScope: CaseIterable {
case all case all
case people case people
case hashtags case hashtags
case posts 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 { var searchType: Mastodon.API.V2.Search.SearchType {
switch self { switch self {
case .all: return .default case .all: return .default
@ -58,5 +47,4 @@ extension SearchDetailViewModel {
case .posts: return .statuses case .posts: return .statuses
} }
} }
}
} }

View File

@ -10,6 +10,8 @@ import MetaTextKit
final class HashtagTableViewCell: UITableViewCell { final class HashtagTableViewCell: UITableViewCell {
static let reuseIdentifier = "HashtagTableViewCell"
let primaryLabel = MetaLabel(style: .statusName) let primaryLabel = MetaLabel(style: .statusName)
let separatorLine = UIView.separatorLine let separatorLine = UIView.separatorLine

View File

@ -14,8 +14,6 @@ import MastodonUI
final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { final class SearchResultViewController: UIViewController, NeedsDependency, MediaPreviewableViewController {
let logger = Logger(subsystem: "SearchResultViewController", category: "ViewController")
weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } weak var context: AppContext! { willSet { precondition(!isViewLoaded) } }
weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } }

View File

@ -5,7 +5,6 @@
// Created by MainasuK Cirno on 2021-7-14. // Created by MainasuK Cirno on 2021-7-14.
// //
import os.log
import Foundation import Foundation
import GameplayKit import GameplayKit
import MastodonSDK import MastodonSDK

View File

@ -20,7 +20,7 @@ final class SearchResultViewModel {
// input // input
let context: AppContext let context: AppContext
let authContext: AuthContext let authContext: AuthContext
let searchScope: SearchDetailViewModel.SearchScope let searchScope: SearchScope
let searchText = CurrentValueSubject<String, Never>("") let searchText = CurrentValueSubject<String, Never>("")
@Published var hashtags: [Mastodon.Entity.Tag] = [] @Published var hashtags: [Mastodon.Entity.Tag] = []
let userFetchedResultsController: UserFetchedResultsController let userFetchedResultsController: UserFetchedResultsController
@ -48,7 +48,7 @@ final class SearchResultViewModel {
}() }()
let didDataSourceUpdate = PassthroughSubject<Void, Never>() let didDataSourceUpdate = PassthroughSubject<Void, Never>()
init(context: AppContext, authContext: AuthContext, searchScope: SearchDetailViewModel.SearchScope) { init(context: AppContext, authContext: AuthContext, searchScope: SearchScope = .all) {
self.context = context self.context = context
self.authContext = authContext self.authContext = authContext
self.searchScope = searchScope self.searchScope = searchScope

View File

@ -14,6 +14,8 @@ import MastodonUI
final class StatusTableViewCell: UITableViewCell { final class StatusTableViewCell: UITableViewCell {
static let reuseIdentifier = "StatusTableViewCell"
static let marginForRegularHorizontalSizeClass: CGFloat = 64 static let marginForRegularHorizontalSizeClass: CGFloat = 64
weak var delegate: StatusTableViewCellDelegate? weak var delegate: StatusTableViewCellDelegate?

View File

@ -17,6 +17,7 @@ protocol UserTableViewCellDelegate: UserViewDelegate, AnyObject { }
final class UserTableViewCell: UITableViewCell { final class UserTableViewCell: UITableViewCell {
static let reuseIdentifier = "UserTableViewCell"
weak var delegate: UserTableViewCellDelegate? weak var delegate: UserTableViewCellDelegate?
let userView = UserView() let userView = UserView()

View File

@ -97,7 +97,6 @@ extension UserFetchedResultsController {
// MARK: - NSFetchedResultsControllerDelegate // MARK: - NSFetchedResultsControllerDelegate
extension UserFetchedResultsController: NSFetchedResultsControllerDelegate { extension UserFetchedResultsController: NSFetchedResultsControllerDelegate {
public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) { public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>, didChangeContentWith snapshot: NSDiffableDataSourceSnapshotReference) {
os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
let indexes = userIDs let indexes = userIDs
let objects = fetchedResultsController.fetchedObjects ?? [] let objects = fetchedResultsController.fetchedObjects ?? []

View File

@ -16,7 +16,7 @@ extension Mastodon.Entity {
/// 2021/1/28 /// 2021/1/28
/// # Reference /// # Reference
/// [Document](https://docs.joinmastodon.org/entities/history/) /// [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 /// UNIX timestamp on midnight of the given day
public let day: Date public let day: Date
public let uses: String public let uses: String

View File

@ -361,8 +361,6 @@ extension StatusView.ViewModel {
statusView.statusCardControl.alpha = isContentReveal ? 1 : 0 statusView.statusCardControl.alpha = isContentReveal ? 1 : 0
statusView.setSpoilerOverlayViewHidden(isHidden: isContentReveal) 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) .store(in: &disposeBag)
@ -400,7 +398,6 @@ extension StatusView.ViewModel {
$mediaViewConfigurations $mediaViewConfigurations
.sink { [weak self] configurations in .sink { [weak self] configurations in
guard let self = self else { return } 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() statusView.mediaGridContainerView.prepareForReuse()