feat: make diffable data source work with search text
This commit is contained in:
parent
29653ca612
commit
8568debab0
|
@ -445,7 +445,7 @@ extension MastodonPickServerViewController {
|
||||||
// MARK: - PickServerSearchCellDelegate
|
// MARK: - PickServerSearchCellDelegate
|
||||||
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
|
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
|
||||||
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
|
func pickServerSearchCell(_ cell: PickServerSearchCell, searchTextDidChange searchText: String?) {
|
||||||
viewModel.searchText.send(searchText)
|
viewModel.searchText.send(searchText ?? "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -36,10 +36,10 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) })
|
items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) })
|
||||||
return items
|
return items
|
||||||
}()
|
}()
|
||||||
let selectCategoryIndex = CurrentValueSubject<Int, Never>(0)
|
let selectCategoryItem = CurrentValueSubject<CategoryPickerItem, Never>(.all)
|
||||||
let searchText = CurrentValueSubject<String?, Never>(nil)
|
let searchText = CurrentValueSubject<String, Never>("")
|
||||||
let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], 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>()
|
let viewWillAppear = PassthroughSubject<Void, Never>()
|
||||||
|
|
||||||
// output
|
// output
|
||||||
|
@ -55,6 +55,7 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
stateMachine.enter(LoadIndexedServerState.Initial.self)
|
stateMachine.enter(LoadIndexedServerState.Initial.self)
|
||||||
return stateMachine
|
return stateMachine
|
||||||
}()
|
}()
|
||||||
|
let filteredIndexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([])
|
||||||
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
let servers = CurrentValueSubject<[Mastodon.Entity.Server], Error>([])
|
||||||
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
|
let selectedServer = CurrentValueSubject<Mastodon.Entity.Server?, Never>(nil)
|
||||||
let error = PassthroughSubject<Error, Never>()
|
let error = PassthroughSubject<Error, Never>()
|
||||||
|
@ -75,13 +76,12 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configure() {
|
private func configure() {
|
||||||
Publishers.CombineLatest3(
|
Publishers.CombineLatest(
|
||||||
indexedServers,
|
filteredIndexedServers.eraseToAnyPublisher(),
|
||||||
unindexedServers,
|
unindexedServers.eraseToAnyPublisher()
|
||||||
searchText
|
|
||||||
)
|
)
|
||||||
.receive(on: DispatchQueue.main)
|
.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 self = self else { return }
|
||||||
guard let diffableDataSource = self.diffableDataSource 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)
|
let attribute = oldSnapshotServerItemAttributeDict[server.domain] ?? PickServerItem.ServerItemAttribute(isLast: false, isExpand: false)
|
||||||
attribute.isLast = false
|
attribute.isLast = false
|
||||||
let item = PickServerItem.server(server: server, attribute: attribute)
|
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)
|
serverItems.append(item)
|
||||||
}
|
}
|
||||||
if case let .server(_, attribute) = serverItems.last {
|
if case let .server(_, attribute) = serverItems.last {
|
||||||
|
@ -110,7 +118,8 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
}
|
}
|
||||||
snapshot.appendItems(serverItems, toSection: .servers)
|
snapshot.appendItems(serverItems, toSection: .servers)
|
||||||
|
|
||||||
diffableDataSource.apply(snapshot)
|
diffableDataSource.defaultRowAnimation = .fade
|
||||||
|
diffableDataSource.apply(snapshot, animatingDifferences: true, completion: nil)
|
||||||
})
|
})
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
|
@ -125,80 +134,77 @@ class MastodonPickServerViewModel: NSObject {
|
||||||
.assign(to: \.value, on: emptyStateViewState)
|
.assign(to: \.value, on: emptyStateViewState)
|
||||||
.store(in: &disposeBag)
|
.store(in: &disposeBag)
|
||||||
|
|
||||||
// Publishers.CombineLatest3(
|
Publishers.CombineLatest3(
|
||||||
// selectCategoryIndex,
|
indexedServers.eraseToAnyPublisher(),
|
||||||
// searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates(),
|
selectCategoryItem.eraseToAnyPublisher(),
|
||||||
// indexedServers
|
searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main).removeDuplicates()
|
||||||
// )
|
)
|
||||||
// .flatMap { [weak self] (selectCategoryIndex, searchText, allServers) -> AnyPublisher<Result<[Mastodon.Entity.Server], Error>, Never> in
|
.map { indexedServers, selectCategoryItem, searchText -> [Mastodon.Entity.Server] in
|
||||||
// guard let self = self else { return Just(Result.success([])).eraseToAnyPublisher() }
|
// Filter the indexed servers from joinmastodon.org
|
||||||
//
|
switch selectCategoryItem {
|
||||||
// // 1. Search from the servers recorded in joinmastodon.org
|
case .all:
|
||||||
// let searchedServersFromAPI = self.searchServersFromAPI(category: self.categories[selectCategoryIndex], searchText: searchText, allServers: allServers)
|
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: nil, searchText: searchText)
|
||||||
// if !searchedServersFromAPI.isEmpty {
|
case .category(let category):
|
||||||
// // If found servers, just return
|
return MastodonPickServerViewModel.filterServers(servers: indexedServers, category: category.category.rawValue, searchText: searchText)
|
||||||
// return Just(Result.success(searchedServersFromAPI)).eraseToAnyPublisher()
|
}
|
||||||
// }
|
}
|
||||||
// // 2. No server found in the recorded list, check if searchText is a valid mastodon server domain
|
.assign(to: \.value, on: filteredIndexedServers)
|
||||||
// if let toSearchText = searchText, !toSearchText.isEmpty, let _ = URL(string: "https://\(toSearchText)") {
|
.store(in: &disposeBag)
|
||||||
// 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)
|
|
||||||
|
|
||||||
|
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() {
|
extension MastodonPickServerViewModel {
|
||||||
// context.apiService.servers(language: nil, category: nil)
|
private static func filterServers(servers: [Mastodon.Entity.Server], category: String?, searchText: String) -> [Mastodon.Entity.Server] {
|
||||||
// .sink { completion in
|
return servers
|
||||||
// // TODO: Add a reload button when fails to fetch servers initially
|
// 1. Filter the category
|
||||||
// } receiveValue: { [weak self] result in
|
.filter {
|
||||||
// self?.indexedServers.send(result.value)
|
guard let category = category else { return true }
|
||||||
// }
|
return $0.category.caseInsensitiveCompare(category) == .orderedSame
|
||||||
// .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
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
// 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
|
// MARK: - SignIn methods & structs
|
||||||
extension MastodonPickServerViewModel {
|
extension MastodonPickServerViewModel {
|
||||||
|
|
|
@ -42,21 +42,7 @@ final class AuthenticationViewModel {
|
||||||
|
|
||||||
input
|
input
|
||||||
.map { input in
|
.map { input in
|
||||||
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
AuthenticationViewModel.parseDomain(from: input)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
.assign(to: \.value, on: domain)
|
.assign(to: \.value, on: domain)
|
||||||
.store(in: &disposeBag)
|
.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 {
|
extension AuthenticationViewModel {
|
||||||
enum AuthenticationError: Error, LocalizedError {
|
enum AuthenticationError: Error, LocalizedError {
|
||||||
case badCredentials
|
case badCredentials
|
||||||
|
|
|
@ -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 {
|
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 struct RateLimit {
|
||||||
|
|
||||||
public let limit: Int
|
public let limit: Int
|
||||||
|
|
Loading…
Reference in New Issue