[WIP] Remove CoreData for Tags/Accounts in Search (IOS-196)

- Add basic, json-based persistence (it's WIP and pragmatic aka dirty, see FileManager+SearchHistory)
This commit is contained in:
Nathan Mattes 2023-11-23 13:58:56 +01:00
parent aeaaf87b68
commit 59c6d31ca4
22 changed files with 303 additions and 320 deletions

View File

@ -161,6 +161,8 @@
D886FBD329DF710F00272017 /* WelcomeSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D886FBD229DF710F00272017 /* WelcomeSeparatorView.swift */; };
D8916DC029211BE500124085 /* ContentSizedTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8916DBF29211BE500124085 /* ContentSizedTableView.swift */; };
D8A6AB6C291C5136003AB663 /* MastodonLoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8A6AB6B291C5136003AB663 /* MastodonLoginViewController.swift */; };
D8AC98762B0F61680045EC2B /* FileManager+SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */; };
D8AC98792B0F622B0045EC2B /* SearchHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AC98782B0F622B0045EC2B /* SearchHistory.swift */; };
D8B5E4EE2A4EB8930008970C /* NotificationSettingTableViewToggleCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4ED2A4EB8920008970C /* NotificationSettingTableViewToggleCell.swift */; };
D8B5E4F02A4EB8A00008970C /* NotificationSettingTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4EF2A4EB8A00008970C /* NotificationSettingTableViewCell.swift */; };
D8B5E4F22A4EBCF90008970C /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8B5E4F12A4EBCF90008970C /* NotificationSettings.swift */; };
@ -832,6 +834,8 @@
D8A6FE6429325F5900666A47 /* StringsConvertor */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = StringsConvertor; sourceTree = "<group>"; };
D8A6FE6529325F5900666A47 /* ios-infoPlist.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "ios-infoPlist.json"; sourceTree = "<group>"; };
D8A6FE6629325F5900666A47 /* Localizable.stringsdict */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = "<group>"; };
D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+SearchHistory.swift"; sourceTree = "<group>"; };
D8AC98782B0F622B0045EC2B /* SearchHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchHistory.swift; sourceTree = "<group>"; };
D8B5E4ED2A4EB8920008970C /* NotificationSettingTableViewToggleCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationSettingTableViewToggleCell.swift; sourceTree = "<group>"; };
D8B5E4EF2A4EB8A00008970C /* NotificationSettingTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingTableViewCell.swift; sourceTree = "<group>"; };
D8B5E4F12A4EBCF90008970C /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
@ -1889,6 +1893,23 @@
path = Localization;
sourceTree = "<group>";
};
D8AC98742B0F615E0045EC2B /* Persistence */ = {
isa = PBXGroup;
children = (
D8AC98772B0F62230045EC2B /* Model */,
D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */,
);
path = Persistence;
sourceTree = "<group>";
};
D8AC98772B0F62230045EC2B /* Model */ = {
isa = PBXGroup;
children = (
D8AC98782B0F622B0045EC2B /* SearchHistory.swift */,
);
path = Model;
sourceTree = "<group>";
};
D8E5C347296DB896007E76A7 /* Edit History */ = {
isa = PBXGroup;
children = (
@ -2174,6 +2195,7 @@
DB427DD425BAA00100D1B89D /* Mastodon */ = {
isa = PBXGroup;
children = (
D8AC98742B0F615E0045EC2B /* Persistence */,
DB89BA1025C10FF5008580ED /* Mastodon.entitlements */,
DB427DE325BAA00100D1B89D /* Info.plist */,
2D76319C25C151DE00929FB9 /* Diffable */,
@ -3842,6 +3864,7 @@
2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */,
DB852D1C26FB021500FC9D81 /* RootSplitViewController.swift in Sources */,
DB697DD1278F4871004EF2F7 /* AutoGenerateTableViewDelegate.swift in Sources */,
D8AC98792B0F622B0045EC2B /* SearchHistory.swift in Sources */,
DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */,
DB3E6FFA2807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift in Sources */,
DB1FD44425F26CCC004CFCFC /* PickServerSection.swift in Sources */,
@ -3849,6 +3872,7 @@
DB6180E626391B550018D199 /* MediaPreviewTransitionController.swift in Sources */,
DB0FCB922796DE19006C02E2 /* TrendSectionHeaderCollectionReusableView.swift in Sources */,
DB2F073525E8ECF000957B2D /* AuthenticationViewModel.swift in Sources */,
D8AC98762B0F61680045EC2B /* FileManager+SearchHistory.swift in Sources */,
DB63F779279ABF9C00455B82 /* DataSourceFacade+Reblog.swift in Sources */,
DB4F0963269ED06300D62E92 /* SearchResultViewController.swift in Sources */,
DB603111279EB38500A935FE /* DataSourceFacade+Mute.swift in Sources */,

View File

@ -0,0 +1,54 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import MastodonCore
extension FileManager {
func searchItems(forUser userID: String) throws -> [Persistence.SearchHistory.Item] {
guard let path = documentsDirectory()?.appending(path: Persistence.searchHistory.filename).appendingPathExtension("json"),
let data = try? Data(contentsOf: path)
else { return [] }
let jsonDecoder = JSONDecoder()
jsonDecoder.dateDecodingStrategy = .iso8601
do {
let searchItems = try jsonDecoder.decode([Persistence.SearchHistory.Item].self, from: data)
.filter { $0.userID == userID }
.sorted { $0.updatedAt < $1.updatedAt }
return searchItems
} catch {
return []
}
}
func addSearchItem(_ newSearchItem: Persistence.SearchHistory.Item) throws {
guard let path = documentsDirectory()?.appending(path: Persistence.searchHistory.filename).appendingPathExtension("json") else { return }
var searchItems = (try? searchItems(forUser: newSearchItem.userID)) ?? []
searchItems.append(newSearchItem)
let jsonEncoder = JSONEncoder()
jsonEncoder.dateEncodingStrategy = .iso8601
do {
let data = try jsonEncoder.encode(searchItems)
try data.write(to: path)
} catch {
debugPrint(error.localizedDescription)
}
}
func removeSearchHistory() {
guard let path = documentsDirectory()?.appending(path: Persistence.searchHistory.filename).appendingPathExtension("json") else { return }
try? removeItem(at: path)
}
}
extension FileManager {
func documentsDirectory() -> URL? {
return self.urls(for: .documentDirectory, in: .userDomainMask).first
}
}

View File

@ -0,0 +1,15 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import Foundation
import MastodonCore
import MastodonSDK
extension Persistence.SearchHistory {
struct Item: Codable {
let updatedAt: Date
let userID: Mastodon.Entity.Account.ID
let account: Mastodon.Entity.Account?
let hashtag: Mastodon.Entity.Tag?
}
}

View File

@ -14,14 +14,9 @@ extension DataSourceFacade {
@MainActor
static func coordinateToHashtagScene(
provider: DataSourceProvider & AuthContextProvider,
tag: DataSourceItem.TagKind
tag: Mastodon.Entity.Tag
) async {
switch tag {
case .entity(let entity):
await coordinateToHashtagScene(provider: provider, tag: entity)
case .record(let record):
await coordinateToHashtagScene(provider: provider, tag: record)
}
await coordinateToHashtagScene(provider: provider, tag: tag)
}
@MainActor

View File

@ -11,107 +11,42 @@ import MastodonCore
import UIKit
extension DataSourceFacade {
static func responseToCreateSearchHistory(
provider: ViewControllerWithDependencies & AuthContextProvider,
item: DataSourceItem
) async {
switch item {
case .status, .account(_, _):
break // not create search history for status
case .user(let record):
let authenticationBox = provider.authContext.mastodonAuthenticationBox
let managedObjectContext = provider.context.backgroundManagedObjectContext
try? await managedObjectContext.performChanges {
guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return }
guard let user = record.object(in: managedObjectContext) else { return }
_ = Persistence.SearchHistory.createOrMerge(
in: managedObjectContext,
context: Persistence.SearchHistory.PersistContext(
entity: .user(user),
me: me,
now: Date()
)
case .account(account: let account, relationship: _):
let now = Date()
let userID = provider.authContext.mastodonAuthenticationBox.userID
let searchEntry = Persistence.SearchHistory.Item(
updatedAt: now,
userID: userID,
account: account,
hashtag: nil
)
} // end try? await managedObjectContext.performChanges { }
case .hashtag(let tag):
let authenticationBox = provider.authContext.mastodonAuthenticationBox
let managedObjectContext = provider.context.backgroundManagedObjectContext
switch tag {
case .entity(let entity):
try? await managedObjectContext.performChanges {
guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return }
let now = Date()
let result = Persistence.Tag.createOrMerge(
in: managedObjectContext,
context: Persistence.Tag.PersistContext(
domain: authenticationBox.domain,
entity: entity,
me: me,
networkDate: now
)
)
_ = Persistence.SearchHistory.createOrMerge(
in: managedObjectContext,
context: Persistence.SearchHistory.PersistContext(
entity: .hashtag(result.tag),
me: me,
now: now
)
)
} // end try? await managedObjectContext.performChanges { }
case .record(let record):
try? await managedObjectContext.performChanges {
let authenticationBox = provider.authContext.mastodonAuthenticationBox
guard let me = authenticationBox.authentication.user(in: managedObjectContext) else { return }
guard let tag = record.object(in: managedObjectContext) else { return }
let now = Date()
_ = Persistence.SearchHistory.createOrMerge(
in: managedObjectContext,
context: Persistence.SearchHistory.PersistContext(
entity: .hashtag(tag),
me: me,
now: now
)
)
} // end try? await managedObjectContext.performChanges { }
} // end switch tag { }
case .notification:
assertionFailure()
} // end switch item { }
} // end func
}
extension DataSourceFacade {
static func responseToDeleteSearchHistory(
provider: DataSourceProvider & AuthContextProvider
) async throws {
let authenticationBox = provider.authContext.mastodonAuthenticationBox
let managedObjectContext = provider.context.backgroundManagedObjectContext
try await managedObjectContext.performChanges {
guard let _ = authenticationBox.authentication.user(in: managedObjectContext) else { return }
let request = SearchHistory.sortedFetchRequest
request.predicate = SearchHistory.predicate(
domain: authenticationBox.domain,
userID: authenticationBox.userID
)
let searchHistories = managedObjectContext.safeFetch(request)
for searchHistory in searchHistories {
managedObjectContext.delete(searchHistory)
}
} // end try await managedObjectContext.performChanges { }
} // end func
try? FileManager.default.addSearchItem(searchEntry)
case .hashtag(let tag):
let now = Date()
let userID = provider.authContext.mastodonAuthenticationBox.userID
let searchEntry = Persistence.SearchHistory.Item(
updatedAt: now,
userID: userID,
account: nil,
hashtag: tag
)
try? FileManager.default.addSearchItem(searchEntry)
case .status:
break
case .user(_):
break
case .notification:
break
}
}
}

View File

@ -14,18 +14,11 @@ import class CoreDataStack.Notification
enum DataSourceItem: Hashable {
case status(record: ManagedObjectRecord<Status>)
case user(record: ManagedObjectRecord<MastodonUser>)
case hashtag(tag: TagKind)
case hashtag(tag: Mastodon.Entity.Tag)
case notification(record: ManagedObjectRecord<Notification>)
case account(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
}
extension DataSourceItem {
enum TagKind: Hashable {
case entity(Mastodon.Entity.Tag)
case record(ManagedObjectRecord<Tag>)
}
}
extension DataSourceItem {
struct Source {
let collectionViewCell: UICollectionViewCell?

View File

@ -5,6 +5,10 @@ import MastodonCore
import MastodonSDK
import MastodonLocalization
protocol SearchResultOverviewCoordinatorDelegate: AnyObject {
func newSearchHistoryItemAdded(_ coordinator: SearchResultOverviewCoordinator)
}
class SearchResultOverviewCoordinator: Coordinator {
let overviewViewController: SearchResultsOverviewTableViewController
@ -12,6 +16,8 @@ class SearchResultOverviewCoordinator: Coordinator {
let context: AppContext
let authContext: AuthContext
weak var delegate: SearchResultOverviewCoordinatorDelegate?
var activeTask: Task<Void, Never>?
init(appContext: AppContext, authContext: AuthContext, sceneCoordinator: SceneCoordinator) {
@ -37,13 +43,13 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl
func showPosts(_ viewController: SearchResultsOverviewTableViewController, tag: Mastodon.Entity.Tag) {
Task {
await DataSourceFacade.coordinateToHashtagScene(
provider: viewController,
tag: tag
)
await DataSourceFacade.coordinateToHashtagScene(provider: viewController,
tag: tag)
await DataSourceFacade.responseToCreateSearchHistory(provider: viewController,
item: .hashtag(tag: .entity(tag)))
item: .hashtag(tag: tag))
delegate?.newSearchHistoryItemAdded(self)
}
}
@ -111,27 +117,14 @@ extension SearchResultOverviewCoordinator: SearchResultsOverviewTableViewControl
}
func showProfile(_ viewController: SearchResultsOverviewTableViewController, for account: Mastodon.Entity.Account) {
let managedObjectContext = context.managedObjectContext
let domain = authContext.mastodonAuthenticationBox.domain
Task {
let user = try await managedObjectContext.perform {
return Persistence.MastodonUser.fetch(in: managedObjectContext,
context: Persistence.MastodonUser.PersistContext(
domain: domain,
entity: account,
cache: nil,
networkDate: Date()
))
}
await DataSourceFacade.coordinateToProfileScene(provider: viewController,
account: account)
if let user {
await DataSourceFacade.coordinateToProfileScene(provider: viewController,
user: user.asRecord)
await DataSourceFacade.responseToCreateSearchHistory(provider: viewController,
item: .account(account: account, relationship: nil))
await DataSourceFacade.responseToCreateSearchHistory(provider: viewController,
item: .user(record: user.asRecord))
}
delegate?.newSearchHistoryItemAdded(self)
}
}

View File

@ -73,13 +73,7 @@ final class SearchDetailViewController: UIViewController, NeedsDependency {
return searchBar
}()
private(set) lazy var searchHistoryViewController: SearchHistoryViewController = {
let searchHistoryViewController = SearchHistoryViewController()
searchHistoryViewController.context = context
searchHistoryViewController.coordinator = coordinator
searchHistoryViewController.viewModel = SearchHistoryViewModel(context: context, authContext: viewModel.authContext)
return searchHistoryViewController
}()
private var searchHistoryViewController: SearchHistoryViewController
private(set) lazy var searchResultsOverviewViewController: SearchResultsOverviewTableViewController = {
return searchResultOverviewCoordinator.overviewViewController
@ -92,8 +86,14 @@ final class SearchDetailViewController: UIViewController, NeedsDependency {
self.coordinator = sceneCoordinator
self.searchResultOverviewCoordinator = SearchResultOverviewCoordinator(appContext: appContext, authContext: authContext, sceneCoordinator: sceneCoordinator)
self.searchHistoryViewController = SearchHistoryViewController()
searchHistoryViewController.context = appContext
searchHistoryViewController.coordinator = sceneCoordinator
searchHistoryViewController.viewModel = SearchHistoryViewModel(context: appContext, authContext: authContext)
super.init(nibName: nil, bundle: nil)
searchResultOverviewCoordinator.delegate = searchHistoryViewController
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

View File

@ -6,10 +6,9 @@
//
import Foundation
import CoreData
import CoreDataStack
import MastodonSDK
enum SearchHistoryItem: Hashable {
case hashtag(ManagedObjectRecord<Tag>)
case user(ManagedObjectRecord<MastodonUser>)
case hashtag(Mastodon.Entity.Tag)
case account(Mastodon.Entity.Account)
}

View File

@ -9,6 +9,7 @@ import UIKit
import CoreDataStack
import MastodonCore
import MastodonAsset
import MastodonSDK
enum SearchHistorySection: Hashable {
case main
@ -28,47 +29,44 @@ extension SearchHistorySection {
configuration: Configuration
) -> UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem> {
let userCellRegister = UICollectionView.CellRegistration<SearchHistoryUserCollectionViewCell, ManagedObjectRecord<MastodonUser>> { cell, indexPath, item in
context.managedObjectContext.performAndWait {
guard let user = item.object(in: context.managedObjectContext) else { return }
cell.condensedUserView.configure(with: user)
}
let userCellRegister = UICollectionView.CellRegistration<SearchHistoryUserCollectionViewCell, Mastodon.Entity.Account> { cell, indexPath, account in
cell.condensedUserView.configure(with: account)
}
let hashtagCellRegister = UICollectionView.CellRegistration<UICollectionViewListCell, ManagedObjectRecord<Tag>> { cell, indexPath, item in
context.managedObjectContext.performAndWait {
guard let hashtag = item.object(in: context.managedObjectContext) else { return }
var contentConfiguration = cell.defaultContentConfiguration()
contentConfiguration.image = UIImage(systemName: "magnifyingglass")
contentConfiguration.imageProperties.tintColor = Asset.Colors.Brand.blurple.color
contentConfiguration.text = "#" + hashtag.name
cell.contentConfiguration = contentConfiguration
}
let hashtagCellRegister = UICollectionView.CellRegistration<UICollectionViewListCell, Mastodon.Entity.Tag> { cell, indexPath, hashtag in
var contentConfiguration = cell.defaultContentConfiguration()
contentConfiguration.image = UIImage(systemName: "magnifyingglass")
contentConfiguration.imageProperties.tintColor = Asset.Colors.Brand.blurple.color
contentConfiguration.text = "#" + hashtag.name
cell.contentConfiguration = contentConfiguration
var backgroundConfiguration = UIBackgroundConfiguration.listGroupedCell()
backgroundConfiguration.backgroundColorTransformer = .init { [weak cell] _ in
guard let state = cell?.configurationState else {
return .secondarySystemGroupedBackground
}
if state.isHighlighted || state.isSelected {
return SystemTheme.tableViewCellSelectionBackgroundColor
}
return .secondarySystemGroupedBackground
}
cell.backgroundConfiguration = backgroundConfiguration
}
let dataSource = UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>(collectionView: collectionView) { collectionView, indexPath, item in
switch item {
case .user(let record):
return collectionView.dequeueConfiguredReusableCell(
using: userCellRegister,
for: indexPath, item: record)
case .hashtag(let record):
return collectionView.dequeueConfiguredReusableCell(
using: hashtagCellRegister,
for: indexPath, item: record)
case .account(let account):
return collectionView.dequeueConfiguredReusableCell(
using: userCellRegister,
for: indexPath, item: account)
case .hashtag(let tag):
return collectionView.dequeueConfiguredReusableCell(
using: hashtagCellRegister,
for: indexPath, item: tag)
}
}

View File

@ -21,10 +21,10 @@ extension SearchHistoryViewController: DataSourceProvider {
}
switch item {
case .user(let record):
return .user(record: record)
case .hashtag(let record):
return .hashtag(tag: .record(record))
case .account(let account):
return .account(account: account, relationship: nil)
case .hashtag(let tag):
return .hashtag(tag: tag)
}
}

View File

@ -27,6 +27,7 @@ final class SearchHistoryViewController: UIViewController, NeedsDependency {
configuration.headerMode = .supplementary
let layout = UICollectionViewCompositionalLayout.list(using: configuration)
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.keyboardDismissMode = .onDrag
return collectionView
}()
}
@ -56,32 +57,30 @@ extension SearchHistoryViewController: UICollectionViewDelegate {
defer {
collectionView.deselectItem(at: indexPath, animated: true)
}
Task {
let source = DataSourceItem.Source(indexPath: indexPath)
guard let item = await item(from: source) else {
return
}
await DataSourceFacade.responseToCreateSearchHistory(
provider: self,
item: item
)
switch item {
case .user(let record):
await DataSourceFacade.coordinateToProfileScene(
provider: self,
user: record
)
case .hashtag(let record):
await DataSourceFacade.coordinateToHashtagScene(
provider: self,
tag: record
)
default:
assertionFailure()
break
case .account(account: let account, relationship: _):
await DataSourceFacade.coordinateToProfileScene(provider: self, account: account)
case .hashtag(let tag):
await DataSourceFacade.coordinateToHashtagScene(
provider: self,
tag: tag
)
default:
assertionFailure()
break
}
}
}
@ -99,14 +98,15 @@ extension SearchHistoryViewController: SearchHistorySectionHeaderCollectionReusa
_ searchHistorySectionHeaderCollectionReusableView: SearchHistorySectionHeaderCollectionReusableView,
clearButtonDidPressed button: UIButton
) {
Task {
try await DataSourceFacade.responseToDeleteSearchHistory(
provider: self
)
await MainActor.run {
button.isEnabled = false
}
}
FileManager.default.removeSearchHistory()
viewModel.items = []
}
}
//MARK: - SearchResultOverviewCoordinatorDelegate
extension SearchHistoryViewController: SearchResultOverviewCoordinatorDelegate {
func newSearchHistoryItemAdded(_ coordinator: SearchResultOverviewCoordinator) {
let userID = authContext.mastodonAuthenticationBox.userID
viewModel.items = (try? FileManager.default.searchItems(forUser: userID)) ?? []
}
}

View File

@ -26,42 +26,30 @@ extension SearchHistoryViewModel {
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
snapshot.appendSections([.main])
diffableDataSource?.apply(snapshot, animatingDifferences: false)
searchHistoryFetchedResultController.$records
$items
.receive(on: DispatchQueue.main)
.sink { [weak self] records in
.sink { [weak self] items in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
Task {
do {
let managedObjectContext = self.context.managedObjectContext
let items: [SearchHistoryItem] = try await managedObjectContext.perform {
var items: [SearchHistoryItem] = []
for record in records {
guard let searchHistory = record.object(in: managedObjectContext) else { continue }
if let user = searchHistory.account {
items.append(.user(.init(objectID: user.objectID)))
} else if let hashtag = searchHistory.hashtag {
items.append(.hashtag(.init(objectID: hashtag.objectID)))
}
}
return items
}
let mostRecentItems = Array(items.prefix(10))
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
snapshot.appendSections([.main])
snapshot.appendItems(mostRecentItems, toSection: .main)
await diffableDataSource.apply(snapshot, animatingDifferences: true)
} catch {
// do nothing
let searchItems: [SearchHistoryItem] = items.compactMap {
if let account = $0.account {
return .account(account)
} else if let tag = $0.hashtag {
return .hashtag(tag)
} else {
return nil
}
} // end Task
}
let mostRecentItems = Array(searchItems.prefix(10))
var snapshot = NSDiffableDataSourceSnapshot<SearchHistorySection, SearchHistoryItem>()
snapshot.appendSections([.main])
snapshot.appendItems(mostRecentItems, toSection: .main)
diffableDataSource.apply(snapshot, animatingDifferences: true)
}
.store(in: &disposeBag)
}
}

View File

@ -16,7 +16,7 @@ final class SearchHistoryViewModel {
// input
let context: AppContext
let authContext: AuthContext
let searchHistoryFetchedResultController: SearchHistoryFetchedResultController
@Published public var items: [Persistence.SearchHistory.Item]
// output
var diffableDataSource: UICollectionViewDiffableDataSource<SearchHistorySection, SearchHistoryItem>?
@ -24,10 +24,7 @@ final class SearchHistoryViewModel {
init(context: AppContext, authContext: AuthContext) {
self.context = context
self.authContext = authContext
self.searchHistoryFetchedResultController = SearchHistoryFetchedResultController(managedObjectContext: context.managedObjectContext)
searchHistoryFetchedResultController.domain.value = authContext.mastodonAuthenticationBox.domain
searchHistoryFetchedResultController.userID.value = authContext.mastodonAuthenticationBox.userID
self.items = (try? FileManager.default.searchItems(forUser: authContext.mastodonAuthenticationBox.userID)) ?? []
}
}

View File

@ -11,7 +11,7 @@ import CoreDataStack
import MastodonSDK
enum SearchResultItem: Hashable {
case user(ManagedObjectRecord<MastodonUser>)
case account(Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)
case status(ManagedObjectRecord<Status>)
case hashtag(tag: Mastodon.Entity.Tag)
case bottomLoader(attribute: BottomLoaderAttribute)

View File

@ -41,23 +41,21 @@ extension SearchResultSection {
return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in
switch item {
case .user(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as! UserTableViewCell
context.managedObjectContext.performAndWait {
guard let user = record.object(in: context.managedObjectContext) else { return }
configure(
context: context,
authContext: authContext,
case .account(let account, let relationship):
let cell = tableView.dequeueReusableCell(withIdentifier: UserTableViewCell.reuseIdentifier, for: indexPath) as! UserTableViewCell
guard let me = authContext.mastodonAuthenticationBox.authentication.user(in: context.managedObjectContext) else { return cell }
cell.userView.setButtonState(.loading)
cell.configure(
me: me,
tableView: tableView,
cell: cell,
viewModel: UserTableViewCell.ViewModel(user: user,
followedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followingUserIds.eraseToAnyPublisher(),
blockedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$blockedUserIds.eraseToAnyPublisher(),
followRequestedUsers: authContext.mastodonAuthenticationBox.inMemoryCache.$followRequestedUserIDs.eraseToAnyPublisher()),
configuration: configuration
account: account,
relationship: relationship,
delegate: configuration.userTableViewCellDelegate
)
}
return cell
return cell
case .status(let record):
let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: StatusTableViewCell.self), for: indexPath) as! StatusTableViewCell
context.managedObjectContext.performAndWait {

View File

@ -21,12 +21,12 @@ extension SearchResultViewController: DataSourceProvider {
}
switch item {
case .user(let record):
return .user(record: record)
case .account(let account, let relationship):
return .account(account: account, relationship: relationship)
case .status(let record):
return .status(record: record)
case .hashtag(let entity):
return .hashtag(tag: .entity(entity))
case .hashtag(let tag):
return .hashtag(tag: tag)
default:
return nil
}
@ -52,9 +52,8 @@ extension SearchResultViewController {
)
switch item {
case .account(account: _, relationship: _):
// do nothing
break
case .account(let account, relationship: _):
await DataSourceFacade.coordinateToProfileScene(provider: self, account: account)
case .status(let status):
await DataSourceFacade.coordinateToStatusThreadScene(
provider: self,

View File

@ -7,6 +7,7 @@
import UIKit
import Combine
import MastodonSDK
extension SearchResultViewModel {
@ -33,13 +34,19 @@ extension SearchResultViewModel {
Publishers.CombineLatest3(
statusFetchedResultsController.$records,
userFetchedResultsController.$records,
$accounts,
$hashtags
)
.map { statusRecords, userRecords, hashtags in
.map { statusRecords, accounts, hashtags in
var items: [SearchResultItem] = []
let userItems = userRecords.map { SearchResultItem.user($0) }
let accountsWithRelationship: [(account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?)] = accounts.compactMap { account in
guard let relationship = self.relationships.first(where: {$0.id == account.id }) else { return (account: account, relationship: nil)}
return (account: account, relationship: relationship)
}
let userItems = accountsWithRelationship.map { SearchResultItem.account($0.account, relationship: $0.relationship) }
items.append(contentsOf: userItems)
let hashtagItems = hashtags.map { SearchResultItem.hashtag(tag: $0) }

View File

@ -113,20 +113,26 @@ extension SearchResultViewModel.State {
Task {
do {
let response = try await viewModel.context.apiService.search(
let searchResults = try await viewModel.context.apiService.search(
query: query,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
)
).value
// discard result when request not the latest one
guard id == self.latestLoadingToken else { return }
// discard result when state is not Loading
guard stateMachine.currentState is Loading else { return }
let userIDs = response.value.accounts.map { $0.id }
let statusIDs = response.value.statuses.map { $0.id }
let accounts = searchResults.accounts
let isNoMore = userIDs.isEmpty && statusIDs.isEmpty
let relationships = try await viewModel.context.apiService.relationship(
forAccounts: accounts,
authenticationBox: viewModel.authContext.mastodonAuthenticationBox
).value
let statusIDs = searchResults.statuses.map { $0.id }
let isNoMore = accounts.isEmpty && statusIDs.isEmpty
if viewModel.searchScope == .all || isNoMore {
await enter(state: NoMore.self)
@ -136,20 +142,34 @@ extension SearchResultViewModel.State {
// reset data source when the search is refresh
if offset == nil {
viewModel.userFetchedResultsController.userIDs = []
viewModel.relationships = []
viewModel.accounts = []
viewModel.statusFetchedResultsController.statusIDs = []
viewModel.hashtags = []
}
viewModel.userFetchedResultsController.append(userIDs: userIDs)
// due to combine relationships must be set first
var existingRelationships = viewModel.relationships
for hashtag in relationships where !existingRelationships.contains(hashtag) {
existingRelationships.append(hashtag)
}
viewModel.relationships = existingRelationships
viewModel.statusFetchedResultsController.append(statusIDs: statusIDs)
var hashtags = viewModel.hashtags
for hashtag in response.value.hashtags where !hashtags.contains(hashtag) {
hashtags.append(hashtag)
var existingHashtags = viewModel.hashtags
for hashtag in searchResults.hashtags where !existingHashtags.contains(hashtag) {
existingHashtags.append(hashtag)
}
viewModel.hashtags = hashtags
viewModel.hashtags = existingHashtags
var existingAccounts = viewModel.accounts
for hashtag in searchResults.accounts where !existingAccounts.contains(hashtag) {
existingAccounts.append(hashtag)
}
viewModel.accounts = existingAccounts
} catch {
await enter(state: Fail.self)
}

View File

@ -22,7 +22,8 @@ final class SearchResultViewModel {
let searchScope: SearchScope
let searchText: String
@Published var hashtags: [Mastodon.Entity.Tag] = []
let userFetchedResultsController: UserFetchedResultsController
@Published var accounts: [Mastodon.Entity.Account] = []
var relationships: [Mastodon.Entity.Relationship] = []
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
@ -50,12 +51,9 @@ final class SearchResultViewModel {
self.authContext = authContext
self.searchScope = searchScope
self.searchText = searchText
self.accounts = []
self.relationships = []
self.userFetchedResultsController = UserFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,
additionalPredicate: nil
)
self.statusFetchedResultsController = StatusFetchedResultsController(
managedObjectContext: context.managedObjectContext,
domain: authContext.mastodonAuthenticationBox.domain,

View File

@ -8,7 +8,16 @@
import Foundation
public enum Persistence { }
public enum Persistence {
case searchHistory
public var filename: String {
switch self {
case .searchHistory:
return "search_history"
}
}
}
extension Persistence {

View File

@ -124,45 +124,6 @@ public class CondensedUserView: UIView {
avatarImageView.prepareForReuse()
}
public func configure(with user: MastodonUser) {
let displayNameMetaContent: MetaContent
do {
let content = MastodonContent(content: user.displayNameWithFallback, emojis: user.emojis.asDictionary)
displayNameMetaContent = try MastodonMetaContent.convert(document: content)
} catch {
displayNameMetaContent = PlaintextMetaContent(string: user.displayNameWithFallback)
}
displayNameLabel.configure(content: displayNameMetaContent)
acctLabel.text = user.acct
followersLabel.attributedText = NSAttributedString(
format: NSAttributedString(string: L10n.Common.UserList.followersCount("%@"), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .regular))]),
args: NSAttributedString(string: Self.metricFormatter.string(from: Int(user.followersCount)) ?? user.followersCount.formatted(), attributes: [.font: UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .bold))])
)
avatarImageView.setImage(url: user.avatarImageURL())
if let verifiedLink = user.verifiedLink?.value {
verifiedLinkImageView.image = UIImage(systemName: "checkmark")
verifiedLinkImageView.tintColor = Asset.Colors.Brand.blurple.color
let verifiedLinkMetaContent: MetaContent
do {
let mastodonContent = MastodonContent(content: verifiedLink, emojis: [:])
verifiedLinkMetaContent = try MastodonMetaContent.convert(document: mastodonContent)
} catch {
verifiedLinkMetaContent = PlaintextMetaContent(string: verifiedLink)
}
verifiedLinkLabel.configure(content: verifiedLinkMetaContent)
} else {
verifiedLinkImageView.image = UIImage(systemName: "questionmark.circle")
verifiedLinkImageView.tintColor = .secondaryLabel
verifiedLinkLabel.configure(content: PlaintextMetaContent(string: L10n.Common.UserList.noVerifiedLink))
}
}
public func configure(with account: Mastodon.Entity.Account, showFollowers: Bool = true) {
let displayNameMetaContent: MetaContent
do {