feat: add Discovery page with posts segment

This commit is contained in:
CMK 2022-04-12 17:32:38 +08:00
parent 8a051c2177
commit af619e198a
17 changed files with 837 additions and 13 deletions

View File

@ -492,6 +492,14 @@
"clear": "Clear"
}
},
"discovery": {
"tabs": {
"posts": "Posts",
"hashtags": "Hashtags",
"news": "News",
"for_you": "For You"
}
},
"favorite": {
"title": "Your Favorites"
},

View File

@ -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 = "<group>"; };
DBD5B1F927BD013700BD6B38 /* DataSourceProvider+StatusTableViewControllerNavigateable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceProvider+StatusTableViewControllerNavigateable.swift"; sourceTree = "<group>"; };
DBD9148F25DF6D8D00903DFD /* APIService+Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Onboarding.swift"; sourceTree = "<group>"; };
DBDFF18F2805543100557A48 /* DiscoveryPostsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewController.swift; sourceTree = "<group>"; };
DBDFF1922805554900557A48 /* DiscoveryPostsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPostsViewModel.swift; sourceTree = "<group>"; };
DBDFF1942805561700557A48 /* DiscoveryPostsViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+Diffable.swift"; sourceTree = "<group>"; };
DBDFF196280556D900557A48 /* DiscoveryPostsViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewModel+State.swift"; sourceTree = "<group>"; };
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewController.swift; sourceTree = "<group>"; };
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModel.swift; sourceTree = "<group>"; };
DBDFF19D2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DiscoveryPostsViewController+DataSourceProvider.swift"; sourceTree = "<group>"; };
DBE0821425CD382600FD6BBD /* MastodonRegisterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewController.swift; sourceTree = "<group>"; };
DBE0822325CD3F1E00FD6BBD /* MastodonRegisterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonRegisterViewModel.swift; sourceTree = "<group>"; };
DBE3CDBA261C427900430CC6 /* TimelineHeaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineHeaderTableViewCell.swift; sourceTree = "<group>"; };
@ -2682,6 +2696,7 @@
2DAC9E36262FC20B0062E1A6 /* SuggestionAccount */,
DB9D6C0825E4F5A60051B173 /* Profile */,
DB9D6BEE25E4F5370051B173 /* Search */,
DBDFF1912805544800557A48 /* Discovery */,
5B90C455262599800002E742 /* Settings */,
);
path = Scene;
@ -3068,6 +3083,28 @@
path = FetchedResultsController;
sourceTree = "<group>";
};
DBDFF1912805544800557A48 /* Discovery */ = {
isa = PBXGroup;
children = (
DBDFF19828055A0900557A48 /* Posts */,
DBDFF19928055A1400557A48 /* DiscoveryViewController.swift */,
DBDFF19B28055BD600557A48 /* DiscoveryViewModel.swift */,
);
path = Discovery;
sourceTree = "<group>";
};
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 = "<group>";
};
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 */,

View File

@ -109,7 +109,7 @@
<key>MastodonIntent.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>24</integer>
<integer>23</integer>
</dict>
<key>MastodonIntents.xcscheme_^#shared#^_</key>
<dict>
@ -124,12 +124,12 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>22</integer>
<integer>24</integer>
</dict>
<key>ShareActionExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>23</integer>
<integer>22</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -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<AnyCancellable>()
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
}
}

View File

@ -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]
}
}

View File

@ -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)
}
}

View File

@ -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<AnyCancellable>()
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
}
}

View File

@ -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<StatusSection, StatusItem>()
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)
}
}

View File

@ -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 ?? "<nil>")")
}
@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)
}
}
}

View File

@ -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<AnyCancellable>()
// input
let context: AppContext
let statusFetchedResultsController: StatusFetchedResultsController
let listBatchFetchViewModel = ListBatchFetchViewModel()
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, StatusItem>?
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)
}
}

View File

@ -402,6 +402,7 @@ extension ProfileViewController {
}
extension ProfileViewController {
private func updateBarButtonInsets() {
let margin: CGFloat = {
switch traitCollection.userInterfaceIdiom {

View File

@ -36,7 +36,7 @@ final class UserTimelineViewController: UIViewController, NeedsDependency, Media
let cellFrameCache = NSCache<NSNumber, NSValue>()
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)
}
}

View File

@ -48,6 +48,13 @@ final class SearchViewController: UIViewController, NeedsDependency {
let searchBarTapPublisher = PassthroughSubject<Void, Never>()
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) {

View File

@ -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<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, Error> { response } }

View File

@ -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
}
}

View File

@ -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<Mastodon.Response.Content<[Mastodon.Entity.Tag]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Status]>, 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
}
}
}

View File

@ -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..<link.endIndex, in: link))
guard let match = results.first else { return nil }
guard let range = Range(match.range(at: 1), in: link) else { return nil }
let offset = link[range]
return Int(offset)
}()
}
}
}