feature: add search history

This commit is contained in:
sunxiaojian 2021-04-07 19:49:33 +08:00
parent 90803fc544
commit d800e10bd7
10 changed files with 325 additions and 34 deletions

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D91" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier=""> <model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17709" systemVersion="20D75" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Application" representedClassName=".Application" syncable="YES"> <entity name="Application" representedClassName=".Application" syncable="YES">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/> <attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/> <attribute name="name" attributeType="String"/>
@ -138,12 +138,18 @@
<relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/> <relationship name="poll" maxCount="1" deletionRule="Nullify" destinationEntity="Poll" inverseName="options" inverseEntity="Poll"/>
<relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/> <relationship name="votedBy" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="votePollOptions" inverseEntity="MastodonUser"/>
</entity> </entity>
<entity name="PrivateNote" representedClassName="PrivateNote" syncable="YES"> <entity name="PrivateNote" representedClassName=".PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/> <attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/> <relationship name="from" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotesTo" inverseEntity="MastodonUser"/>
<relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/> <relationship name="to" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser" inverseName="privateNotes" inverseEntity="MastodonUser"/>
</entity> </entity>
<entity name="SearchHistory" representedClassName=".SearchHistory" syncable="YES">
<attribute name="createAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="identifier" attributeType="UUID" usesScalarValueType="NO"/>
<relationship name="account" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="MastodonUser"/>
<relationship name="hashTag" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Tag"/>
</entity>
<entity name="Status" representedClassName=".Status" syncable="YES"> <entity name="Status" representedClassName=".Status" syncable="YES">
<attribute name="content" attributeType="String"/> <attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/> <attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -201,8 +207,9 @@
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/> <element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/> <element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<element name="PollOption" positionX="81" positionY="171" width="128" height="134"/> <element name="PollOption" positionX="81" positionY="171" width="128" height="134"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/> <element name="PrivateNote" positionX="72" positionY="153" width="128" height="89"/>
<element name="Status" positionX="0" positionY="0" width="128" height="569"/>
<element name="Tag" positionX="18" positionY="117" width="128" height="119"/>
<element name="SearchHistory" positionX="72" positionY="162" width="128" height="89"/>
</elements> </elements>
</model> </model>

View File

@ -0,0 +1,54 @@
//
// SearchHistory.swift
// CoreDataStack
//
// Created by sxiaojian on 2021/4/7.
//
import Foundation
import CoreData
public final class SearchHistory: NSManagedObject {
public typealias ID = UUID
@NSManaged public private(set) var identifier: ID
@NSManaged public private(set) var createAt: Date
@NSManaged public private(set) var account: MastodonUser?
@NSManaged public private(set) var hashTag: Tag?
}
extension SearchHistory {
public override func awakeFromInsert() {
super.awakeFromInsert()
setPrimitiveValue(UUID(), forKey: #keyPath(SearchHistory.identifier))
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
account: MastodonUser
) -> SearchHistory {
let searchHistory: SearchHistory = context.insertObject()
searchHistory.account = account
searchHistory.createAt = Date()
return searchHistory
}
@discardableResult
public static func insert(
into context: NSManagedObjectContext,
hashTag: Tag
) -> SearchHistory {
let searchHistory: SearchHistory = context.insertObject()
searchHistory.hashTag = hashTag
searchHistory.createAt = Date()
return searchHistory
}
}
extension SearchHistory: Managed {
public static var defaultSortDescriptors: [NSSortDescriptor] {
return [NSSortDescriptor(keyPath: \SearchHistory.createAt, ascending: false)]
}
}

View File

@ -21,6 +21,7 @@
18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; }; 18BC7629F65E6DB12CB8416D /* Pods_Mastodon_MastodonUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C030226D3C73DCC23D67452 /* Pods_Mastodon_MastodonUITests.framework */; };
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; }; 2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */; };
2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; }; 2D0B7A0B261D5A5600B44727 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A0A261D5A5600B44727 /* Array.swift */; };
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D0B7A1C261D839600B44727 /* SearchHistory.swift */; };
2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; }; 2D152A8C25C295CC009AA50C /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A8B25C295CC009AA50C /* StatusView.swift */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; }; 2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.swift */; };
2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; };
@ -372,6 +373,7 @@
0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; }; 0FB3D33725E6401400AAD544 /* PickServerCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerCell.swift; sourceTree = "<group>"; };
2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; }; 2D04F42425C255B9003F936F /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = "<group>"; };
2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; }; 2D0B7A0A261D5A5600B44727 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = "<group>"; };
2D0B7A1C261D839600B44727 /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = "<group>"; };
2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; }; 2D152A8B25C295CC009AA50C /* StatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusView.swift; sourceTree = "<group>"; };
2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; }; 2D152A9125C2980C009AA50C /* UIFont.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIFont.swift; sourceTree = "<group>"; };
2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = "<group>"; };
@ -1398,6 +1400,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
DB89BA2625C110B4008580ED /* Status.swift */, DB89BA2625C110B4008580ED /* Status.swift */,
2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */, DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */, DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
2D927F0125C7E4F2004F19B8 /* Mention.swift */, 2D927F0125C7E4F2004F19B8 /* Mention.swift */,
@ -2333,6 +2336,7 @@
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */, 2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */,
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */, 2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
DB89BA1D25C1107F008580ED /* URL.swift in Sources */, DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */,
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */, 2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -5,6 +5,7 @@
// Created by sxiaojian on 2021/4/6. // Created by sxiaojian on 2021/4/6.
// //
import CoreData
import Foundation import Foundation
import MastodonSDK import MastodonSDK
@ -13,6 +14,10 @@ enum SearchResultItem {
case account(account: Mastodon.Entity.Account) case account(account: Mastodon.Entity.Account)
case accountObjectID(accountObjectID: NSManagedObjectID)
case hashTagObjectID(hashTagObjectID: NSManagedObjectID)
case bottomLoader case bottomLoader
} }
@ -25,6 +30,10 @@ extension SearchResultItem: Equatable {
return accountLeft == accountRight return accountLeft == accountRight
case (.bottomLoader, .bottomLoader): case (.bottomLoader, .bottomLoader):
return true return true
case (.accountObjectID(let idLeft),.accountObjectID(let idRight)):
return idLeft == idRight
case (.hashTagObjectID(let idLeft),.hashTagObjectID(let idRight)):
return idLeft == idRight
default: default:
return false return false
} }
@ -38,6 +47,10 @@ extension SearchResultItem: Hashable {
hasher.combine(account) hasher.combine(account)
case .hashTag(let tag): case .hashTag(let tag):
hasher.combine(tag) hasher.combine(tag)
case .accountObjectID(let id):
hasher.combine(id)
case .hashTagObjectID(let id):
hasher.combine(id)
case .bottomLoader: case .bottomLoader:
hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) hasher.combine(String(describing: SearchResultItem.bottomLoader.self))
} }

View File

@ -8,16 +8,20 @@
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import UIKit import UIKit
import CoreData
import CoreDataStack
enum SearchResultSection: Equatable, Hashable { enum SearchResultSection: Equatable, Hashable {
case account case account
case hashTag case hashTag
case mixed
case bottomLoader case bottomLoader
} }
extension SearchResultSection { extension SearchResultSection {
static func tableViewDiffableDataSource( static func tableViewDiffableDataSource(
for tableView: UITableView for tableView: UITableView,
dependency: NeedsDependency
) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> { ) -> UITableViewDiffableDataSource<SearchResultSection, SearchResultItem> {
UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in UITableViewDiffableDataSource(tableView: tableView) { (tableView, indexPath, result) -> UITableViewCell? in
switch result { switch result {
@ -29,6 +33,16 @@ extension SearchResultSection {
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
cell.config(with: tag) cell.config(with: tag)
return cell return cell
case .hashTagObjectID(let hashTagObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
let tag = dependency.context.managedObjectContext.object(with: hashTagObjectID) as! Tag
cell.config(with: tag)
return cell
case .accountObjectID(let accountObjectID):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchingTableViewCell.self), for: indexPath) as! SearchingTableViewCell
let user = dependency.context.managedObjectContext.object(with: accountObjectID) as! MastodonUser
cell.config(with: user)
return cell
case .bottomLoader: case .bottomLoader:
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: SearchBottomLoader.self)) as! SearchBottomLoader
cell.startAnimating() cell.startAnimating()

View File

@ -5,8 +5,12 @@
// Created by sxiaojian on 2021/4/2. // Created by sxiaojian on 2021/4/2.
// //
import Combine
import CoreData
import CoreDataStack
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import OSLog
import UIKit import UIKit
extension SearchViewController { extension SearchViewController {
@ -20,7 +24,7 @@ extension SearchViewController {
searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), searchingTableView.frameLayoutGuide.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), searchingTableView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor)
]) ])
searchingTableView.tableFooterView = UIView() searchingTableView.tableFooterView = UIView()
viewModel.isSearching viewModel.isSearching
@ -29,6 +33,42 @@ extension SearchViewController {
self?.searchingTableView.isHidden = !isSearching self?.searchingTableView.isHidden = !isSearching
} }
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest(
viewModel.isSearching,
viewModel.searchText
)
.sink { [weak self] isSearching, text in
guard let self = self else { return }
if isSearching, text.isEmpty {
self.searchingTableView.tableHeaderView = self.searchHeader
} else {
self.searchingTableView.tableHeaderView = nil
}
}
.store(in: &disposeBag)
}
func setupSearchHeader() {
searchHeader.addSubview(recentSearchesLabel)
recentSearchesLabel.constrain([
recentSearchesLabel.constraint(.leading, toView: searchHeader, constant: 16),
recentSearchesLabel.constraint(.centerY, toView: searchHeader)
])
searchHeader.addSubview(clearSearchHistoryButton)
recentSearchesLabel.constrain([
searchHeader.trailingAnchor.constraint(equalTo: clearSearchHistoryButton.trailingAnchor, constant: 16),
clearSearchHistoryButton.constraint(.centerY, toView: searchHeader)
])
clearSearchHistoryButton.addTarget(self, action: #selector(SearchViewController.clearAction(_:)), for: .touchUpInside)
}
}
extension SearchViewController {
@objc func clearAction(_ sender: UIButton) {
viewModel.deleteSearchHistory()
} }
} }
@ -43,5 +83,9 @@ extension SearchViewController: UITableViewDelegate {
66 66
} }
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {} func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let diffableDataSource = viewModel.searchResultDiffableDataSource else { return }
guard let item = diffableDataSource.itemIdentifier(for: indexPath) else { return }
viewModel.saveItemToCoreData(item: item)
}
} }

View File

@ -76,17 +76,39 @@ final class SearchViewController: UIViewController, NeedsDependency {
// searching // searching
let searchingTableView: UITableView = { let searchingTableView: UITableView = {
let tableView = UITableView() let tableView = UITableView()
tableView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine tableView.separatorStyle = .singleLine
tableView.backgroundColor = .white
return tableView return tableView
}() }()
lazy var searchHeader: UIView = {
let view = UIView()
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
view.frame = CGRect(origin: .zero, size: CGSize(width: searchingTableView.frame.width, height: 56))
return view
}()
let recentSearchesLabel: UILabel = {
let label = UILabel()
label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 20, weight: .semibold))
label.textColor = Asset.Colors.Label.primary.color
label.text = L10n.Scene.Search.Searching.recentSearch
return label
}()
let clearSearchHistoryButton: UIButton = {
let button = UIButton(type: .custom)
button.setTitleColor(Asset.Colors.buttonDefault.color, for: .normal)
button.setTitle(L10n.Scene.Search.Searching.clear, for: .normal)
return button
}()
} }
extension SearchViewController { extension SearchViewController {
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.search.color view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
searchBar.delegate = self searchBar.delegate = self
navigationItem.titleView = searchBar navigationItem.titleView = searchBar
navigationItem.hidesBackButton = true navigationItem.hidesBackButton = true
@ -95,6 +117,7 @@ extension SearchViewController {
setupAccountsCollectionView() setupAccountsCollectionView()
setupSearchingTableView() setupSearchingTableView()
setupDataSource() setupDataSource()
setupSearchHeader()
} }
func setupScrollView() { func setupScrollView() {
@ -120,7 +143,7 @@ extension SearchViewController {
func setupDataSource() { func setupDataSource() {
viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView) viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView)
viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView) viewModel.accountDiffableDataSource = RecommendAccountSection.collectionViewDiffableDataSource(for: accountsCollectionView)
viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView) viewModel.searchResultDiffableDataSource = SearchResultSection.tableViewDiffableDataSource(for: searchingTableView, dependency: self)
} }
} }

View File

@ -6,13 +6,15 @@
// //
import Combine import Combine
import CoreData
import CoreDataStack
import Foundation import Foundation
import GameplayKit import GameplayKit
import MastodonSDK import MastodonSDK
import OSLog import OSLog
import UIKit import UIKit
final class SearchViewModel { final class SearchViewModel: NSObject {
var disposeBag = Set<AnyCancellable>() var disposeBag = Set<AnyCancellable>()
// input // input
@ -51,41 +53,79 @@ final class SearchViewModel {
init(context: AppContext) { init(context: AppContext) {
self.context = context self.context = context
super.init()
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return return
} }
Publishers.CombineLatest( Publishers.CombineLatest(
searchText searchText
.filter { !$0.isEmpty }
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(), .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
searchScope) searchScope
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in )
let query = Mastodon.API.Search.Query(accountID: nil, .filter { text, _ in
maxID: nil, !text.isEmpty
minID: nil, }
type: scope, .flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
excludeUnreviewed: nil,
q: text, let query = Mastodon.API.Search.Query(accountID: nil,
resolve: nil, maxID: nil,
limit: nil, minID: nil,
offset: nil, type: scope,
following: nil) excludeUnreviewed: nil,
return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox) q: text,
} resolve: nil,
.sink { _ in limit: nil,
} receiveValue: { [weak self] result in offset: nil,
self?.searchResult.value = result.value following: nil)
} return context.apiService.search(domain: activeMastodonAuthenticationBox.domain, query: query, mastodonAuthenticationBox: activeMastodonAuthenticationBox)
.store(in: &disposeBag) }
.sink { _ in
} receiveValue: { [weak self] result in
self?.searchResult.value = result.value
}
.store(in: &disposeBag)
isSearching isSearching
.sink { [weak self] isSearching in .sink { [weak self] isSearching in
if !isSearching { if !isSearching {
self?.searchResult.value = nil self?.searchResult.value = nil
self?.searchText.value = ""
} }
} }
.store(in: &disposeBag) .store(in: &disposeBag)
Publishers.CombineLatest3(
isSearching,
searchText,
searchScope
)
.filter { isSearching, text, _ in
isSearching && text.isEmpty
}
.sink { [weak self] _, _, scope in
guard let self = self else { return }
guard let searchHistories = self.fetchSearchHistory() else { return }
guard let dataSource = self.searchResultDiffableDataSource else { return }
var snapshot = NSDiffableDataSourceSnapshot<SearchResultSection, SearchResultItem>()
snapshot.appendSections([.mixed])
searchHistories.forEach { searchHistory in
let containsAccount = scope == Mastodon.API.Search.Scope.accounts.rawValue || scope == ""
let containsHashTag = scope == Mastodon.API.Search.Scope.hashTags.rawValue || scope == ""
if let mastodonUser = searchHistory.account, containsAccount {
let item = SearchResultItem.accountObjectID(accountObjectID: mastodonUser.objectID)
snapshot.appendItems([item], toSection: .mixed)
}
if let tag = searchHistory.hashTag, containsHashTag {
let item = SearchResultItem.hashTagObjectID(hashTagObjectID: tag.objectID)
snapshot.appendItems([item], toSection: .mixed)
}
}
dataSource.apply(snapshot, animatingDifferences: false, completion: nil)
}
.store(in: &disposeBag)
requestRecommendHashTags() requestRecommendHashTags()
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
@ -190,4 +230,67 @@ final class SearchViewModel {
.store(in: &self.disposeBag) .store(in: &self.disposeBag)
} }
} }
func saveItemToCoreData(item: SearchResultItem) {
_ = context.managedObjectContext.performChanges { [weak self] in
guard let self = self else { return }
switch item {
case .account(let account):
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
// load request mastodon user
let requestMastodonUser: MastodonUser? = {
let request = MastodonUser.sortedFetchRequest
request.predicate = MastodonUser.predicate(domain: activeMastodonAuthenticationBox.domain, id: activeMastodonAuthenticationBox.userID)
request.fetchLimit = 1
request.returnsObjectsAsFaults = false
do {
return try self.context.managedObjectContext.fetch(request).first
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
let (mastodonUser, _) = APIService.CoreData.createOrMergeMastodonUser(into: self.context.managedObjectContext, for: requestMastodonUser, in: activeMastodonAuthenticationBox.domain, entity: account, userCache: nil, networkDate: Date(), log: OSLog.api)
SearchHistory.insert(into: self.context.managedObjectContext, account: mastodonUser)
case .hashTag(let tag):
let histories = tag.history?[0 ... 2].compactMap { history -> History in
History.insert(into: self.context.managedObjectContext, property: History.Property(day: history.day, uses: history.uses, accounts: history.accounts))
}
let tagInCoreData = Tag.insert(into: self.context.managedObjectContext, property: Tag.Property(name: tag.name, url: tag.url, histories: histories))
SearchHistory.insert(into: self.context.managedObjectContext, hashTag: tagInCoreData)
default:
break
}
}
}
func fetchSearchHistory() -> [SearchHistory]? {
let searchHistory: [SearchHistory]? = {
let request = SearchHistory.sortedFetchRequest
request.predicate = nil
request.returnsObjectsAsFaults = false
do {
return try context.managedObjectContext.fetch(request)
} catch {
assertionFailure(error.localizedDescription)
return nil
}
}()
return searchHistory
}
func deleteSearchHistory() {
let result = fetchSearchHistory()
_ = context.managedObjectContext.performChanges { [weak self] in
result?.forEach { history in
self?.context.managedObjectContext.delete(history)
}
self?.isSearching.value = true
}
}
} }

View File

@ -5,6 +5,8 @@
// Created by sxiaojian on 2021/4/2. // Created by sxiaojian on 2021/4/2.
// //
import CoreData
import CoreDataStack
import Foundation import Foundation
import MastodonSDK import MastodonSDK
import UIKit import UIKit
@ -12,7 +14,7 @@ import UIKit
final class SearchingTableViewCell: UITableViewCell { final class SearchingTableViewCell: UITableViewCell {
let _imageView: UIImageView = { let _imageView: UIImageView = {
let imageView = UIImageView() let imageView = UIImageView()
imageView.tintColor = .black imageView.tintColor = Asset.Colors.Label.primary.color
return imageView return imageView
}() }()
@ -50,6 +52,7 @@ final class SearchingTableViewCell: UITableViewCell {
extension SearchingTableViewCell { extension SearchingTableViewCell {
private func configure() { private func configure() {
backgroundColor = .clear
selectionStyle = .none selectionStyle = .none
contentView.addSubview(_imageView) contentView.addSubview(_imageView)
_imageView.pin(toSize: CGSize(width: 42, height: 42)) _imageView.pin(toSize: CGSize(width: 42, height: 42))
@ -75,6 +78,16 @@ extension SearchingTableViewCell {
_subTitleLabel.text = account.acct _subTitleLabel.text = account.acct
} }
func config(with account: MastodonUser) {
_imageView.af.setImage(
withURL: URL(string: account.avatar)!,
placeholderImage: UIImage.placeholder(color: .systemFill),
imageTransition: .crossDissolve(0.2)
)
_titleLabel.text = account.displayName.isEmpty ? account.username : account.displayName
_subTitleLabel.text = account.acct
}
func config(with tag: Mastodon.Entity.Tag) { func config(with tag: Mastodon.Entity.Tag) {
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate) let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
_imageView.image = image _imageView.image = image
@ -88,6 +101,22 @@ extension SearchingTableViewCell {
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking)) let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
_subTitleLabel.text = string _subTitleLabel.text = string
} }
func config(with tag: Tag) {
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
_imageView.image = image
_titleLabel.text = "# " + tag.name
guard let historys = tag.histories?.sorted(by: {
$0.createAt.compare($1.createAt) == .orderedAscending
}) else {
_subTitleLabel.text = ""
return
}
let recentHistory = historys[0 ... 2]
let peopleAreTalking = recentHistory.compactMap { Int($0.accounts) }.reduce(0, +)
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
_subTitleLabel.text = string
}
} }
#if canImport(SwiftUI) && DEBUG #if canImport(SwiftUI) && DEBUG

View File

@ -18,7 +18,7 @@ class SearchRecommendCollectionHeader: UIView {
let descriptionLabel: UILabel = { let descriptionLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.textColor = Asset.Colors.lightSecondaryText.color label.textColor = Asset.Colors.Label.secondary.color
label.font = .preferredFont(forTextStyle: .body) label.font = .preferredFont(forTextStyle: .body)
label.numberOfLines = 0 label.numberOfLines = 0
label.lineBreakMode = .byWordWrapping label.lineBreakMode = .byWordWrapping