feat: make diffable data source work with search text

This commit is contained in:
CMK 2021-03-06 14:21:52 +08:00
parent 29653ca612
commit 8568debab0
4 changed files with 121 additions and 96 deletions

View File

@ -445,7 +445,7 @@ extension MastodonPickServerViewController {
// MARK: - PickServerSearchCellDelegate
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
viewModel.searchText.send(searchText)
viewModel.searchText.send(searchText ?? "")
}
}

View File

@ -36,10 +36,10 @@ class MastodonPickServerViewModel: NSObject {
items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) })
return items
}()
let selectCategoryIndex = CurrentValueSubject<Int, Never>(0)
let searchText = CurrentValueSubject<String?, Never>(nil)
let selectCategoryItem = CurrentValueSubject<CategoryPickerItem, Never>(.all)
let searchText = CurrentValueSubject<String, Never>("")
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([])
let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let viewWillAppear = PassthroughSubject<Void, Never>()
// output
@ -55,6 +55,7 @@ class MastodonPickServerViewModel: NSObject {
stateMachine.enter(LoadIndexedServerState.Initial.self)
return stateMachine
}()
let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
let error = PassthroughSubject<Error, Never>()
@ -75,13 +76,12 @@ class MastodonPickServerViewModel: NSObject {
}
private func configure() {
Publishers.CombineLatest3(
indexedServers,
unindexedServers,
searchText
Publishers.CombineLatest(
filteredIndexedServers.eraseToAnyPublisher(),
unindexedServers.eraseToAnyPublisher()
)
.receive(on: DispatchQueue.main)
.sink(receiveValue: { [weak self] indexedServers, unindexedServers, searchText in
.sink(receiveValue: { [weak self] indexedServers, unindexedServers in
guard let self = self else { return }
guard let diffableDataSource = self.diffableDataSource else { return }
@ -103,6 +103,14 @@ class MastodonPickServerViewModel: NSObject {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
for server in unindexedServers {
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
attribute.isLast = false
let item = PickServerItem.server(server: server, attribute: attribute)
guard !serverItems.contains(item) else { continue }
serverItems.append(item)
}
if case let .server(_, attribute) = serverItems.last {
@ -110,7 +118,8 @@ class MastodonPickServerViewModel: NSObject {
}
snapshot.appendItems(serverItems, toSection: .servers)
diffableDataSource.apply(snapshot)
diffableDataSource.defaultRowAnimation = .fade
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
})
.store(in: &disposeBag)
@ -125,80 +134,77 @@ class MastodonPickServerViewModel: NSObject {
.assign(to: \.value, on: emptyStateViewState)
.store(in: &disposeBag)
// Publishers.CombineLatest3(
// selectCategoryIndex,
// searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
// indexedServers
// )
// .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher<Result<[Mastodon.Entity.Server], Error>, Never> in
// guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() }
//
// // 1. Search from the servers recorded in joinmastodon.org
// let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers)
// if !searchedServersFromAPI.isEmpty {
// // If found servers, just return
// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
// }
// // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain
// if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") {
// return self.context.apiService.instance(domain: toSearchText)
// .map { return Result.success([Mastodon.Entity.Server(instance: $0.value)]) }
// .catch({ error -> Just<Result<[Mastodon.Entity.Server], Error>> in
// return Just(Result.failure(error))
// })
// .eraseToAnyPublisher()
// }
// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
// }
// .sink { _ in
//
// } receiveValue: { [weak self] result in
// switch result {
// case .success(let servers):
// self?.servers.send(servers)
// case .failure(let error):
// // TODO: What should be presented when user inputs invalid search text?
// self?.servers.send([])
// }
//
// }
// .store(in: &disposeBag)
Publishers.CombineLatest3(
indexedServers.eraseToAnyPublisher(),
selectCategoryItem.eraseToAnyPublisher(),
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates()
)
.map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in
// Filter the indexed servers from joinmastodon.org
switch selectCategoryItem {
case .all:
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText)
case .category(let category):
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText)
}
}
.assign(to: \.value, on: filteredIndexedServers)
.store(in: &disposeBag)
searchText
.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)
.removeDuplicates()
.compactMap { [weak self] searchText -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>, Never>? in
// Check if searchText is a valid mastodon server domain
guard let self = self else { return nil }
guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else {
return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher()
}
return self.context.apiService.instance(domain: domain)
.map { response -> Result<Mastodon.Response.Content<[Mastodon.Entity.Server]>, Error>in
let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] }
return Result.success(newResponse)
}
.catch { error in
return Just(Result.failure(error))
}
.eraseToAnyPublisher()
}
.switchToLatest()
.sink(receiveValue: { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let response):
self.unindexedServers.send(response.value)
case .failure(let error):
// TODO: What should be presented when user inputs invalid search text?
self.unindexedServers.send([])
}
})
.store(in: &disposeBag)
}
// func fetchAllServers() {
// context.apiService.servers(language: nil, category: nil)
// .sink { completion in
// // TODO: Add a reload button when fails to fetch servers initially
// } receiveValue: { [weak self] result in
// self?.indexedServers.send(result.value)
// }
// .store(in: &disposeBag)
//
// }
//
// private func searchServersFromAPI(category: Category, searchText: String?, allServers: [Mastodon.Entity.Server]) -> [Mastodon.Entity.Server] {
// return allServers
// // 1. Filter the category
// .filter {
// switch category {
// case .all:
// return true
// case .some(let masCategory):
// return $0.category.caseInsensitiveCompare(masCategory.category.rawValue) == .orderedSame
// }
// }
// // 2. Filter the searchText
// .filter {
// if let searchText = searchText, !searchText.isEmpty {
// return $0.domain.lowercased().contains(searchText.lowercased())
// } else {
// return true
// }
// }
// }
}
extension MastodonPickServerViewModel {
private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] {
return servers
// 1. Filter the category
.filter {
guard let category = category else { return true }
return $0.category.caseInsensitiveCompare(category) == .orderedSame
}
// 2. Filter the searchText
.filter {
let searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !searchText.isEmpty else {
return true
}
return $0.domain.lowercased().contains(searchText.lowercased())
}
}
}
// MARK: - SignIn methods & structs
extension MastodonPickServerViewModel {

View File

@ -42,21 +42,7 @@ final class AuthenticationViewModel {
input
.map { input in
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
guard let url = URL(string: urlString),
let host = url.host else {
return nil
}
let components = host.components(separatedBy: ".")
guard !components.contains(where: { $0.isEmpty }) else { return nil }
guard components.count >= 2 else { return nil }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: iput host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
return host
AuthenticationViewModel.parseDomain(from: input)
}
.assign(to: \.value, on: domain)
.store(in: &disposeBag)
@ -77,6 +63,26 @@ final class AuthenticationViewModel {
}
extension AuthenticationViewModel {
static func parseDomain(from input: String) -> String? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !trimmed.isEmpty else { return nil }
let urlString = trimmed.hasPrefix("https://") ? trimmed : "https://" + trimmed
guard let url = URL(string: urlString),
let host = url.host else {
return nil
}
let components = host.components(separatedBy: ".")
guard !components.contains(where: { $0.isEmpty }) else { return nil }
guard components.count >= 2 else { return nil }
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: input host: %s", ((#file as NSString).lastPathComponent), #line, #function, host)
return host
}
}
extension AuthenticationViewModel {
enum AuthenticationError: Error, LocalizedError {
case badCredentials

View File

@ -39,10 +39,23 @@ extension Mastodon.Response {
}()
}
init<O>(value: T, old: Mastodon.Response.Content<O>) {
self.value = value
self.date = old.date
self.rateLimit = old.rateLimit
self.responseTime = old.responseTime
}
}
}
extension Mastodon.Response.Content {
public func map<R>(_ transform: (T) -> R) -> Mastodon.Response.Content<R> {
return Mastodon.Response.Content(value: transform(value), old: self)
}
}
extension Mastodon.Response {
public struct RateLimit {
public let limit: Int