diff --git a/Localization/app.json b/Localization/app.json index ad35fed5f..21304d564 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -199,7 +199,8 @@ }, "empty_state": { "finding_servers": "Finding available servers...", - "bad_network": "Something went wrong while loading data. Check your internet connection." + "bad_network": "Something went wrong while loading data. Check your internet connection.", + "no_results": "No results" } }, "register": { diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index b274f5c7c..0eb170fc5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ DB02CDBF2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB02CDBE2625AE5000D0A2AF /* AdaptiveUserInterfaceStyleBarButtonItem.swift */; }; DB084B5725CBC56C00F898ED /* Status.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB084B5625CBC56C00F898ED /* Status.swift */; }; DB0AC6FC25CD02E600D75117 /* APIService+Instance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */; }; + DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; DB118A8225E4B6E600FAB162 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */; }; DB118A8C25E4BFB500FAB162 /* HighlightDimmableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */; }; DB1D186C25EF5BA7003F1F23 /* PollTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */; }; @@ -758,6 +759,7 @@ DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Instance.swift"; sourceTree = ""; }; DB0F814D264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/Localizable.strings; sourceTree = ""; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; + DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; DB118A8B25E4BFB500FAB162 /* HighlightDimmableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HighlightDimmableButton.swift; sourceTree = ""; }; DB1D186B25EF5BA7003F1F23 /* PollTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollTableView.swift; sourceTree = ""; }; @@ -1136,6 +1138,7 @@ 0FB3D30725E524C600AAD544 /* PickServerCategoriesCell.swift */, 0FB3D33125E5F50E00AAD544 /* PickServerSearchCell.swift */, 0FB3D33725E6401400AAD544 /* PickServerCell.swift */, + DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */, ); path = TableViewCell; sourceTree = ""; @@ -1390,6 +1393,7 @@ 2D7631A425C1532200929FB9 /* Share */ = { isa = PBXGroup; children = ( + 5D03938E2612D200007FE196 /* Webview */, DB68A04F25E9028800CFDF14 /* NavigationController */, DB9D6C2025E502C60051B173 /* ViewModel */, 2D7631A525C1532D00929FB9 /* View */, @@ -2047,7 +2051,6 @@ DB8AF55525C1379F002E6C99 /* Scene */ = { isa = PBXGroup; children = ( - 5D03938E2612D200007FE196 /* Webview */, 2D7631A425C1532200929FB9 /* Share */, DB6180E426391A500018D199 /* Transition */, DB8AF54E25C13703002E6C99 /* MainTab */, @@ -2983,6 +2986,7 @@ DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, DBE3CE0D261D767100430CC6 /* FavoriteViewController+Provider.swift in Sources */, 2D084B9326259545003AA3AF /* NotificationViewModel+LoadLatestState.swift in Sources */, + DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, DB98337F25C9452D00AD9700 /* APIService+APIError.swift in Sources */, DB9E0D6F25EE008500CFDD76 /* UIInterpolatingMotionEffect.swift in Sources */, DBB9759C262462E1004620BD /* ThreadMetaView.swift in Sources */, diff --git a/Mastodon/Diffiable/Item/PickServerItem.swift b/Mastodon/Diffiable/Item/PickServerItem.swift index 13acefeae..1ae38ba1c 100644 --- a/Mastodon/Diffiable/Item/PickServerItem.swift +++ b/Mastodon/Diffiable/Item/PickServerItem.swift @@ -14,6 +14,7 @@ enum PickServerItem { case categoryPicker(items: [CategoryPickerItem]) case search case server(server: Mastodon.Entity.Server, attribute: ServerItemAttribute) + case loader(attribute: LoaderItemAttribute) } extension PickServerItem { @@ -34,6 +35,26 @@ extension PickServerItem { hasher.combine(isExpand) } } + + final class LoaderItemAttribute: Equatable, Hashable { + let id = UUID() + + var isLast: Bool + var isNoResult: Bool + + init(isLast: Bool, isEmptyResult: Bool) { + self.isLast = isLast + self.isNoResult = isEmptyResult + } + + static func == (lhs: PickServerItem.LoaderItemAttribute, rhs: PickServerItem.LoaderItemAttribute) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } } extension PickServerItem: Equatable { @@ -47,6 +68,8 @@ extension PickServerItem: Equatable { return true case (.server(let serverLeft, _), .server(let serverRight, _)): return serverLeft.domain == serverRight.domain + case (.loader(let attributeLeft), loader(let attributeRight)): + return attributeLeft == attributeRight default: return false } @@ -64,6 +87,8 @@ extension PickServerItem: Hashable { hasher.combine(String(describing: PickServerItem.search.self)) case .server(let server, _): hasher.combine(server.domain) + case .loader(let attribute): + hasher.combine(attribute) } } } diff --git a/Mastodon/Diffiable/Section/PickServerSection.swift b/Mastodon/Diffiable/Section/PickServerSection.swift index f5b1ee500..aaafb8ce7 100644 --- a/Mastodon/Diffiable/Section/PickServerSection.swift +++ b/Mastodon/Diffiable/Section/PickServerSection.swift @@ -57,6 +57,10 @@ extension PickServerSection { PickServerSection.configure(cell: cell, server: server, attribute: attribute) cell.delegate = pickServerCellDelegate return cell + case .loader(let attribute): + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: PickServerLoaderTableViewCell.self), for: indexPath) as! PickServerLoaderTableViewCell + PickServerSection.configure(cell: cell, attribute: attribute) + return cell } } } @@ -137,3 +141,23 @@ extension PickServerSection { } } + +extension PickServerSection { + + static func configure(cell: PickServerLoaderTableViewCell, attribute: PickServerItem.LoaderItemAttribute) { + if attribute.isLast { + cell.containerView.layer.maskedCorners = [ + .layerMinXMaxYCorner, + .layerMaxXMaxYCorner + ] + cell.containerView.layer.cornerCurve = .continuous + cell.containerView.layer.cornerRadius = MastodonPickServerAppearance.tableViewCornerRadius + } else { + cell.containerView.layer.cornerRadius = 0 + } + + attribute.isNoResult ? cell.stopAnimating() : cell.startAnimating() + cell.emptyStatusLabel.isHidden = !attribute.isNoResult + } + +} diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index d179be707..8f6c13f9e 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -773,6 +773,8 @@ internal enum L10n { internal static let badNetwork = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.BadNetwork") /// Finding available servers... internal static let findingServers = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.FindingServers") + /// No results + internal static let noResults = L10n.tr("Localizable", "Scene.ServerPicker.EmptyState.NoResults") } internal enum Input { /// Find a server or join your own... diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 7b9e0e43c..c9ed556c3 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -257,6 +257,7 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.EmptyState.NoResults" = "No results"; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 7b9e0e43c..c9ed556c3 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -257,6 +257,7 @@ tap the link to confirm your account."; "Scene.ServerPicker.Button.SeeMore" = "See More"; "Scene.ServerPicker.EmptyState.BadNetwork" = "Something went wrong while loading data. Check your internet connection."; "Scene.ServerPicker.EmptyState.FindingServers" = "Finding available servers..."; +"Scene.ServerPicker.EmptyState.NoResults" = "No results"; "Scene.ServerPicker.Input.Placeholder" = "Find a server or join your own..."; "Scene.ServerPicker.Label.Category" = "CATEGORY"; "Scene.ServerPicker.Label.Language" = "LANGUAGE"; diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift index 638734c11..71e74d56b 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewController.swift @@ -31,6 +31,7 @@ final class MastodonPickServerViewController: UIViewController, NeedsDependency tableView.register(PickServerCategoriesCell.self, forCellReuseIdentifier: String(describing: PickServerCategoriesCell.self)) tableView.register(PickServerSearchCell.self, forCellReuseIdentifier: String(describing: PickServerSearchCell.self)) tableView.register(PickServerCell.self, forCellReuseIdentifier: String(describing: PickServerCell.self)) + tableView.register(PickServerLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: PickServerLoaderTableViewCell.self)) tableView.rowHeight = UITableView.automaticDimension tableView.separatorStyle = .none tableView.backgroundColor = .clear diff --git a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift index ed804afd9..0edc0a350 100644 --- a/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift +++ b/Mastodon/Scene/Onboarding/PickServer/MastodonPickServerViewModel.swift @@ -39,7 +39,7 @@ class MastodonPickServerViewModel: NSObject { let selectCategoryItem = CurrentValueSubject(.all) let searchText = CurrentValueSubject("") let indexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) - let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server], Never>([]) + let unindexedServers = CurrentValueSubject<[Mastodon.Entity.Server]?, Never>([]) // set nil when loading let viewWillAppear = PassthroughSubject() // output @@ -85,8 +85,8 @@ extension MastodonPickServerViewModel { private func configure() { Publishers.CombineLatest( - filteredIndexedServers.eraseToAnyPublisher(), - unindexedServers.eraseToAnyPublisher() + filteredIndexedServers, + unindexedServers ) .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] indexedServers, unindexedServers in @@ -114,16 +114,31 @@ extension MastodonPickServerViewModel { 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 let unindexedServers = unindexedServers { + if !unindexedServers.isEmpty { + 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) + } + } else { + if indexedServers.isEmpty && !self.isLoadingIndexedServers.value { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: true))) + } + } + } else { + serverItems.append(.loader(attribute: PickServerItem.LoaderItemAttribute(isLast: false, isEmptyResult: false))) } + if case let .server(_, attribute) = serverItems.last { attribute.isLast = true } + if case let .loader(attribute) = serverItems.last { + attribute.isLast = true + } snapshot.appendItems(serverItems, toSection: .servers) diffableDataSource.defaultRowAnimation = .fade @@ -168,6 +183,7 @@ extension MastodonPickServerViewModel { guard let domain = AuthenticationViewModel.parseDomain(from: searchText) else { return Just(Result.failure(APIService.APIError.implicit(.badRequest))).eraseToAnyPublisher() } + self.unindexedServers.value = nil return self.context.apiService.instance(domain: domain) .map { response -> Result, Error>in let newResponse = response.map { [Mastodon.Entity.Server(instance: $0)] } @@ -184,9 +200,14 @@ extension MastodonPickServerViewModel { switch result { case .success(let response): self.unindexedServers.send(response.value) - case .failure: - // TODO: What should be presented when user inputs invalid search text? - self.unindexedServers.send([]) + case .failure(let error): + if let error = error as? APIService.APIError, + case let .implicit(reason) = error, + case .badRequest = reason { + self.unindexedServers.send([]) + } else { + self.unindexedServers.send(nil) + } } }) .store(in: &disposeBag) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift index a93dcfebf..8eb0cb771 100644 --- a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerCell.swift @@ -88,11 +88,14 @@ class PickServerCell: UITableViewCell { let expandButton: UIButton = { let button = UIButton(type: .custom) + button.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) button.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) - button.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .selected) button.setTitleColor(Asset.Colors.brandBlue.color, for: .normal) - button.titleLabel?.font = .preferredFont(forTextStyle: .footnote) + button.titleLabel?.font = .systemFont(ofSize: 13, weight: .regular) button.translatesAutoresizingMaskIntoConstraints = false + button.imageView?.transform = CGAffineTransform(scaleX: -1, y: 1) + button.titleLabel?.transform = CGAffineTransform(scaleX: -1, y: 1) + button.transform = CGAffineTransform(scaleX: -1, y: 1) return button }() @@ -325,11 +328,15 @@ extension PickServerCell { func updateExpandMode(mode: ExpandMode) { switch mode { case .collapse: + expandButton.setImage(UIImage(systemName: "chevron.down", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeMore, for: .normal) expandBox.isHidden = true expandButton.isSelected = false NSLayoutConstraint.deactivate(expandConstraints) NSLayoutConstraint.activate(collapseConstraints) case .expand: + expandButton.setImage(UIImage(systemName: "chevron.up", withConfiguration: UIImage.SymbolConfiguration(pointSize: 13)), for: .normal) + expandButton.setTitle(L10n.Scene.ServerPicker.Button.seeLess, for: .normal) expandBox.isHidden = false expandButton.isSelected = true NSLayoutConstraint.activate(expandConstraints) diff --git a/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift new file mode 100644 index 000000000..37135fa9b --- /dev/null +++ b/Mastodon/Scene/Onboarding/PickServer/TableViewCell/PickServerLoaderTableViewCell.swift @@ -0,0 +1,86 @@ +// +// PickServerLoaderTableViewCell.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-5-13. +// + +import UIKit +import Combine + +final class PickServerLoaderTableViewCell: TimelineLoaderTableViewCell { + + let containerView: UIView = { + let view = UIView() + view.layoutMargins = UIEdgeInsets(top: 16, left: 16, bottom: 10, right: 16) + view.backgroundColor = Asset.Colors.Background.secondaryGroupedSystemBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let seperator: UIView = { + let view = UIView() + view.backgroundColor = Asset.Colors.Background.systemGroupedBackground.color + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + let emptyStatusLabel: UILabel = { + let label = UILabel() + label.text = L10n.Scene.ServerPicker.EmptyState.noResults + label.textColor = Asset.Colors.Label.secondary.color + label.textAlignment = .center + label.font = UIFontMetrics(forTextStyle: .headline).scaledFont(for: .systemFont(ofSize: 14, weight: .semibold), maximumPointSize: 19) + return label + }() + + override func _init() { + super._init() + + contentView.addSubview(containerView) + contentView.addSubview(seperator) + + NSLayoutConstraint.activate([ + // Set background view + containerView.topAnchor.constraint(equalTo: contentView.topAnchor), + containerView.leadingAnchor.constraint(equalTo: contentView.readableContentGuide.leadingAnchor), + contentView.readableContentGuide.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: 1), + + // Set bottom separator + seperator.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: seperator.trailingAnchor), + containerView.topAnchor.constraint(equalTo: seperator.topAnchor), + seperator.heightAnchor.constraint(equalToConstant: 1).priority(.defaultHigh), + ]) + + emptyStatusLabel.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(emptyStatusLabel) + NSLayoutConstraint.activate([ + emptyStatusLabel.leadingAnchor.constraint(equalTo: containerView.readableContentGuide.leadingAnchor), + containerView.readableContentGuide.trailingAnchor.constraint(equalTo: emptyStatusLabel.trailingAnchor), + emptyStatusLabel.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + ]) + emptyStatusLabel.isHidden = true + + contentView.bringSubviewToFront(stackView) + activityIndicatorView.isHidden = false + startAnimating() + } +} + +#if canImport(SwiftUI) && DEBUG +import SwiftUI + +struct PickServerLoaderTableViewCell_Previews: PreviewProvider { + + static var previews: some View { + UIViewPreview(width: 375) { + PickServerLoaderTableViewCell() + } + .previewLayout(.fixed(width: 375, height: 100)) + } + +} + +#endif diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift index da7420e43..ded8fa49b 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineLoaderTableViewCell.swift @@ -16,9 +16,9 @@ class TimelineLoaderTableViewCell: UITableViewCell { static let labelFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 17, weight: .medium)) var disposeBag = Set() - - var stateBindDispose: AnyCancellable? - + + let stackView = UIStackView() + let loadMoreButton: UIButton = { let button = HighlightDimmableButton() button.titleLabel?.font = TimelineLoaderTableViewCell.labelFont @@ -86,7 +86,6 @@ class TimelineLoaderTableViewCell: UITableViewCell { ]) // use stack view to alignlment content center - let stackView = UIStackView() stackView.spacing = 4 stackView.axis = .horizontal stackView.alignment = .center diff --git a/Mastodon/Scene/Webview/WebViewController.swift b/Mastodon/Scene/Share/Webview/WebViewController.swift similarity index 100% rename from Mastodon/Scene/Webview/WebViewController.swift rename to Mastodon/Scene/Share/Webview/WebViewController.swift diff --git a/Mastodon/Scene/Webview/WebViewModel.swift b/Mastodon/Scene/Share/Webview/WebViewModel.swift similarity index 100% rename from Mastodon/Scene/Webview/WebViewModel.swift rename to Mastodon/Scene/Share/Webview/WebViewModel.swift