diff --git a/Localization/app.json b/Localization/app.json index 1ade4c5c5..82e548d9f 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -456,6 +456,9 @@ "hashtags": "Hashtags", "posts": "Posts" }, + "empty_state": { + "no_results": "No results" + }, "recent_search": "Recent searches", "clear": "Clear" } diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index b7ea9df4a..220a7fdba 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -31,6 +31,7 @@ enum Item { case publicMiddleLoader(statusID: String) case topLoader case bottomLoader + case emptyBottomLoader case emptyStateHeader(attribute: EmptyStateHeaderAttribute) @@ -98,6 +99,7 @@ extension Item { super.init(isSeparatorLineHidden: isSeparatorLineHidden) } } + } extension Item: Equatable { @@ -123,6 +125,8 @@ extension Item: Equatable { return true case (.bottomLoader, .bottomLoader): return true + case (.emptyBottomLoader, .emptyBottomLoader): + return true case (.emptyStateHeader(let attributeLeft), .emptyStateHeader(let attributeRight)): return attributeLeft == attributeRight case (.reportStatus(let objectIDLeft, _), .reportStatus(let objectIDRight, _)): @@ -158,6 +162,8 @@ extension Item: Hashable { hasher.combine(String(describing: Item.topLoader.self)) case .bottomLoader: hasher.combine(String(describing: Item.bottomLoader.self)) + case .emptyBottomLoader: + hasher.combine(String(describing: Item.emptyBottomLoader.self)) case .emptyStateHeader(let attribute): hasher.combine(attribute) case .reportStatus(let objectID, _): @@ -184,6 +190,7 @@ extension Item { .publicMiddleLoader, .topLoader, .bottomLoader, + .emptyBottomLoader, .emptyStateHeader: return nil } diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift index 4765c4db2..b3d70be49 100644 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -17,7 +17,27 @@ enum SearchResultItem { case hashtagObjectID(hashtagObjectID: NSManagedObjectID) case status(statusObjectID: NSManagedObjectID, attribute: Item.StatusAttribute) - case bottomLoader + case bottomLoader(attribute: BottomLoaderAttribute) +} + +extension SearchResultItem { + class BottomLoaderAttribute: Hashable { + let id = UUID() + + var isNoResult: Bool + + init(isEmptyResult: Bool) { + self.isNoResult = isEmptyResult + } + + static func == (lhs: SearchResultItem.BottomLoaderAttribute, rhs: SearchResultItem.BottomLoaderAttribute) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + } } extension SearchResultItem: Equatable { @@ -33,8 +53,8 @@ extension SearchResultItem: Equatable { return idLeft == idRight case (.status(let idLeft, _), .status(let idRight, _)): return idLeft == idRight - case (.bottomLoader, .bottomLoader): - return true + case (.bottomLoader(let attributeLeft), .bottomLoader(let attributeRight)): + return attributeLeft == attributeRight default: return false } @@ -56,8 +76,8 @@ extension SearchResultItem: Hashable { hasher.combine(id) case .status(let id, _): hasher.combine(id) - case .bottomLoader: - hasher.combine(String(describing: SearchResultItem.bottomLoader.self)) + case .bottomLoader(let attribute): + hasher.combine(attribute) } } } diff --git a/Mastodon/Diffiable/Section/SearchResultSection.swift b/Mastodon/Diffiable/Section/SearchResultSection.swift index 60364aa6f..5d26a6828 100644 --- a/Mastodon/Diffiable/Section/SearchResultSection.swift +++ b/Mastodon/Diffiable/Section/SearchResultSection.swift @@ -61,9 +61,17 @@ extension SearchResultSection { } cell.delegate = statusTableViewCellDelegate return cell - case .bottomLoader: + case .bottomLoader(let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) as! TimelineBottomLoaderTableViewCell - cell.startAnimating() + if attribute.isNoResult { + cell.stopAnimating() + cell.loadMoreLabel.text = L10n.Scene.Search.Searching.EmptyState.noResults + cell.loadMoreLabel.textColor = Asset.Colors.Label.secondary.color + cell.loadMoreLabel.isHidden = false + } else { + cell.startAnimating() + cell.loadMoreLabel.isHidden = true + } return cell default: fatalError() diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 3b808ccc5..e1823851a 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -207,6 +207,12 @@ extension StatusSection { let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell cell.startAnimating() return cell + case .emptyBottomLoader: + let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self), for: indexPath) as! TimelineBottomLoaderTableViewCell + cell.stopAnimating() + cell.loadMoreLabel.text = " " + cell.loadMoreLabel.isHidden = false + return cell case .emptyStateHeader(let attribute): let cell = tableView.dequeueReusableCell(withIdentifier: String(describing: TimelineHeaderTableViewCell.self), for: indexPath) as! TimelineHeaderTableViewCell StatusSection.configureEmptyStateHeader(cell: cell, attribute: attribute) diff --git a/Mastodon/Generated/Strings.swift b/Mastodon/Generated/Strings.swift index 459b8e62c..5ad12f980 100644 --- a/Mastodon/Generated/Strings.swift +++ b/Mastodon/Generated/Strings.swift @@ -823,6 +823,10 @@ internal enum L10n { internal static let clear = L10n.tr("Localizable", "Scene.Search.Searching.Clear") /// Recent searches internal static let recentSearch = L10n.tr("Localizable", "Scene.Search.Searching.RecentSearch") + internal enum EmptyState { + /// No results + internal static let noResults = L10n.tr("Localizable", "Scene.Search.Searching.EmptyState.NoResults") + } internal enum Segment { /// All internal static let all = L10n.tr("Localizable", "Scene.Search.Searching.Segment.All") diff --git a/Mastodon/Resources/ar.lproj/Localizable.strings b/Mastodon/Resources/ar.lproj/Localizable.strings index 890c9f6f2..7547775e7 100644 --- a/Mastodon/Resources/ar.lproj/Localizable.strings +++ b/Mastodon/Resources/ar.lproj/Localizable.strings @@ -277,6 +277,7 @@ tap the link to confirm your account."; "Scene.Search.SearchBar.Cancel" = "Cancel"; "Scene.Search.SearchBar.Placeholder" = "Search hashtags and users"; "Scene.Search.Searching.Clear" = "Clear"; +"Scene.Search.Searching.EmptyState.NoResults" = "No results"; "Scene.Search.Searching.RecentSearch" = "Recent searches"; "Scene.Search.Searching.Segment.All" = "All"; "Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; diff --git a/Mastodon/Resources/en.lproj/Localizable.strings b/Mastodon/Resources/en.lproj/Localizable.strings index 890c9f6f2..7547775e7 100644 --- a/Mastodon/Resources/en.lproj/Localizable.strings +++ b/Mastodon/Resources/en.lproj/Localizable.strings @@ -277,6 +277,7 @@ tap the link to confirm your account."; "Scene.Search.SearchBar.Cancel" = "Cancel"; "Scene.Search.SearchBar.Placeholder" = "Search hashtags and users"; "Scene.Search.Searching.Clear" = "Clear"; +"Scene.Search.Searching.EmptyState.NoResults" = "No results"; "Scene.Search.Searching.RecentSearch" = "Recent searches"; "Scene.Search.Searching.Segment.All" = "All"; "Scene.Search.Searching.Segment.Hashtags" = "Hashtags"; diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index be06d781a..2566006e0 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -179,8 +179,8 @@ extension UserTimelineViewModel.State { super.didEnter(from: previousState) guard let viewModel = viewModel, let _ = stateMachine else { return } - // trigger data source update - viewModel.statusFetchedResultsController.objectIDs.value = viewModel.statusFetchedResultsController.objectIDs.value + // trigger data source update. otherwise, spinner always display + viewModel.isSuspended.value = viewModel.isSuspended.value } } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 7d6a6c8fe..42edafb0f 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -24,11 +24,12 @@ final class UserTimelineViewModel { let queryFilter: CurrentValueSubject let statusFetchedResultsController: StatusFetchedResultsController var cellFrameCache = NSCache() - + let isBlocking = CurrentValueSubject(false) let isBlockedBy = CurrentValueSubject(false) let isSuspended = CurrentValueSubject(false) let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label + var dataSourceDidUpdate = PassthroughSubject() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -77,9 +78,13 @@ final class UserTimelineViewModel { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) + var animatingDifferences = true defer { // not animate when empty items fix loader first appear layout issue - diffableDataSource.apply(snapshot, animatingDifferences: !items.isEmpty) + diffableDataSource.apply(snapshot, animatingDifferences: animatingDifferences) { [weak self] in + guard let self = self else { return } + self.dataSourceDidUpdate.send() + } } let name = self.userDisplayName.value @@ -125,7 +130,8 @@ final class UserTimelineViewModel { case is State.Reloading, is State.Loading, is State.Idle, is State.Fail: snapshot.appendItems([.bottomLoader], toSection: .main) case is State.NoMore: - break + snapshot.appendItems([.emptyBottomLoader], toSection: .main) + animatingDifferences = false // TODO: handle other states default: break diff --git a/Mastodon/Scene/Search/SearchResult/SearchResultViewController.swift b/Mastodon/Scene/Search/SearchResult/SearchResultViewController.swift index c78ee71ed..988c2945b 100644 --- a/Mastodon/Scene/Search/SearchResult/SearchResultViewController.swift +++ b/Mastodon/Scene/Search/SearchResult/SearchResultViewController.swift @@ -205,6 +205,8 @@ extension SearchResultViewController: UITableViewDelegate { coordinator.present(scene: .hashtagTimeline(viewModel: hashtagViewModel), from: self, transition: .show) case .status: aspectTableView(tableView, didSelectRowAt: indexPath) + case .bottomLoader: + break default: assertionFailure() } diff --git a/Mastodon/Scene/Search/SearchResult/SearchResultViewModel+State.swift b/Mastodon/Scene/Search/SearchResult/SearchResultViewModel+State.swift index 986d60940..0445ee017 100644 --- a/Mastodon/Scene/Search/SearchResult/SearchResultViewModel+State.swift +++ b/Mastodon/Scene/Search/SearchResult/SearchResultViewModel+State.swift @@ -61,16 +61,18 @@ extension SearchResultViewModel.State { return } - if previousState is Initial { - // trigger bottom loader display - viewModel.items.value = viewModel.items.value - } - let domain = activeMastodonAuthenticationBox.domain let searchText = viewModel.searchText.value let searchType = viewModel.searchScope.searchType + if previousState is NoMore && previousSearchText == searchText { + // same searchText from NoMore. should silent refresh + } else { + // trigger bottom loader display + viewModel.items.value = viewModel.items.value + } + guard !searchText.isEmpty else { stateMachine.enter(Fail.self) return @@ -79,6 +81,8 @@ extension SearchResultViewModel.State { if searchText != previousSearchText { previousSearchText = searchText offset = nil + } else { + offset = viewModel.items.value.count } // not set offset for all case @@ -150,7 +154,7 @@ extension SearchResultViewModel.State { newStatusIDs.append(status.id) } - if viewModel.searchScope == .all { + if viewModel.searchScope == .all || newItems.isEmpty { stateMachine.enter(NoMore.self) } else { stateMachine.enter(Idle.self) diff --git a/Mastodon/Scene/Search/SearchResult/SearchResultViewModel.swift b/Mastodon/Scene/Search/SearchResult/SearchResultViewModel.swift index a2c9dd42b..1b5cb504a 100644 --- a/Mastodon/Scene/Search/SearchResult/SearchResultViewModel.swift +++ b/Mastodon/Scene/Search/SearchResult/SearchResultViewModel.swift @@ -92,10 +92,16 @@ final class SearchResultViewModel { if let currentState = self.stateMachine.currentState { switch currentState { - case is State.Loading, is State.Fail, is State.Fail: - snapshot.appendItems([.bottomLoader], toSection: .main) - case is State.NoMore: + case is State.Loading, is State.Fail, is State.Idle: + let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: false) + snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) + case is State.Fail: break + case is State.NoMore: + if snapshot.itemIdentifiers.isEmpty { + let attribute = SearchResultItem.BottomLoaderAttribute(isEmptyResult: true) + snapshot.appendItems([.bottomLoader(attribute: attribute)], toSection: .main) + } default: break } diff --git a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift index 8d3589fb1..70f366bce 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/TimelineBottomLoaderTableViewCell.swift @@ -9,11 +9,18 @@ import UIKit import Combine final class TimelineBottomLoaderTableViewCell: TimelineLoaderTableViewCell { + + override func prepareForReuse() { + super.prepareForReuse() + + loadMoreLabel.isHidden = true + loadMoreButton.isHidden = true + } + override func _init() { super._init() activityIndicatorView.isHidden = false - startAnimating() } }