forked from zelo72/mastodon-ios
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
|
||||
extension MastodonPickServerViewController: PickServerSearchCellDelegate {
|
||||
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) })
|
||||
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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue