From 0548aa5f5670fc5e61cd2abe2fe042b0a2dab39a Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 29 Apr 2022 13:35:03 +0800 Subject: [PATCH] feat: add Community tab into discovery scene --- Mastodon.xcodeproj/project.pbxproj | 32 +++ ...oGenerateTableViewDelegate.generated.swift | 19 +- .../Community/CommunityViewViewModel.swift | 64 ++++++ ...ityViewController+DataSourceProvider.swift | 34 +++ .../DiscoveryCommunityViewController.swift | 154 +++++++++++++ ...DiscoveryCommunityViewModel+Diffable.swift | 65 ++++++ .../DiscoveryCommunityViewModel+State.swift | 206 ++++++++++++++++++ .../DiscoveryCommunityViewModel.swift | 65 ++++++ .../DiscoveryCommunityViewViewModel.swift | 64 ++++++ .../Scene/Discovery/DiscoveryViewModel.swift | 25 ++- .../Hashtags/DiscoveryHashtagsViewModel.swift | 1 + .../Posts/DiscoveryPostsViewModel+State.swift | 2 +- .../UserTimelineViewModel+State.swift | 6 + .../APIService+PublicTimeline.swift | 52 +++++ .../APIService/APIService+UserTimeline.swift | 2 +- .../API/Mastodon+API+Timeline.swift | 6 +- 16 files changed, 781 insertions(+), 16 deletions(-) create mode 100644 Mastodon/Scene/Discovery/Community/CommunityViewViewModel.swift create mode 100644 Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift create mode 100644 Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift create mode 100644 Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift create mode 100644 Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift create mode 100644 Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewViewModel.swift create mode 100644 Mastodon/Service/APIService/APIService+PublicTimeline.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index a9aab17b..833ea24d 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -233,6 +233,12 @@ DB3E6FF52807C40300B035AE /* DiscoveryForYouViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */; }; DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */; }; DB3E6FFA2807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift */; }; + DB3EA8E6281B79E200598866 /* DiscoveryCommunityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3EA8E5281B79E200598866 /* DiscoveryCommunityViewController.swift */; }; + DB3EA8E9281B7A3700598866 /* DiscoveryCommunityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3EA8E8281B7A3700598866 /* DiscoveryCommunityViewModel.swift */; }; + DB3EA8EB281B7E0700598866 /* DiscoveryCommunityViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3EA8EA281B7E0700598866 /* DiscoveryCommunityViewModel+State.swift */; }; + DB3EA8ED281B810100598866 /* APIService+PublicTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3EA8EC281B810100598866 /* APIService+PublicTimeline.swift */; }; + DB3EA8EF281B837000598866 /* DiscoveryCommunityViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3EA8EE281B837000598866 /* DiscoveryCommunityViewController+DataSourceProvider.swift */; }; + DB3EA8F1281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB3EA8F0281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift */; }; DB427DD625BAA00100D1B89D /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD525BAA00100D1B89D /* AppDelegate.swift */; }; DB427DD825BAA00100D1B89D /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DD725BAA00100D1B89D /* SceneDelegate.swift */; }; DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = DB427DDB25BAA00100D1B89D /* Main.storyboard */; }; @@ -962,6 +968,12 @@ DB3E6FF42807C40300B035AE /* DiscoveryForYouViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryForYouViewController.swift; sourceTree = ""; }; DB3E6FF72807C45300B035AE /* DiscoveryForYouViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryForYouViewModel.swift; sourceTree = ""; }; DB3E6FF92807C47900B035AE /* DiscoveryForYouViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryForYouViewModel+Diffable.swift"; sourceTree = ""; }; + DB3EA8E5281B79E200598866 /* DiscoveryCommunityViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryCommunityViewController.swift; sourceTree = ""; }; + DB3EA8E8281B7A3700598866 /* DiscoveryCommunityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryCommunityViewModel.swift; sourceTree = ""; }; + DB3EA8EA281B7E0700598866 /* DiscoveryCommunityViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryCommunityViewModel+State.swift"; sourceTree = ""; }; + DB3EA8EC281B810100598866 /* APIService+PublicTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+PublicTimeline.swift"; sourceTree = ""; }; + DB3EA8EE281B837000598866 /* DiscoveryCommunityViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryCommunityViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB3EA8F0281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryCommunityViewModel+Diffable.swift"; sourceTree = ""; }; DB427DD225BAA00100D1B89D /* Mastodon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Mastodon.app; sourceTree = BUILT_PRODUCTS_DIR; }; DB427DD525BAA00100D1B89D /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; DB427DD725BAA00100D1B89D /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -2165,6 +2177,18 @@ path = ForYou; sourceTree = ""; }; + DB3EA8E7281B79E500598866 /* Community */ = { + isa = PBXGroup; + children = ( + DB3EA8E5281B79E200598866 /* DiscoveryCommunityViewController.swift */, + DB3EA8EE281B837000598866 /* DiscoveryCommunityViewController+DataSourceProvider.swift */, + DB3EA8E8281B7A3700598866 /* DiscoveryCommunityViewModel.swift */, + DB3EA8F0281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift */, + DB3EA8EA281B7E0700598866 /* DiscoveryCommunityViewModel+State.swift */, + ); + path = Community; + sourceTree = ""; + }; DB427DC925BAA00100D1B89D = { isa = PBXGroup; children = ( @@ -2258,6 +2282,7 @@ 2D9DB96A263A91D1007C1D71 /* APIService+DomainBlock.swift */, DB45FB1C25CA9D23005A8AC7 /* APIService+HomeTimeline.swift */, DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */, + DB3EA8EC281B810100598866 /* APIService+PublicTimeline.swift */, DBA465922696B495002B41DB /* APIService+WebFinger.swift */, DB0AC6FB25CD02E600D75117 /* APIService+Instance.swift */, DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */, @@ -3128,6 +3153,7 @@ DBDFF19828055A0900557A48 /* Posts */, DB3E6FDE2806A41200B035AE /* Hashtags */, DB3E6FED2806D7FC00B035AE /* News */, + DB3EA8E7281B79E500598866 /* Community */, DB3E6FF62807C40500B035AE /* ForYou */, DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */, DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */, @@ -4049,6 +4075,7 @@ DB63F7452799056400455B82 /* HashtagTableViewCell.swift in Sources */, DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */, DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */, + DB3EA8EB281B7E0700598866 /* DiscoveryCommunityViewModel+State.swift in Sources */, DB0FCB7227952986006C02E2 /* NamingState.swift in Sources */, DB73BF47271199CA00781945 /* Instance.swift in Sources */, DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */, @@ -4181,6 +4208,7 @@ DB040ED126538E3D00BEE9D8 /* Trie.swift in Sources */, DB73BF4B27140C0800781945 /* UITableViewDiffableDataSource.swift in Sources */, DBB525642612C988002F1F29 /* MeProfileViewModel.swift in Sources */, + DB3EA8EF281B837000598866 /* DiscoveryCommunityViewController+DataSourceProvider.swift in Sources */, DB6B74EF272FB55000C70B6E /* FollowerListViewController.swift in Sources */, DB4AA6B327BA34B6009EC082 /* CellFrameCacheContainer.swift in Sources */, DB0FCB942797E2B0006C02E2 /* SearchResultViewModel+Diffable.swift in Sources */, @@ -4222,10 +4250,12 @@ DB697DE1278F5296004EF2F7 /* DataSourceFacade+Model.swift in Sources */, DBCC3B8F26148F7B0045B23D /* CachedProfileViewModel.swift in Sources */, DB4F097526A037F500D62E92 /* SearchHistoryViewModel.swift in Sources */, + DB3EA8E9281B7A3700598866 /* DiscoveryCommunityViewModel.swift in Sources */, DB6180F826391D660018D199 /* MediaPreviewingViewController.swift in Sources */, DB0140CF25C42AEE00F9F3CF /* OSLog.swift in Sources */, DB98EB5627B0FF1B0082E365 /* ReportViewControllerAppearance.swift in Sources */, DB938F1526241FDF00E5B6C1 /* APIService+Thread.swift in Sources */, + DB3EA8E6281B79E200598866 /* DiscoveryCommunityViewController.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, @@ -4276,6 +4306,7 @@ DB73BF45271195AC00781945 /* APIService+CoreData+Instance.swift in Sources */, DB336F21278D6D960031E64B /* MastodonEmoji.swift in Sources */, DB1D84382657B275000346B3 /* SegmentedControlNavigateable.swift in Sources */, + DB3EA8ED281B810100598866 /* APIService+PublicTimeline.swift in Sources */, DB447697260B439000B66B82 /* CustomEmojiPickerHeaderCollectionReusableView.swift in Sources */, DB025B97278D66D5002F581E /* MastodonUser+Property.swift in Sources */, DB45FAF925CA80A2005A8AC7 /* APIService+CoreData+MastodonAuthentication.swift in Sources */, @@ -4338,6 +4369,7 @@ DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, DB63F756279949BD00455B82 /* Persistence+SearchHistory.swift in Sources */, 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, + DB3EA8F1281B9EF600598866 /* DiscoveryCommunityViewModel+Diffable.swift in Sources */, DB63F775279A997D00455B82 /* NotificationTableViewCell+ViewModel.swift in Sources */, DB98EB5927B109890082E365 /* ReportSupplementaryViewController.swift in Sources */, DB0617EB277EF3820030EE79 /* GradientBorderView.swift in Sources */, diff --git a/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift index ebf86700..7d6e01cf 100644 --- a/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift +++ b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift @@ -1,14 +1,7 @@ // Generated using Sourcery 1.6.1 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT - - - - - - - -// sourcery:inline:UserTimelineViewController.AutoGenerateTableViewDelegate +// sourcery:inline:DiscoveryCommunityViewController.AutoGenerateTableViewDelegate // Generated using Sourcery // DO NOT EDIT @@ -33,3 +26,13 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con } // sourcery:end + + + + + + + + + + diff --git a/Mastodon/Scene/Discovery/Community/CommunityViewViewModel.swift b/Mastodon/Scene/Discovery/Community/CommunityViewViewModel.swift new file mode 100644 index 00000000..51eba9b3 --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/CommunityViewViewModel.swift @@ -0,0 +1,64 @@ +// +// DiscoveryCommunityViewViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import os.log +import UIKit +import Combine +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +final class DiscoveryCommunityViewViewModel { + + let logger = Logger(subsystem: "DiscoveryCommunityViewViewModel", category: "ViewModel") + + var disposeBag = Set() + + // input + let context: AppContext + let viewDidAppeared = PassthroughSubject() + let statusFetchedResultsController: StatusFetchedResultsController + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + let didLoadLatest = PassthroughSubject() + + init(context: AppContext) { + self.context = context + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) + // end init + + context.authenticationService.activeMastodonAuthentication + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift new file mode 100644 index 00000000..e9441b5d --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// DiscoveryCommunityViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import UIKit + +extension DiscoveryCommunityViewController: DataSourceProvider { + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + var _indexPath = source.indexPath + if _indexPath == nil, let cell = source.tableViewCell { + _indexPath = await self.indexPath(for: cell) + } + guard let indexPath = _indexPath else { return nil } + + guard let item = viewModel.diffableDataSource?.itemIdentifier(for: indexPath) else { + return nil + } + + switch item { + case .status(let record): + return .status(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift new file mode 100644 index 00000000..eabed7a0 --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift @@ -0,0 +1,154 @@ +// +// DiscoveryCommunityViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import os.log +import UIKit +import Combine +import MastodonUI + +// Local Timeline +final class DiscoveryCommunityViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "DiscoveryCommunityViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: DiscoveryCommunityViewModel! + + let mediaPreviewTransitionController = MediaPreviewTransitionController() + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 100 + tableView.separatorStyle = .none + tableView.backgroundColor = .clear + return tableView + }() + + let refreshControl = UIRefreshControl() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension DiscoveryCommunityViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.view.backgroundColor = theme.secondarySystemBackgroundColor + } + .store(in: &disposeBag) + + tableView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tableView) + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + tableView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(DiscoveryCommunityViewController.refreshControlValueChanged(_:)), for: .valueChanged) + viewModel.didLoadLatest + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.refreshControl.endRefreshing() + } + .store(in: &disposeBag) + + tableView.delegate = self + viewModel.setupDiffableDataSource( + tableView: tableView, + statusTableViewCellDelegate: self + ) + + // setup batch fetch + viewModel.listBatchFetchViewModel.setup(scrollView: tableView) + viewModel.listBatchFetchViewModel.shouldFetch + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + guard self.view.window != nil else { return } + self.viewModel.stateMachine.enter(DiscoveryCommunityViewModel.State.Loading.self) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + viewModel.viewDidAppeared.send() + } + +} + +extension DiscoveryCommunityViewController { + + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { + if !viewModel.stateMachine.enter(DiscoveryCommunityViewModel.State.Reloading.self) { + refreshControl.endRefreshing() + } + } + +} + +// MARK: - UITableViewDelegate +extension DiscoveryCommunityViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:CommunityViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + return aspectTableView(tableView, contextMenuConfigurationForRowAt: indexPath, point: point) + } + + func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForHighlightingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + return aspectTableView(tableView, previewForDismissingContextMenuWithConfiguration: configuration) + } + + func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + aspectTableView(tableView, willPerformPreviewActionForMenuWith: configuration, animator: animator) + } + // sourcery:end +} + +// MARK: - StatusTableViewCellDelegate +extension DiscoveryCommunityViewController: StatusTableViewCellDelegate { } + +// MARK: ScrollViewContainer +extension DiscoveryCommunityViewController: ScrollViewContainer { + var scrollView: UIScrollView? { + tableView + } +} diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift new file mode 100644 index 00000000..26335ec3 --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+Diffable.swift @@ -0,0 +1,65 @@ +// +// DiscoveryCommunityViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import UIKit +import Combine + +extension DiscoveryCommunityViewModel { + + func setupDiffableDataSource( + tableView: UITableView, + statusTableViewCellDelegate: StatusTableViewCellDelegate + ) { + diffableDataSource = StatusSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: StatusSection.Configuration( + statusTableViewCellDelegate: statusTableViewCellDelegate, + timelineMiddleLoaderTableViewCellDelegate: nil, + filterContext: .none, + activeFilters: nil + ) + ) + + stateMachine.enter(State.Reloading.self) + + statusFetchedResultsController.$records + .receive(on: DispatchQueue.main) + .sink { [weak self] records in + guard let self = self else { return } + guard let diffableDataSource = self.diffableDataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + + let items = records.map { StatusItem.status(record: $0) } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Initial, + is State.Reloading, + is State.Loading, + is State.Idle, + is State.Fail: + if !items.isEmpty { + snapshot.appendItems([.bottomLoader], toSection: .main) + } + case is State.NoMore: + break + default: + assertionFailure() + break + } + } + + diffableDataSource.applySnapshot(snapshot, animated: false) + } + .store(in: &disposeBag) + } + +} diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift new file mode 100644 index 00000000..a3947e6a --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel+State.swift @@ -0,0 +1,206 @@ +// +// DiscoveryCommunityViewModel+State.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension DiscoveryCommunityViewModel { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "DiscoveryCommunityViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: DiscoveryCommunityViewModel? + + init(viewModel: DiscoveryCommunityViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? DiscoveryCommunityViewModel.State + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] enter \(self.name), previous: \(previousState?.name ?? "")") + } + + @MainActor + func enter(state: State.Type) { + stateMachine?.enter(state) + } + + deinit { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [\(self.id.uuidString)] \(self.name)") + } + } +} + +extension DiscoveryCommunityViewModel.State { + class Initial: DiscoveryCommunityViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } + + class Reloading: DiscoveryCommunityViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + stateMachine.enter(Loading.self) + } + } + + class Fail: DiscoveryCommunityViewModel.State { + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Loading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let _ = viewModel, let stateMachine = stateMachine else { return } + + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading 3s later…", ((#file as NSString).lastPathComponent), #line, #function) + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: retry loading", ((#file as NSString).lastPathComponent), #line, #function) + stateMachine.enter(Loading.self) + } + } + } + + class Idle: DiscoveryCommunityViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: DiscoveryCommunityViewModel.State { + + var maxID: String? + + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Fail.Type: + return true + case is Idle.Type: + return true + case is NoMore.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + + switch previousState { + case is Reloading: + maxID = nil + default: + break + } + + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let maxID = self.maxID + let isReloading = maxID == nil + + Task { + do { + let response = try await viewModel.context.apiService.publicTimeline( + query: .init( + local: true, + remote: nil, + onlyMedia: nil, + maxID: maxID, + sinceID: nil, + minID: nil, + limit: 20 + ), + authenticationBox: authenticationBox + ) + + let newMaxID = response.link?.maxID + let hasMore = newMaxID != nil + self.maxID = newMaxID + + var hasNewStatusesAppend = false + var statusIDs = isReloading ? [] : viewModel.statusFetchedResultsController.statusIDs.value + for status in response.value { + guard !statusIDs.contains(status.id) else { continue } + statusIDs.append(status.id) + hasNewStatusesAppend = true + } + + if hasNewStatusesAppend, hasMore { + self.maxID = response.link?.maxID + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + viewModel.didLoadLatest.send() + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch user timeline fail: \(error.localizedDescription)") + await enter(state: Fail.self) + } + } // end Task + } // end func + } + + class NoMore: DiscoveryCommunityViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + + } + } +} diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift new file mode 100644 index 00000000..8911a506 --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewModel.swift @@ -0,0 +1,65 @@ +// +// DiscoveryCommunityViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import os.log +import UIKit +import Combine +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +final class DiscoveryCommunityViewModel { + + let logger = Logger(subsystem: "DiscoveryCommunityViewModel", category: "ViewModel") + + var disposeBag = Set() + + // input + let context: AppContext + let viewDidAppeared = PassthroughSubject() + let statusFetchedResultsController: StatusFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + let didLoadLatest = PassthroughSubject() + + init(context: AppContext) { + self.context = context + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) + // end init + + context.authenticationService.activeMastodonAuthentication + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewViewModel.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewViewModel.swift new file mode 100644 index 00000000..bbf508b6 --- /dev/null +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewViewModel.swift @@ -0,0 +1,64 @@ +// +// DiscoveryCommunityViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import os.log +import UIKit +import Combine +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +final class DiscoveryCommunityViewModel { + + let logger = Logger(subsystem: "DiscoveryCommunityViewModel", category: "ViewModel") + + var disposeBag = Set() + + // input + let context: AppContext + let viewDidAppeared = PassthroughSubject() + let statusFetchedResultsController: StatusFetchedResultsController + + // output + var diffableDataSource: UITableViewDiffableDataSource? + private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(viewModel: self), + State.Reloading(viewModel: self), + State.Fail(viewModel: self), + State.Idle(viewModel: self), + State.Loading(viewModel: self), + State.NoMore(viewModel: self), + ]) + stateMachine.enter(State.Initial.self) + return stateMachine + }() + + let didLoadLatest = PassthroughSubject() + + init(context: AppContext) { + self.context = context + self.statusFetchedResultsController = StatusFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalTweetPredicate: nil + ) + // end init + + context.authenticationService.activeMastodonAuthentication + .map { $0?.domain } + .assign(to: \.value, on: statusFetchedResultsController.domain) + .store(in: &disposeBag) + + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} diff --git a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift index ae31797e..ca4773fe 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift @@ -9,6 +9,7 @@ import UIKit import Combine import Tabman import Pageboy +import MastodonLocalization final class DiscoveryViewModel { @@ -19,6 +20,7 @@ final class DiscoveryViewModel { let discoveryPostsViewController: DiscoveryPostsViewController let discoveryHashtagsViewController: DiscoveryHashtagsViewController let discoveryNewsViewController: DiscoveryNewsViewController + let discoveryCommunityViewController: DiscoveryCommunityViewController let discoveryForYouViewController: DiscoveryForYouViewController @Published var viewControllers: [ScrollViewContainer & PageViewController] @@ -48,6 +50,12 @@ final class DiscoveryViewModel { viewController.viewModel = DiscoveryNewsViewModel(context: context) return viewController }() + discoveryCommunityViewController = { + let viewController = DiscoveryCommunityViewController() + setupDependency(viewController) + viewController.viewModel = DiscoveryCommunityViewModel(context: context) + return viewController + }() discoveryForYouViewController = { let viewController = DiscoveryForYouViewController() setupDependency(viewController) @@ -58,6 +66,7 @@ final class DiscoveryViewModel { discoveryPostsViewController, discoveryHashtagsViewController, discoveryNewsViewController, + discoveryCommunityViewController, discoveryForYouViewController, ] // end init @@ -123,7 +132,7 @@ protocol PageViewController: UIViewController { // MARK: - PageViewController extension DiscoveryPostsViewController: PageViewController { - var tabItemTitle: String { "Posts" } + var tabItemTitle: String { L10n.Scene.Discovery.Tabs.posts } var tabItem: TMBarItemable { return TMBarItem(title: tabItemTitle) } @@ -132,7 +141,7 @@ extension DiscoveryPostsViewController: PageViewController { // MARK: - PageViewController extension DiscoveryHashtagsViewController: PageViewController { - var tabItemTitle: String { "Hashtags" } + var tabItemTitle: String { L10n.Scene.Discovery.Tabs.hashtags } var tabItem: TMBarItemable { return TMBarItem(title: tabItemTitle) @@ -141,7 +150,15 @@ extension DiscoveryHashtagsViewController: PageViewController { // MARK: - PageViewController extension DiscoveryNewsViewController: PageViewController { - var tabItemTitle: String { "News" } + var tabItemTitle: String { L10n.Scene.Discovery.Tabs.news } + var tabItem: TMBarItemable { + return TMBarItem(title: tabItemTitle) + } +} + +// MARK: - PageViewController +extension DiscoveryCommunityViewController: PageViewController { + var tabItemTitle: String { "Community" } var tabItem: TMBarItemable { return TMBarItem(title: tabItemTitle) } @@ -149,7 +166,7 @@ extension DiscoveryNewsViewController: PageViewController { // MARK: - PageViewController extension DiscoveryForYouViewController: PageViewController { - var tabItemTitle: String { "For You" } + var tabItemTitle: String { L10n.Scene.Discovery.Tabs.forYou } var tabItem: TMBarItemable { return TMBarItem(title: tabItemTitle) } diff --git a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift index bb30ec9e..1b119f3d 100644 --- a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift +++ b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewModel.swift @@ -73,4 +73,5 @@ extension DiscoveryHashtagsViewModel { hashtags = response.value.filter { !$0.name.isEmpty } logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch tags: \(response.value.count)") } + } diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift index d91c5adc..0ff6cbb1 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift @@ -180,7 +180,7 @@ extension DiscoveryPostsViewModel.State { } viewModel.statusFetchedResultsController.statusIDs.value = statusIDs viewModel.didLoadLatest.send() -// } catch let error as? + } catch { logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch posts fail: \(error.localizedDescription)") if let error = error as? Mastodon.API.Error, error.httpResponseStatus.code == 404 { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 06f657ba..ae870f7b 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -195,6 +195,12 @@ extension UserTimelineViewModel.State { // trigger data source update. otherwise, spinner always display viewModel.isSuspended.value = viewModel.isSuspended.value + + // remove bottom loader + guard let diffableDataSource = viewModel.diffableDataSource else { return } + var snapshot = diffableDataSource.snapshot() + snapshot.deleteItems([.bottomLoader]) + diffableDataSource.apply(snapshot) } } } diff --git a/Mastodon/Service/APIService/APIService+PublicTimeline.swift b/Mastodon/Service/APIService/APIService+PublicTimeline.swift new file mode 100644 index 00000000..fd0c2f0c --- /dev/null +++ b/Mastodon/Service/APIService/APIService+PublicTimeline.swift @@ -0,0 +1,52 @@ +// +// APIService+PublicTimeline.swift +// Mastodon +// +// Created by MainasuK on 2022-4-29. +// + +import Foundation +import Combine +import CoreData +import CoreDataStack +import CommonOSLog +import MastodonSDK + +extension APIService { + + func publicTimeline( + query: Mastodon.API.Timeline.PublicTimelineQuery, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let response = try await Mastodon.API.Timeline.public( + session: session, + domain: domain, + query: query, + authorization: authorization + ).singleOutput() + + let managedObjectContext = self.backgroundManagedObjectContext + try await managedObjectContext.performChanges { + let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user + for entity in response.value { + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: me, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + } + } + + return response + } // end func + +} diff --git a/Mastodon/Service/APIService/APIService+UserTimeline.swift b/Mastodon/Service/APIService/APIService+UserTimeline.swift index c5cb6318..85b8d615 100644 --- a/Mastodon/Service/APIService/APIService+UserTimeline.swift +++ b/Mastodon/Service/APIService/APIService+UserTimeline.swift @@ -48,7 +48,7 @@ extension APIService { try await managedObjectContext.performChanges { let me = authenticationBox.authenticationRecord.object(in: managedObjectContext)?.user for entity in response.value { - Persistence.Status.createOrMerge( + _ = Persistence.Status.createOrMerge( in: managedObjectContext, context: Persistence.Status.PersistContext( domain: domain, diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift index c1857ae8..a569f7a6 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Timeline.swift @@ -33,16 +33,18 @@ extension Mastodon.API.Timeline { /// - session: `URLSession` /// - domain: Mastodon instance domain. e.g. "example.com" /// - query: `PublicTimelineQuery` with query parameters + /// - authorization: required if the instance has disabled public preview /// - Returns: `AnyPublisher` contains `Token` nested in the response public static func `public`( session: URLSession, domain: String, - query: PublicTimelineQuery + query: PublicTimelineQuery, + authorization: Mastodon.API.OAuth.Authorization? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: publicTimelineEndpointURL(domain: domain), query: query, - authorization: nil + authorization: authorization ) return session.dataTaskPublisher(for: request) .tryMap { data, response in