From af619e198a77bbec5216bf0c742ebcb3ac5dceaa Mon Sep 17 00:00:00 2001 From: CMK Date: Tue, 12 Apr 2022 17:32:38 +0800 Subject: [PATCH] feat: add Discovery page with posts segment --- Localization/app.json | 8 + Mastodon.xcodeproj/project.pbxproj | 44 ++++ .../xcschemes/xcschememanagement.plist | 6 +- .../Discovery/DiscoveryViewController.swift | 94 ++++++++ .../Scene/Discovery/DiscoveryViewModel.swift | 72 ++++++ ...stsViewController+DataSourceProvider.swift | 34 +++ .../Posts/DiscoveryPostsViewController.swift | 118 ++++++++++ .../DiscoveryPostsViewModel+Diffable.swift | 63 ++++++ .../Posts/DiscoveryPostsViewModel+State.swift | 208 ++++++++++++++++++ .../Posts/DiscoveryPostsViewModel.swift | 59 +++++ .../Scene/Profile/ProfileViewController.swift | 1 + .../Timeline/UserTimelineViewController.swift | 2 +- .../Search/Search/SearchViewController.swift | 17 ++ .../Scene/Search/Search/SearchViewModel.swift | 2 +- .../Service/APIService/APIService+Trend.swift | 38 +++- .../MastodonSDK/API/Mastodon+API+Trends.swift | 74 ++++++- .../Response/Mastodon+Response+Content.swift | 10 + 17 files changed, 837 insertions(+), 13 deletions(-) create mode 100644 Mastodon/Scene/Discovery/DiscoveryViewController.swift create mode 100644 Mastodon/Scene/Discovery/DiscoveryViewModel.swift create mode 100644 Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift create mode 100644 Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift create mode 100644 Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift create mode 100644 Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift create mode 100644 Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift diff --git a/Localization/app.json b/Localization/app.json index f0dc0ebf..548c5ada 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -492,6 +492,14 @@ "clear": "Clear" } }, + "discovery": { + "tabs": { + "posts": "Posts", + "hashtags": "Hashtags", + "news": "News", + "for_you": "For You" + } + }, "favorite": { "title": "Your Favorites" }, diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index cbcd948b..98da1374 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -550,6 +550,13 @@ DBD5B1F827BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */; }; DBD5B1FA27BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */; }; DBD9149025DF6D8D00903DFD /* APIService+Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */; }; + DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */; }; + DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */; }; + DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */; }; + DBDFF197280556D900557A48 /* DiscoveryPostsViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */; }; + DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */; }; + DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */; }; + DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */; }; DBE0821525CD382600FD6BBD /* MastodonRegisterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */; }; DBE0822425CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */; }; DBE3CA6827A39CAB00AFE27B /* AppShared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DB68047F2637CD4C00430867 /* AppShared.framework */; }; @@ -1293,6 +1300,13 @@ DBD5B1F727BCFD9D00BD6B38 /* DataSourceProvider+TableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+TableViewControllerNavigateable.swift"; sourceTree = ""; }; DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewControllerNavigateable.swift"; sourceTree = ""; }; DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = ""; }; + DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewController.swift; sourceTree = ""; }; + DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewModel.swift; sourceTree = ""; }; + DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+Diffable.swift"; sourceTree = ""; }; + DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+State.swift"; sourceTree = ""; }; + DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewController.swift; sourceTree = ""; }; + DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModel.swift; sourceTree = ""; }; + DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewController+DataSourceProvider.swift"; sourceTree = ""; }; DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = ""; }; DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = ""; }; DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = ""; }; @@ -2682,6 +2696,7 @@ 2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */, DB9D6C0825E4F5A60051B173 /* Profile */, DB9D6BEE25E4F5370051B173 /* Search */, + DBDFF1912805544800557A48 /* Discovery */, 5B90C455262599800002E742 /* Settings */, ); path = Scene; @@ -3068,6 +3083,28 @@ path = FetchedResultsController; sourceTree = ""; }; + DBDFF1912805544800557A48 /* Discovery */ = { + isa = PBXGroup; + children = ( + DBDFF19828055A0900557A48 /* Posts */, + DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */, + DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */, + ); + path = Discovery; + sourceTree = ""; + }; + DBDFF19828055A0900557A48 /* Posts */ = { + isa = PBXGroup; + children = ( + DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */, + DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */, + DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */, + DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */, + DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */, + ); + path = Posts; + sourceTree = ""; + }; DBE0821A25CD382900FD6BBD /* Register */ = { isa = PBXGroup; children = ( @@ -3797,6 +3834,7 @@ buildActionMask = 2147483647; files = ( DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, + DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */, DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */, DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */, DB98337125C9443200AD9700 /* APIService+Authentication.swift in Sources */, @@ -3858,6 +3896,7 @@ DB697DD6278F4C29004EF2F7 /* DataSourceProvider.swift in Sources */, DB0FCB8E2796C0B7006C02E2 /* TrendCollectionViewCell.swift in Sources */, 0F1E2D0B2615C39400C38565 /* DoubleTitleLabelNavigationBarTitleView.swift in Sources */, + DBDFF1902805543100557A48 /* DiscoveryPostsViewController.swift in Sources */, DB697DD9278F4CED004EF2F7 /* HomeTimelineViewController+DataSourceProvider.swift in Sources */, DB9A488A26034D40008B817C /* ComposeViewModel+PublishState.swift in Sources */, DB45FAD725CA6C76005A8AC7 /* UIBarButtonItem.swift in Sources */, @@ -3878,7 +3917,9 @@ DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */, DB0FCB8C2796BF8D006C02E2 /* SearchViewModel+Diffable.swift in Sources */, 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, + DBDFF1952805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift in Sources */, DB03A795272A981400EE37C5 /* ContentSplitViewController.swift in Sources */, + DBDFF19C28055BD600557A48 /* DiscoveryViewModel.swift in Sources */, DBBC24DE26A54BCB00398BB9 /* MastodonMetricFormatter.swift in Sources */, DB06180A2785B2AB0030EE79 /* MastodonRegisterAvatarTableViewCell.swift in Sources */, DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */, @@ -4012,6 +4053,7 @@ DB1D843026566512000346B3 /* KeyboardPreference.swift in Sources */, DB852D1926FAEB6B00FC9D81 /* SidebarViewController.swift in Sources */, 2D206B9225F60EA700143C56 /* UIControl.swift in Sources */, + DBDFF1932805554900557A48 /* DiscoveryPostsViewModel.swift in Sources */, 2D9DB96B263A91D1007C1D71 /* APIService+DomainBlock.swift in Sources */, DBBF1DC92652538500E5B703 /* AutoCompleteSection.swift in Sources */, DB8AF55D25C138B7002E6C99 /* UIViewController.swift in Sources */, @@ -4069,6 +4111,7 @@ 2D5981A125E4A593000FB903 /* MastodonConfirmEmailViewModel.swift in Sources */, DB6B74F6272FBCDB00C70B6E /* FollowerListViewModel+State.swift in Sources */, DB87D4452609BE0500D12C0D /* ComposeStatusPollOptionCollectionViewCell.swift in Sources */, + DBDFF197280556D900557A48 /* DiscoveryPostsViewModel+State.swift in Sources */, DB336F2C278D6FC30031E64B /* Persistence+Status.swift in Sources */, DB336F2A278D6F2B0031E64B /* MastodonField.swift in Sources */, DB0FCB7A279576A2006C02E2 /* DataSourceFacade+Thread.swift in Sources */, @@ -4246,6 +4289,7 @@ DB6F5E38264E994A009108F4 /* AutoCompleteTopChevronView.swift in Sources */, DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */, DBB525412611ED54002F1F29 /* ProfileHeaderViewController.swift in Sources */, + DBDFF19A28055A1400557A48 /* DiscoveryViewController.swift in Sources */, DB9D6BFF25E4F5940051B173 /* ProfileViewController.swift in Sources */, DB63F756279949BD00455B82 /* Persistence+SearchHistory.swift in Sources */, 2D4AD8A226316CD200613EFC /* SelectedAccountSection.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index ad09868c..92edf05d 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -109,7 +109,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 24 + 23 MastodonIntents.xcscheme_^#shared#^_ @@ -124,12 +124,12 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 22 + 24 ShareActionExtension.xcscheme_^#shared#^_ orderHint - 23 + 22 SuppressBuildableAutocreation diff --git a/Mastodon/Scene/Discovery/DiscoveryViewController.swift b/Mastodon/Scene/Discovery/DiscoveryViewController.swift new file mode 100644 index 00000000..4f909d6c --- /dev/null +++ b/Mastodon/Scene/Discovery/DiscoveryViewController.swift @@ -0,0 +1,94 @@ +// +// DiscoveryViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import os.log +import UIKit +import Combine +import Tabman +import MastodonAsset + +public class DiscoveryViewController: TabmanViewController, NeedsDependency { + + public static let containerViewMarginForRegularHorizontalSizeClass: CGFloat = 64 + public static let containerViewMarginForCompactHorizontalSizeClass: CGFloat = 16 + + var disposeBag = Set() + + let logger = Logger(subsystem: "DiscoveryViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + private(set) lazy var viewModel = DiscoveryViewModel( + context: context, + coordinator: coordinator + ) + + let buttonBar: TMBar.ButtonBar = { + let buttonBar = TMBar.ButtonBar() + buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color + buttonBar.layout.contentInset = .zero + return buttonBar + }() + +} + +extension DiscoveryViewController { + + public override func viewDidLoad() { + super.viewDidLoad() + + setupAppearance(theme: ThemeService.shared.currentTheme.value) + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.setupAppearance(theme: theme) + } + .store(in: &disposeBag) + + dataSource = viewModel + addBar( + buttonBar, + dataSource: viewModel, + at: .top + ) + updateBarButtonInsets() + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateBarButtonInsets() + } + +} + +extension DiscoveryViewController { + + private func setupAppearance(theme: Theme) { + view.backgroundColor = ThemeService.shared.currentTheme.value.secondarySystemBackgroundColor + buttonBar.backgroundView.style = .flat(color: theme.systemBackgroundColor) + } + + private func updateBarButtonInsets() { + let margin: CGFloat = { + switch traitCollection.userInterfaceIdiom { + case .phone: + return DiscoveryViewController.containerViewMarginForCompactHorizontalSizeClass + default: + return traitCollection.horizontalSizeClass == .regular ? + DiscoveryViewController.containerViewMarginForRegularHorizontalSizeClass : + DiscoveryViewController.containerViewMarginForCompactHorizontalSizeClass + } + }() + + buttonBar.layout.contentInset.left = margin + buttonBar.layout.contentInset.right = margin + } + +} diff --git a/Mastodon/Scene/Discovery/DiscoveryViewModel.swift b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift new file mode 100644 index 00000000..137b8682 --- /dev/null +++ b/Mastodon/Scene/Discovery/DiscoveryViewModel.swift @@ -0,0 +1,72 @@ +// +// DiscoveryViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import UIKit +import Tabman +import Pageboy + +final class DiscoveryViewModel { + + // input + let context: AppContext + let discoveryViewController: DiscoveryPostsViewController + + // output + let barItems: [TMBarItemable] = { + let items = [ + TMBarItem(title: "Posts"), + TMBarItem(title: "Hashtags"), + TMBarItem(title: "News"), + TMBarItem(title: "For You"), + ] + return items + }() + + var viewControllers: [ScrollViewContainer] { + return [ + discoveryViewController, + ] + } + + init(context: AppContext, coordinator: SceneCoordinator) { + self.context = context + discoveryViewController = { + let viewController = DiscoveryPostsViewController() + viewController.context = context + viewController.coordinator = coordinator + viewController.viewModel = DiscoveryPostsViewModel(context: context) + return viewController + }() + // end init + } + +} + + +// MARK: - PageboyViewControllerDataSource +extension DiscoveryViewModel: PageboyViewControllerDataSource { + + func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { + return viewControllers.count + } + + func viewController(for pageboyViewController: PageboyViewController, at index: PageboyViewController.PageIndex) -> UIViewController? { + return viewControllers[index] + } + + func defaultPage(for pageboyViewController: PageboyViewController) -> PageboyViewController.Page? { + return .first + } + +} + +// MARK: - TMBarDataSource +extension DiscoveryViewModel: TMBarDataSource { + func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { + return barItems[index] + } +} diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift new file mode 100644 index 00000000..c3495b24 --- /dev/null +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// DiscoveryPostsViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import UIKit + +extension DiscoveryPostsViewController: 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/Posts/DiscoveryPostsViewController.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift new file mode 100644 index 00000000..3e813dbd --- /dev/null +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift @@ -0,0 +1,118 @@ +// +// DiscoveryPostsViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import os.log +import UIKit +import Combine + +final class DiscoveryPostsViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "TrendPostsViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: DiscoveryPostsViewModel! + + 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 + }() + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension DiscoveryPostsViewController { + + 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.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(DiscoveryPostsViewModel.State.Loading.self) + } + .store(in: &disposeBag) + } + +} + +// MARK: - UITableViewDelegate +extension DiscoveryPostsViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:DiscoveryPostsViewController.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 DiscoveryPostsViewController: StatusTableViewCellDelegate { } + +// MARK: ScrollViewContainer +extension DiscoveryPostsViewController: ScrollViewContainer { + var scrollView: UIScrollView? { + tableView + } +} diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift new file mode 100644 index 00000000..3abb4a21 --- /dev/null +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift @@ -0,0 +1,63 @@ +// +// DiscoveryPostsViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import UIKit +import Combine + +extension DiscoveryPostsViewModel { + + 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 + .debounce(for: .milliseconds(300), scheduler: 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: + 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/Posts/DiscoveryPostsViewModel+State.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift new file mode 100644 index 00000000..0a217868 --- /dev/null +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+State.swift @@ -0,0 +1,208 @@ +// +// DiscoveryPostsViewModel+State.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension DiscoveryPostsViewModel { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "TrendPostsViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: DiscoveryPostsViewModel? + + init(viewModel: DiscoveryPostsViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? DiscoveryPostsViewModel.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 DiscoveryPostsViewModel.State { + class Initial: DiscoveryPostsViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } + + class Reloading: DiscoveryPostsViewModel.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 = viewModel, let stateMachine = stateMachine else { return } + + // reset + viewModel.statusFetchedResultsController.statusIDs.value = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: DiscoveryPostsViewModel.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: DiscoveryPostsViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: DiscoveryPostsViewModel.State { + + var offset: Int? + + 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: + offset = nil + default: + break + } + + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let offset = self.offset + + Task { + do { + let response = try await viewModel.context.apiService.trendStatuses( + domain: authenticationBox.domain, + query: Mastodon.API.Trends.StatusQuery( + offset: offset, + limit: nil + ) + ) + let newOffset: Int? = { + guard let offset = response.link?.offset else { return nil } + return self.offset.flatMap { max($0, offset) } ?? offset + }() + + let hasMore: Bool = { + guard let newOffset = newOffset else { return false } + return newOffset != self.offset // not the same one + }() + + self.offset = newOffset + + var hasNewStatusesAppend = false + var statusIDs = 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 { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + viewModel.statusFetchedResultsController.statusIDs.value = statusIDs + + } 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: DiscoveryPostsViewModel.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/Posts/DiscoveryPostsViewModel.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift new file mode 100644 index 00000000..100a2a34 --- /dev/null +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel.swift @@ -0,0 +1,59 @@ +// +// DiscoveryPostsViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-4-12. +// + +import os.log +import UIKit +import Combine +import GameplayKit +import CoreData +import CoreDataStack +import MastodonSDK + +final class DiscoveryPostsViewModel { + + var disposeBag = Set() + + // input + let context: AppContext + 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 + }() + + 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/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index a890505e..55a952b0 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -402,6 +402,7 @@ extension ProfileViewController { } extension ProfileViewController { + private func updateBarButtonInsets() { let margin: CGFloat = { switch traitCollection.userInterfaceIdiom { diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 12925ca4..d9e52a8c 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media let cellFrameCache = NSCache() deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } } diff --git a/Mastodon/Scene/Search/Search/SearchViewController.swift b/Mastodon/Scene/Search/Search/SearchViewController.swift index d1bed948..6f4d2200 100644 --- a/Mastodon/Scene/Search/Search/SearchViewController.swift +++ b/Mastodon/Scene/Search/Search/SearchViewController.swift @@ -48,6 +48,13 @@ final class SearchViewController: UIViewController, NeedsDependency { let searchBarTapPublisher = PassthroughSubject() + private(set) lazy var trendViewController: DiscoveryViewController = { + let viewController = DiscoveryViewController() + viewController.context = context + viewController.coordinator = coordinator + return viewController + }() + deinit { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) } @@ -84,6 +91,16 @@ extension SearchViewController { viewModel.setupDiffableDataSource( collectionView: collectionView ) + + addChild(trendViewController) + trendViewController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(trendViewController.view) + NSLayoutConstraint.activate([ + trendViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + trendViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + trendViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + trendViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) } override func viewDidAppear(_ animated: Bool) { diff --git a/Mastodon/Scene/Search/Search/SearchViewModel.swift b/Mastodon/Scene/Search/Search/SearchViewModel.swift index 2776713d..84e09725 100644 --- a/Mastodon/Scene/Search/Search/SearchViewModel.swift +++ b/Mastodon/Scene/Search/Search/SearchViewModel.swift @@ -38,7 +38,7 @@ final class SearchViewModel: NSObject { } .throttle(for: 3, scheduler: DispatchQueue.main, latest: true) .asyncMap { authenticationBox in - try await context.apiService.trends(domain: authenticationBox.domain, query: nil) + try await context.apiService.trendHashtags(domain: authenticationBox.domain, query: nil) } .retry(3) .map { response in Result, Error> { response } } diff --git a/Mastodon/Service/APIService/APIService+Trend.swift b/Mastodon/Service/APIService/APIService+Trend.swift index 0ce2a86a..34edae09 100644 --- a/Mastodon/Service/APIService/APIService+Trend.swift +++ b/Mastodon/Service/APIService/APIService+Trend.swift @@ -9,11 +9,12 @@ import Foundation import MastodonSDK extension APIService { - func trends( + + func trendHashtags( domain: String, - query: Mastodon.API.Trends.Query? + query: Mastodon.API.Trends.HashtagQuery? ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Tag]> { - let response = try await Mastodon.API.Trends.get( + let response = try await Mastodon.API.Trends.hashtags( session: session, domain: domain, query: query @@ -21,4 +22,35 @@ extension APIService { return response } + + func trendStatuses( + domain: String, + query: Mastodon.API.Trends.StatusQuery + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Status]> { + let response = try await Mastodon.API.Trends.statuses( + session: session, + domain: domain, + query: query + ).singleOutput() + + let managedObjectContext = backgroundManagedObjectContext + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.Status.createOrMerge( + in: managedObjectContext, + context: Persistence.Status.PersistContext( + domain: domain, + entity: entity, + me: nil, + statusCache: nil, + userCache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift index 385e3d75..25e130e3 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Trends.swift @@ -9,6 +9,7 @@ import Combine import Foundation extension Mastodon.API.Trends { + static func trendsURL(domain: String) -> URL { Mastodon.API.endpointURL(domain: domain).appendingPathComponent("trends") } @@ -27,10 +28,10 @@ extension Mastodon.API.Trends { /// - query: query /// - Returns: `AnyPublisher` contains `Hashtags` nested in the response - public static func get( + public static func hashtags( session: URLSession, domain: String, - query: Mastodon.API.Trends.Query? + query: Mastodon.API.Trends.HashtagQuery? ) -> AnyPublisher, Error> { let request = Mastodon.API.get( url: trendsURL(domain: domain), @@ -44,10 +45,8 @@ extension Mastodon.API.Trends { } .eraseToAnyPublisher() } -} -extension Mastodon.API.Trends { - public struct Query: Codable, GetQuery { + public struct HashtagQuery: Codable, GetQuery { public init(limit: Int?) { self.limit = limit } @@ -61,4 +60,69 @@ extension Mastodon.API.Trends { return items } } + +} + +extension Mastodon.API.Trends { + + static func trendStatusesURL(domain: String) -> URL { + Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("trends") + .appendingPathComponent("statuses") + } + + /// Trending tags + /// + /// Tags that are being used more frequently within the past week. + /// + /// Version history: + /// 3.?.? + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/instance/trends/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - query: query + /// - Returns: `AnyPublisher` contains `Hashtags` nested in the response + + public static func statuses( + session: URLSession, + domain: String, + query: Mastodon.API.Trends.StatusQuery? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: trendStatusesURL(domain: domain), + query: query, + authorization: nil + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Status].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct StatusQuery: Codable, GetQuery { + + public let offset: Int? + public let limit: Int? // Maximum number of results to return. Defaults to 10. + + public init( + offset: Int?, + limit: Int? + ) { + self.offset = offset + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + offset.flatMap { items.append(URLQueryItem(name: "offset", value: String($0))) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + } + } diff --git a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift index db42169d..6cf95752 100644 --- a/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift +++ b/MastodonSDK/Sources/MastodonSDK/Response/Mastodon+Response+Content.swift @@ -106,6 +106,7 @@ extension Mastodon.Response { public struct Link { public let maxID: Mastodon.Entity.Status.ID? public let minID: Mastodon.Entity.Status.ID? + public let offset: Int? init(link: String) { self.maxID = { @@ -125,6 +126,15 @@ extension Mastodon.Response { let id = link[range] return String(id) }() + + self.offset = { + guard let regex = try? NSRegularExpression(pattern: "offset=([[:digit:]]+)", options: []) else { return nil } + let results = regex.matches(in: link, options: [], range: NSRange(link.startIndex..