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"?>
<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">
<attribute name="identifier" optional="YES" attributeType="UUID" usesScalarValueType="NO"/>
<attribute name="name" attributeType="String"/>
@ -138,12 +138,18 @@
<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"/>
</entity>
<entity name="PrivateNote" representedClassName="PrivateNote" syncable="YES">
<entity name="PrivateNote" representedClassName=".PrivateNote" syncable="YES">
<attribute name="note" optional="YES" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
<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"/>
</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">
<attribute name="content" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
@ -201,8 +207,9 @@
<element name="Mention" positionX="9" positionY="108" width="128" height="134"/>
<element name="Poll" positionX="72" positionY="162" width="128" height="194"/>
<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="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>
</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 */; };
2D04F42525C255B9003F936F /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D04F42425C255B9003F936F /* APIService+PublicTimeline.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 */; };
2D152A9225C2980C009AA50C /* UIFont.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D152A9125C2980C009AA50C /* UIFont.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>"; };
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>"; };
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>"; };
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>"; };
@ -1398,6 +1400,7 @@
isa = PBXGroup;
children = (
DB89BA2625C110B4008580ED /* Status.swift */,
2D0B7A1C261D839600B44727 /* SearchHistory.swift */,
DB8AF52425C131D1002E6C99 /* MastodonUser.swift */,
DB8AF56725C13E2A002E6C99 /* HomeTimelineIndex.swift */,
2D927F0125C7E4F2004F19B8 /* Mention.swift */,
@ -2333,6 +2336,7 @@
2DF75BB925D1474100694EC8 /* ManagedObjectObserver.swift in Sources */,
2D927F0225C7E4F2004F19B8 /* Mention.swift in Sources */,
DB89BA1D25C1107F008580ED /* URL.swift in Sources */,
2D0B7A1D261D839600B44727 /* SearchHistory.swift in Sources */,
2D927F0825C7E9A8004F19B8 /* Tag.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View File

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

View File

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

View File

@ -5,8 +5,12 @@
// Created by sxiaojian on 2021/4/2.
//
import Combine
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import OSLog
import UIKit
extension SearchViewController {
@ -20,7 +24,7 @@ extension SearchViewController {
searchingTableView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
searchingTableView.frameLayoutGuide.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
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()
viewModel.isSearching
@ -29,6 +33,42 @@ extension SearchViewController {
self?.searchingTableView.isHidden = !isSearching
}
.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
}
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
let searchingTableView: UITableView = {
let tableView = UITableView()
tableView.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color
tableView.rowHeight = UITableView.automaticDimension
tableView.separatorStyle = .singleLine
tableView.backgroundColor = .white
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 {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = Asset.Colors.Background.search.color
view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color
searchBar.delegate = self
navigationItem.titleView = searchBar
navigationItem.hidesBackButton = true
@ -95,6 +117,7 @@ extension SearchViewController {
setupAccountsCollectionView()
setupSearchingTableView()
setupDataSource()
setupSearchHeader()
}
func setupScrollView() {
@ -120,7 +143,7 @@ extension SearchViewController {
func setupDataSource() {
viewModel.hashTagDiffableDataSource = RecommendHashTagSection.collectionViewDiffableDataSource(for: hashTagCollectionView)
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 CoreData
import CoreDataStack
import Foundation
import GameplayKit
import MastodonSDK
import OSLog
import UIKit
final class SearchViewModel {
final class SearchViewModel: NSObject {
var disposeBag = Set<AnyCancellable>()
// input
@ -51,15 +53,21 @@ final class SearchViewModel {
init(context: AppContext) {
self.context = context
super.init()
guard let activeMastodonAuthenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else {
return
}
Publishers.CombineLatest(
searchText
.filter { !$0.isEmpty }
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
searchScope)
searchScope
)
.filter { text, _ in
!text.isEmpty
}
.flatMap { (text, scope) -> AnyPublisher<Mastodon.Response.Content<Mastodon.Entity.SearchResult>, Error> in
let query = Mastodon.API.Search.Query(accountID: nil,
maxID: nil,
minID: nil,
@ -82,10 +90,42 @@ final class SearchViewModel {
.sink { [weak self] isSearching in
if !isSearching {
self?.searchResult.value = nil
self?.searchText.value = ""
}
}
.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()
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
@ -190,4 +230,67 @@ final class SearchViewModel {
.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.
//
import CoreData
import CoreDataStack
import Foundation
import MastodonSDK
import UIKit
@ -12,7 +14,7 @@ import UIKit
final class SearchingTableViewCell: UITableViewCell {
let _imageView: UIImageView = {
let imageView = UIImageView()
imageView.tintColor = .black
imageView.tintColor = Asset.Colors.Label.primary.color
return imageView
}()
@ -50,6 +52,7 @@ final class SearchingTableViewCell: UITableViewCell {
extension SearchingTableViewCell {
private func configure() {
backgroundColor = .clear
selectionStyle = .none
contentView.addSubview(_imageView)
_imageView.pin(toSize: CGSize(width: 42, height: 42))
@ -75,6 +78,16 @@ extension SearchingTableViewCell {
_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) {
let image = UIImage(systemName: "number.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 34, weight: .regular))!.withRenderingMode(.alwaysTemplate)
_imageView.image = image
@ -88,6 +101,22 @@ extension SearchingTableViewCell {
let string = L10n.Scene.Search.Recommend.HashTag.peopleTalking(String(peopleAreTalking))
_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

View File

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