diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 6af592f85..e894cda34 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -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 ?? "") } } diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index e86d1c590..e136e86ce 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -36,10 +36,10 @@ class MastodonPickServerViewModel: NSObject { items.append(contentsOf: APIService.stubCategories().map { CategoryPickerItem.category(category: $0) }) return items }() - let selectCategoryIndex = CurrentValueSubject(0) - let searchText = CurrentValueSubject(nil) + let selectCategoryItem = CurrentValueSubject(.all) + let searchText = CurrentValueSubject("") let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Instance], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) let viewWillAppear = PassthroughSubject() // 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(nil) let error = PassthroughSubject() @@ -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, 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> 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, 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, 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 { diff --git a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift index cb197dc0a..0bd1bf09b 100644 --- a/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift +++ b/Mastodon/Scene/Onboarding/Share/AuthenticationViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index f99614311..a74d0fcaa 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -39,10 +39,23 @@ extension Mastodon.Response { }() } + init(value: T, old: Mastodon.Response.Content) { + self.value = value + self.date = old.date + self.rateLimit = old.rateLimit + self.responseTime = old.responseTime + } + } } extension Mastodon.Response.Content { + public func map(_ transform: (T) -> R) -> Mastodon.Response.Content { + return Mastodon.Response.Content(value: transform(value), old: self) + } +} + +extension Mastodon.Response { public struct RateLimit { public let limit: Int