diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 69fb67731..173de3507 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -293,6 +293,13 @@ DB5B549D2833A67400DEF8B2 /* FamiliarFollowersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B549C2833A67400DEF8B2 /* FamiliarFollowersViewModel.swift */; }; DB5B549F2833A72500DEF8B2 /* FamiliarFollowersViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B549E2833A72500DEF8B2 /* FamiliarFollowersViewModel+Diffable.swift */; }; DB5B54A12833A89600DEF8B2 /* FamiliarFollowersViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54A02833A89600DEF8B2 /* FamiliarFollowersViewController+DataSourceProvider.swift */; }; + DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54A22833BD1A00DEF8B2 /* UserListViewModel.swift */; }; + DB5B54A62833BE0000DEF8B2 /* UserListViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54A52833BE0000DEF8B2 /* UserListViewModel+State.swift */; }; + DB5B54A82833BFA500DEF8B2 /* FavoritedByViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54A72833BFA500DEF8B2 /* FavoritedByViewController.swift */; }; + DB5B54AB2833C12A00DEF8B2 /* RebloggedByViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54AA2833C12A00DEF8B2 /* RebloggedByViewController.swift */; }; + DB5B54AE2833C15F00DEF8B2 /* UserListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54AD2833C15F00DEF8B2 /* UserListViewModel+Diffable.swift */; }; + DB5B54B02833C24200DEF8B2 /* FavoritedByViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54AF2833C24200DEF8B2 /* FavoritedByViewController+DataSourceProvider.swift */; }; + DB5B54B22833C24B00DEF8B2 /* RebloggedByViewController+DataSourceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B54B12833C24B00DEF8B2 /* RebloggedByViewController+DataSourceProvider.swift */; }; DB5B7295273112B100081888 /* FollowingListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B7294273112B100081888 /* FollowingListViewController.swift */; }; DB5B7298273112C800081888 /* FollowingListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B7297273112C800081888 /* FollowingListViewModel.swift */; }; DB5B729C273113C200081888 /* FollowingListViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */; }; @@ -1050,6 +1057,13 @@ DB5B549C2833A67400DEF8B2 /* FamiliarFollowersViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FamiliarFollowersViewModel.swift; sourceTree = ""; }; DB5B549E2833A72500DEF8B2 /* FamiliarFollowersViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FamiliarFollowersViewModel+Diffable.swift"; sourceTree = ""; }; DB5B54A02833A89600DEF8B2 /* FamiliarFollowersViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FamiliarFollowersViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB5B54A22833BD1A00DEF8B2 /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; + DB5B54A52833BE0000DEF8B2 /* UserListViewModel+State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserListViewModel+State.swift"; sourceTree = ""; }; + DB5B54A72833BFA500DEF8B2 /* FavoritedByViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritedByViewController.swift; sourceTree = ""; }; + DB5B54AA2833C12A00DEF8B2 /* RebloggedByViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RebloggedByViewController.swift; sourceTree = ""; }; + DB5B54AD2833C15F00DEF8B2 /* UserListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserListViewModel+Diffable.swift"; sourceTree = ""; }; + DB5B54AF2833C24200DEF8B2 /* FavoritedByViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FavoritedByViewController+DataSourceProvider.swift"; sourceTree = ""; }; + DB5B54B12833C24B00DEF8B2 /* RebloggedByViewController+DataSourceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RebloggedByViewController+DataSourceProvider.swift"; sourceTree = ""; }; DB5B7294273112B100081888 /* FollowingListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewController.swift; sourceTree = ""; }; DB5B7297273112C800081888 /* FollowingListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowingListViewModel.swift; sourceTree = ""; }; DB5B729B273113C200081888 /* FollowingListViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowingListViewModel+Diffable.swift"; sourceTree = ""; }; @@ -2462,6 +2476,36 @@ path = FamiliarFollowers; sourceTree = ""; }; + DB5B54A42833BD1D00DEF8B2 /* UserLIst */ = { + isa = PBXGroup; + children = ( + DB5B54A22833BD1A00DEF8B2 /* UserListViewModel.swift */, + DB5B54AD2833C15F00DEF8B2 /* UserListViewModel+Diffable.swift */, + DB5B54A52833BE0000DEF8B2 /* UserListViewModel+State.swift */, + DB5B54A92833BFAC00DEF8B2 /* FavoritedBy */, + DB5B54AC2833C12D00DEF8B2 /* RebloggedBy */, + ); + path = UserLIst; + sourceTree = ""; + }; + DB5B54A92833BFAC00DEF8B2 /* FavoritedBy */ = { + isa = PBXGroup; + children = ( + DB5B54A72833BFA500DEF8B2 /* FavoritedByViewController.swift */, + DB5B54AF2833C24200DEF8B2 /* FavoritedByViewController+DataSourceProvider.swift */, + ); + path = FavoritedBy; + sourceTree = ""; + }; + DB5B54AC2833C12D00DEF8B2 /* RebloggedBy */ = { + isa = PBXGroup; + children = ( + DB5B54AA2833C12A00DEF8B2 /* RebloggedByViewController.swift */, + DB5B54B12833C24B00DEF8B2 /* RebloggedByViewController+DataSourceProvider.swift */, + ); + path = RebloggedBy; + sourceTree = ""; + }; DB5B7296273112B400081888 /* Following */ = { isa = PBXGroup; children = ( @@ -2964,6 +3008,7 @@ DB6B74F0272FB55400C70B6E /* Follower */, DB5B7296273112B400081888 /* Following */, DB5B549B2833A60600DEF8B2 /* FamiliarFollowers */, + DB5B54A42833BD1D00DEF8B2 /* UserLIst */, DBFEEC97279BDC6A004F81DD /* About */, DB9D6BFE25E4F5940051B173 /* ProfileViewController.swift */, DBB5255D2611F07A002F1F29 /* ProfileViewModel.swift */, @@ -3884,6 +3929,7 @@ buildActionMask = 2147483647; files = ( DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */, + DB5B54A82833BFA500DEF8B2 /* FavoritedByViewController.swift in Sources */, DBDFF19E2805703700557A48 /* DiscoveryPostsViewController+DataSourceProvider.swift in Sources */, DB6180EB26391C140018D199 /* MediaPreviewTransitionItem.swift in Sources */, DB63F74727990B0600455B82 /* DataSourceFacade+Hashtag.swift in Sources */, @@ -3894,6 +3940,7 @@ DB1D843426579931000346B3 /* TableViewControllerNavigateable.swift in Sources */, 0FAA0FDF25E0B57E0017CCDE /* WelcomeViewController.swift in Sources */, DB65C63727A2AF6C008BAC2E /* ReportItem.swift in Sources */, + DB5B54B22833C24B00DEF8B2 /* RebloggedByViewController+DataSourceProvider.swift in Sources */, 2D59819B25E4A581000FB903 /* MastodonConfirmEmailViewController.swift in Sources */, DB45FB1D25CA9D23005A8AC7 /* APIService+HomeTimeline.swift in Sources */, DBA94440265D137600C537E1 /* Mastodon+Entity+Field.swift in Sources */, @@ -3908,6 +3955,7 @@ 0FB3D2F725E4C24D00AAD544 /* MastodonPickServerViewModel.swift in Sources */, 2D61335E25C1894B00CAE157 /* APIService.swift in Sources */, 2D9DB967263A76FB007C1D71 /* BlockDomainService.swift in Sources */, + DB5B54AE2833C15F00DEF8B2 /* UserListViewModel+Diffable.swift in Sources */, DB336F43278EB1690031E64B /* MediaView+Configuration.swift in Sources */, DB66729625F9F91600D60309 /* ComposeStatusSection.swift in Sources */, DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */, @@ -3961,6 +4009,7 @@ 5DDDF1932617442700311060 /* Mastodon+Entity+Account.swift in Sources */, DB63F767279A5EB300455B82 /* NotificationTimelineViewModel.swift in Sources */, 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, + DB5B54AB2833C12A00DEF8B2 /* RebloggedByViewController.swift in Sources */, DB848E33282B62A800A302CC /* ReportResultView.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */, @@ -3983,12 +4032,14 @@ DB98EB5E27B10A7A0082E365 /* ReportSupplementaryViewModel+Diffable.swift in Sources */, DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, + DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */, DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */, DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */, DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */, DB789A0B25F9F2950071ACA0 /* ComposeViewController.swift in Sources */, DB938F0926240F3C00E5B6C1 /* RemoteThreadViewModel.swift in Sources */, + DB5B54A62833BE0000DEF8B2 /* UserListViewModel+State.swift in Sources */, DB0617ED277F02C50030EE79 /* OnboardingNavigationController.swift in Sources */, DB0617F527855AB90030EE79 /* ServerRuleSection.swift in Sources */, DB75BF1E263C1C1B00EDBF1F /* CustomScheduler.swift in Sources */, @@ -4030,6 +4081,7 @@ DBF9814A265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift in Sources */, 2D939AB525EDD8A90076FA61 /* String.swift in Sources */, DB4481B925EE289600BEFB67 /* UITableView.swift in Sources */, + DB5B54B02833C24200DEF8B2 /* FavoritedByViewController+DataSourceProvider.swift in Sources */, DBE3CDBB261C427900430CC6 /* TimelineHeaderTableViewCell.swift in Sources */, DB159C2B27A17BAC0068DC77 /* DataSourceFacade+Media.swift in Sources */, 0FAA101C25E10E760017CCDE /* UIFont.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 1039ff524..9df3040c7 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -179,6 +179,8 @@ extension SceneCoordinator { case follower(viewModel: FollowerListViewModel) case following(viewModel: FollowingListViewModel) case familiarFollowers(viewModel: FamiliarFollowersViewModel) + case rebloggedBy(viewModel: UserListViewModel) + case favoritedBy(viewModel: UserListViewModel) // setting case settings(viewModel: SettingsViewModel) @@ -450,6 +452,14 @@ private extension SceneCoordinator { let _viewController = FamiliarFollowersViewController() _viewController.viewModel = viewModel viewController = _viewController + case .rebloggedBy(let viewModel): + let _viewController = RebloggedByViewController() + _viewController.viewModel = viewModel + viewController = _viewController + case .favoritedBy(let viewModel): + let _viewController = FavoritedByViewController() + _viewController.viewModel = viewModel + viewController = _viewController case .report(let viewModel): let _viewController = ReportViewController() _viewController.viewModel = viewModel diff --git a/Mastodon/Diffiable/User/UserSection.swift b/Mastodon/Diffiable/User/UserSection.swift index a42110d7a..cb806c4e9 100644 --- a/Mastodon/Diffiable/User/UserSection.swift +++ b/Mastodon/Diffiable/User/UserSection.swift @@ -30,7 +30,9 @@ extension UserSection { configuration: Configuration ) -> UITableViewDiffableDataSource { tableView.register(UserTableViewCell.self, forCellReuseIdentifier: String(describing: UserTableViewCell.self)) - + tableView.register(TimelineBottomLoaderTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineBottomLoaderTableViewCell.self)) + tableView.register(TimelineFooterTableViewCell.self, forCellReuseIdentifier: String(describing: TimelineFooterTableViewCell.self)) + return UITableViewDiffableDataSource(tableView: tableView) { tableView, indexPath, item -> UITableViewCell? in switch item { case .user(let record): diff --git a/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift index c0e489866..4c65e1a3e 100644 --- a/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift +++ b/Mastodon/Generated/AutoGenerateTableViewDelegate.generated.swift @@ -37,3 +37,5 @@ func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith con + + diff --git a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift index f00e14840..d02edcc42 100644 --- a/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift +++ b/Mastodon/Protocol/Provider/DataSourceProvider+StatusTableViewCellDelegate.swift @@ -474,6 +474,55 @@ extension StatusTableViewCellDelegate where Self: DataSourceProvider { } +// MARK: - StatusMetricView +extension StatusTableViewCellDelegate where Self: DataSourceProvider { + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + let userListViewModel = UserListViewModel( + context: context, + kind: .rebloggedBy(status: status) + ) + await coordinator.present( + scene: .rebloggedBy(viewModel: userListViewModel), + from: self, + transition: .show + ) + } // end Task + } + + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) { + Task { + let source = DataSourceItem.Source(tableViewCell: cell, indexPath: nil) + guard let item = await item(from: source) else { + assertionFailure() + return + } + guard case let .status(status) = item else { + assertionFailure("only works for status data provider") + return + } + let userListViewModel = UserListViewModel( + context: context, + kind: .favoritedBy(status: status) + ) + await coordinator.present( + scene: .favoritedBy(viewModel: userListViewModel), + from: self, + transition: .show + ) + } // end Task + } +} + // MARK: a11y extension StatusTableViewCellDelegate where Self: DataSourceProvider { func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) { diff --git a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift index 6ca86bd72..eb0872227 100644 --- a/Mastodon/Scene/Profile/Following/FollowingListViewController.swift +++ b/Mastodon/Scene/Profile/Following/FollowingListViewController.swift @@ -84,12 +84,12 @@ extension FollowingListViewController { viewModel.domain.removeDuplicates().eraseToAnyPublisher(), viewModel.userID.removeDuplicates().eraseToAnyPublisher() ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.viewModel.stateMachine.enter(FollowingListViewModel.State.Reloading.self) - } - .store(in: &disposeBag) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.viewModel.stateMachine.enter(FollowingListViewModel.State.Reloading.self) + } + .store(in: &disposeBag) } override func viewWillAppear(_ animated: Bool) { diff --git a/Mastodon/Scene/Profile/UserLIst/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/UserLIst/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift new file mode 100644 index 000000000..437873d36 --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/FavoritedBy/FavoritedByViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// FavoritedByViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import UIKit + +extension FavoritedByViewController: 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 .user(let record): + return .user(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/UserLIst/FavoritedBy/FavoritedByViewController.swift b/Mastodon/Scene/Profile/UserLIst/FavoritedBy/FavoritedByViewController.swift new file mode 100644 index 000000000..7316be5aa --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/FavoritedBy/FavoritedByViewController.swift @@ -0,0 +1,109 @@ +// +// FavoritedByViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import os.log +import UIKit +import GameplayKit +import Combine +import MastodonLocalization + +final class FavoritedByViewController: UIViewController, NeedsDependency { + + let logger = Logger(subsystem: "FavoritedByViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: UserListViewModel! + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + 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 FavoritedByViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + #if DEBUG + switch viewModel.kind { + case .favoritedBy: break + default: assertionFailure() + } + #endif + + title = "Favorited By" + + 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, + userTableViewCellDelegate: 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 } + self.viewModel.stateMachine.enter(UserListViewModel.State.Loading.self) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + +} + +// MARK: - UITableViewDelegate +extension FavoritedByViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:FavoritedByViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + // sourcery:end +} + +// MARK: - UserTableViewCellDelegate +extension FavoritedByViewController: UserTableViewCellDelegate { } diff --git a/Mastodon/Scene/Profile/UserLIst/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift b/Mastodon/Scene/Profile/UserLIst/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift new file mode 100644 index 000000000..04d5d2596 --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/RebloggedBy/RebloggedByViewController+DataSourceProvider.swift @@ -0,0 +1,34 @@ +// +// RebloggedByViewController+DataSourceProvider.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import UIKit + +extension RebloggedByViewController: 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 .user(let record): + return .user(record: record) + default: + return nil + } + } + + @MainActor + private func indexPath(for cell: UITableViewCell) async -> IndexPath? { + return tableView.indexPath(for: cell) + } +} diff --git a/Mastodon/Scene/Profile/UserLIst/RebloggedBy/RebloggedByViewController.swift b/Mastodon/Scene/Profile/UserLIst/RebloggedBy/RebloggedByViewController.swift new file mode 100644 index 000000000..ab2087954 --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/RebloggedBy/RebloggedByViewController.swift @@ -0,0 +1,109 @@ +// +// RebloggedByViewController.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import os.log +import UIKit +import GameplayKit +import Combine +import MastodonLocalization + +final class RebloggedByViewController: UIViewController, NeedsDependency { + + let logger = Logger(subsystem: "RebloggedByViewController", category: "ViewController") + + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + + var disposeBag = Set() + var viewModel: UserListViewModel! + + lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + 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 RebloggedByViewController { + + override func viewDidLoad() { + super.viewDidLoad() + + #if DEBUG + switch viewModel.kind { + case .rebloggedBy: break + default: assertionFailure() + } + #endif + + title = "Favorited By" + + 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, + userTableViewCellDelegate: 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 } + self.viewModel.stateMachine.enter(UserListViewModel.State.Loading.self) + } + .store(in: &disposeBag) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + tableView.deselectRow(with: transitionCoordinator, animated: animated) + } + +} + +// MARK: - UITableViewDelegate +extension RebloggedByViewController: UITableViewDelegate, AutoGenerateTableViewDelegate { + // sourcery:inline:RebloggedByViewController.AutoGenerateTableViewDelegate + + // Generated using Sourcery + // DO NOT EDIT + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + aspectTableView(tableView, didSelectRowAt: indexPath) + } + + // sourcery:end +} + +// MARK: - UserTableViewCellDelegate +extension RebloggedByViewController: UserTableViewCellDelegate { } diff --git a/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift b/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift new file mode 100644 index 000000000..acd225f0c --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/UserListViewModel+Diffable.swift @@ -0,0 +1,71 @@ +// +// UserListViewModel+Diffable.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import UIKit +import MastodonAsset +import MastodonLocalization + +extension UserListViewModel { + @MainActor + func setupDiffableDataSource( + tableView: UITableView, + userTableViewCellDelegate: UserTableViewCellDelegate? + ) { + diffableDataSource = UserSection.diffableDataSource( + tableView: tableView, + context: context, + configuration: UserSection.Configuration( + userTableViewCellDelegate: userTableViewCellDelegate + ) + ) + + // workaround to append loader wrong animation issue + // set empty section to make update animation top-to-bottom style + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems([.bottomLoader], toSection: .main) + if #available(iOS 15.0, *) { + diffableDataSource?.applySnapshotUsingReloadData(snapshot, completion: nil) + } else { + // Fallback on earlier versions + diffableDataSource?.apply(snapshot, animatingDifferences: false) + } + + // trigger initial loading + stateMachine.enter(UserListViewModel.State.Reloading.self) + + userFetchedResultsController.$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 { UserItem.user(record: $0) } + snapshot.appendItems(items, toSection: .main) + + if let currentState = self.stateMachine.currentState { + switch currentState { + case is State.Initial, is State.Idle, is State.Reloading, is State.Loading, is State.Fail: + snapshot.appendItems([.bottomLoader], toSection: .main) + case is State.NoMore: + if items.isEmpty { + snapshot.appendItems([.bottomHeader(text: L10n.Scene.Search.Searching.EmptyState.noResults)], toSection: .main) + } + default: + assertionFailure() + } + } else { + snapshot.appendItems([.bottomLoader], toSection: .main) + } + + diffableDataSource.apply(snapshot, animatingDifferences: false) + } + .store(in: &disposeBag) + } +} diff --git a/Mastodon/Scene/Profile/UserLIst/UserListViewModel+State.swift b/Mastodon/Scene/Profile/UserLIst/UserListViewModel+State.swift new file mode 100644 index 000000000..90b928235 --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/UserListViewModel+State.swift @@ -0,0 +1,211 @@ +// +// UserListViewModel+State.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import os.log +import Foundation +import GameplayKit +import MastodonSDK + +extension UserListViewModel { + class State: GKState, NamingState { + + let logger = Logger(subsystem: "UserListViewModel.State", category: "StateMachine") + + let id = UUID() + + var name: String { + String(describing: Self.self) + } + + weak var viewModel: UserListViewModel? + + init(viewModel: UserListViewModel) { + self.viewModel = viewModel + } + + override func didEnter(from previousState: GKState?) { + super.didEnter(from: previousState) + let previousState = previousState as? UserListViewModel.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 UserListViewModel.State { + class Initial: UserListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + guard let _ = viewModel else { return false } + switch stateClass { + case is Reloading.Type: + return true + default: + return false + } + } + } + + class Reloading: UserListViewModel.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.userFetchedResultsController.userIDs = [] + + stateMachine.enter(Loading.self) + } + } + + class Fail: UserListViewModel.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: UserListViewModel.State { + override func isValidNextState(_ stateClass: AnyClass) -> Bool { + switch stateClass { + case is Reloading.Type, is Loading.Type: + return true + default: + return false + } + } + } + + class Loading: UserListViewModel.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) + + if previousState is Reloading { + maxID = nil + } + + guard let viewModel = viewModel, let stateMachine = stateMachine else { return } + guard let authenticationBox = viewModel.context.authenticationService.activeMastodonAuthenticationBox.value else { + stateMachine.enter(Fail.self) + return + } + + let maxID = self.maxID + + Task { + do { + let response: Mastodon.Response.Content<[Mastodon.Entity.Account]> + switch viewModel.kind { + case .favoritedBy(let status): + response = try await viewModel.context.apiService.favoritedBy( + status: status, + query: .init(maxID: maxID, limit: nil), + authenticationBox: authenticationBox + ) + case .rebloggedBy(let status): + response = try await viewModel.context.apiService.rebloggedBy( + status: status, + query: .init(maxID: maxID, limit: nil), + authenticationBox: authenticationBox + ) + } + + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch \(response.value.count) accounts") + + var hasNewAppend = false + var userIDs = viewModel.userFetchedResultsController.userIDs + for user in response.value { + guard !userIDs.contains(user.id) else { continue } + userIDs.append(user.id) + hasNewAppend = true + } + + let maxID = response.link?.maxID + + if hasNewAppend, maxID != nil { + await enter(state: Idle.self) + } else { + await enter(state: NoMore.self) + } + self.maxID = maxID + viewModel.userFetchedResultsController.userIDs = userIDs + + } catch { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): fetch following fail: \(error.localizedDescription)") + await enter(state: Fail.self) + } + } // end Task + } // end func didEnter + } + + class NoMore: UserListViewModel.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) + + guard let viewModel = viewModel else { return } + // trigger reload + viewModel.userFetchedResultsController.records = viewModel.userFetchedResultsController.records + } + } +} diff --git a/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift b/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift new file mode 100644 index 000000000..472c497f6 --- /dev/null +++ b/Mastodon/Scene/Profile/UserLIst/UserListViewModel.swift @@ -0,0 +1,66 @@ +// +// UserListViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-5-17. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import GameplayKit + +final class UserListViewModel { + + let logger = Logger(subsystem: "UserListViewModel", category: "ViewModel") + var disposeBag = Set() + + // input + let context: AppContext + let kind: Kind + let userFetchedResultsController: UserFetchedResultsController + let listBatchFetchViewModel = ListBatchFetchViewModel() + + // output + var diffableDataSource: UITableViewDiffableDataSource! + @MainActor private(set) lazy var stateMachine: GKStateMachine = { + let stateMachine = GKStateMachine(states: [ + State.Initial(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, + kind: Kind + ) { + self.context = context + self.kind = kind + self.userFetchedResultsController = UserFetchedResultsController( + managedObjectContext: context.managedObjectContext, + domain: nil, + additionalPredicate: nil + ) + // end init + + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } + .assign(to: \.domain, on: userFetchedResultsController) + .store(in: &disposeBag) + } + +} + +extension UserListViewModel { + // TODO: refactor follower and following into user list + enum Kind { + case rebloggedBy(status: ManagedObjectRecord) + case favoritedBy(status: ManagedObjectRecord) + } +} diff --git a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift index b4dbef431..034985c03 100644 --- a/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift +++ b/Mastodon/Scene/Share/View/TableviewCell/StatusTableViewCellDelegate.swift @@ -34,6 +34,8 @@ protocol StatusTableViewCellDelegate: AnyObject, AutoGenerateProtocolDelegate { func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) + func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) func tableViewCell(_ cell: UITableViewCell, statusView: StatusView, accessibilityActivate: Void) // sourcery:end } @@ -87,6 +89,14 @@ extension StatusViewDelegate where Self: StatusViewContainerTableViewCell { delegate?.tableViewCell(self, statusView: statusView, mediaGridContainerView: mediaGridContainerView, mediaSensitiveButtonDidPressed: button) } + func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) { + delegate?.tableViewCell(self, statusView: statusView, statusMetricView: statusMetricView, reblogButtonDidPressed: button) + } + + func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) { + delegate?.tableViewCell(self, statusView: statusView, statusMetricView: statusMetricView, favoriteButtonDidPressed: button) + } + func statusView(_ statusView: StatusView, accessibilityActivate: Void) { delegate?.tableViewCell(self, statusView: statusView, accessibilityActivate: accessibilityActivate) } diff --git a/Mastodon/Service/APIService/APIService+Favorite.swift b/Mastodon/Service/APIService/APIService+Favorite.swift index 20c2fe729..6ae2254e3 100644 --- a/Mastodon/Service/APIService/APIService+Favorite.swift +++ b/Mastodon/Service/APIService/APIService+Favorite.swift @@ -150,3 +150,45 @@ extension APIService { return response } // end func } + +extension APIService { + func favoritedBy( + status: ManagedObjectRecord, + query: Mastodon.API.Statuses.FavoriteByQuery, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + let managedObjectContext = backgroundManagedObjectContext + let _statusID: Status.ID? = try? await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return nil } + let status = _status.reblog ?? _status + return status.id + } + guard let statusID = _statusID else { + throw APIError.implicit(.badRequest) + } + + let response = try await Mastodon.API.Statuses.favoriteBy( + session: session, + domain: authenticationBox.domain, + statusID: statusID, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: .init( + domain: authenticationBox.domain, + entity: entity, + cache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response + } // end func +} diff --git a/Mastodon/Service/APIService/APIService+Reblog.swift b/Mastodon/Service/APIService/APIService+Reblog.swift index c8dde08bb..2542636c4 100644 --- a/Mastodon/Service/APIService/APIService+Reblog.swift +++ b/Mastodon/Service/APIService/APIService+Reblog.swift @@ -106,3 +106,45 @@ extension APIService { } } + +extension APIService { + func rebloggedBy( + status: ManagedObjectRecord, + query: Mastodon.API.Statuses.RebloggedByQuery, + authenticationBox: MastodonAuthenticationBox + ) async throws -> Mastodon.Response.Content<[Mastodon.Entity.Account]> { + let managedObjectContext = backgroundManagedObjectContext + let _statusID: Status.ID? = try? await managedObjectContext.perform { + guard let _status = status.object(in: managedObjectContext) else { return nil } + let status = _status.reblog ?? _status + return status.id + } + guard let statusID = _statusID else { + throw APIError.implicit(.badRequest) + } + + let response = try await Mastodon.API.Statuses.rebloggedBy( + session: session, + domain: authenticationBox.domain, + statusID: statusID, + query: query, + authorization: authenticationBox.userAuthorization + ).singleOutput() + + try await managedObjectContext.performChanges { + for entity in response.value { + _ = Persistence.MastodonUser.createOrMerge( + in: managedObjectContext, + context: .init( + domain: authenticationBox.domain, + entity: entity, + cache: nil, + networkDate: response.networkDate + ) + ) + } // end for … in + } + + return response + } // end func +} diff --git a/Mastodon/Service/APIService/APIService+Status.swift b/Mastodon/Service/APIService/APIService+Status.swift index 3d764663c..cf6974fbd 100644 --- a/Mastodon/Service/APIService/APIService+Status.swift +++ b/Mastodon/Service/APIService/APIService+Status.swift @@ -22,7 +22,7 @@ extension APIService { let domain = authenticationBox.domain let authorization = authenticationBox.userAuthorization - let response = try await Mastodon.API.Statuses.status( + let response = try await Mastodon.API.Statuses.status( session: session, domain: domain, statusID: statusID, diff --git a/Mastodon/Service/APIService/APIService.swift b/Mastodon/Service/APIService/APIService.swift index d9ae017d9..dabdadfea 100644 --- a/Mastodon/Service/APIService/APIService.swift +++ b/Mastodon/Service/APIService/APIService.swift @@ -6,7 +6,7 @@ // import os.log -import Foundation +import UIKit import Combine import CoreData import CoreDataStack @@ -38,6 +38,8 @@ final class APIService { NetworkActivityIndicatorManager.shared.isEnabled = true NetworkActivityIndicatorManager.shared.startDelay = 0.2 NetworkActivityIndicatorManager.shared.completionDelay = 0.5 + + UIImageView.af.sharedImageDownloader = ImageDownloader(downloadPrioritization: .lifo) } } diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift new file mode 100644 index 000000000..5d5747d13 --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+FavoriteBy.swift @@ -0,0 +1,84 @@ +// +// Mastodon+API+Statuses+FavoriteBy.swift +// +// +// Created by MainasuK on 2022-5-17. +// + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + + private static func favoriteByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("statuses") + .appendingPathComponent(statusID) + .appendingPathComponent("favourited_by") // use same word from api + } + + /// Favourited by + /// + /// View who favourited a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.5.2 + /// # Last Update + /// 2022/5/17 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func favoriteBy( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Poll.ID, + query: FavoriteByQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: favoriteByEndpointURL(domain: domain, statusID: statusID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct FavoriteByQuery: Codable, GetQuery { + + public let maxID: String? + public let limit: Int? // default 40 + + enum CodingKeys: String, CodingKey { + case maxID = "max_id" + case limit + } + + public init( + maxID: String?, + limit: Int? + ) { + self.maxID = maxID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $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/API/Mastodon+API+Statuses+RebloggedBy.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+RebloggedBy.swift new file mode 100644 index 000000000..db917a72b --- /dev/null +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Statuses+RebloggedBy.swift @@ -0,0 +1,84 @@ +// +// Mastodon+API+Statuses+RebloggedBy.swift +// +// +// Created by MainasuK on 2022-5-17. +// + +import Foundation +import Combine + +extension Mastodon.API.Statuses { + + private static func rebloggedByEndpointURL(domain: String, statusID: Mastodon.Entity.Status.ID) -> URL { + return Mastodon.API.endpointURL(domain: domain) + .appendingPathComponent("statuses") + .appendingPathComponent(statusID) + .appendingPathComponent("reblogged_by") + } + + /// Boosted by + /// + /// View who boosted a given status. + /// + /// - Since: 0.0.0 + /// - Version: 3.5.2 + /// # Last Update + /// 2022/5/17 + /// # Reference + /// [Document](https://docs.joinmastodon.org/methods/statuses/) + /// - Parameters: + /// - session: `URLSession` + /// - domain: Mastodon instance domain. e.g. "example.com" + /// - statusID: id for status + /// - authorization: User token. Could be nil if status is public + /// - Returns: `AnyPublisher` contains `Status` nested in the response + public static func rebloggedBy( + session: URLSession, + domain: String, + statusID: Mastodon.Entity.Poll.ID, + query: RebloggedByQuery, + authorization: Mastodon.API.OAuth.Authorization? + ) -> AnyPublisher, Error> { + let request = Mastodon.API.get( + url: rebloggedByEndpointURL(domain: domain, statusID: statusID), + query: query, + authorization: authorization + ) + return session.dataTaskPublisher(for: request) + .tryMap { data, response in + let value = try Mastodon.API.decode(type: [Mastodon.Entity.Account].self, from: data, response: response) + return Mastodon.Response.Content(value: value, response: response) + } + .eraseToAnyPublisher() + } + + public struct RebloggedByQuery: Codable, GetQuery { + + public let maxID: String? + public let limit: Int? // default 40 + + enum CodingKeys: String, CodingKey { + case maxID = "max_id" + case limit + } + + public init( + maxID: String?, + limit: Int? + ) { + self.maxID = maxID + self.limit = limit + } + + var queryItems: [URLQueryItem]? { + var items: [URLQueryItem] = [] + maxID.flatMap { items.append(URLQueryItem(name: "max_id", value: $0)) } + limit.flatMap { items.append(URLQueryItem(name: "limit", value: String($0))) } + guard !items.isEmpty else { return nil } + return items + } + + } + +} diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift index 8827eadae..8714c7cd0 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/NotificationView.swift @@ -434,6 +434,14 @@ extension NotificationView: StatusViewDelegate { assertionFailure() } + public func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) { + assertionFailure() + } + + public func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) { + assertionFailure() + } + public func statusView(_ statusView: StatusView, accessibilityActivate: Void) { assertionFailure() } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift index 2a770f302..d5f6a0709 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusMetricView.swift @@ -5,10 +5,20 @@ // Created by MainasuK on 2022-1-17. // +import os.log import UIKit +protocol StatusMetricViewDelegate: AnyObject { + func statusMetricView(_ statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) + func statusMetricView(_ statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) +} + public final class StatusMetricView: UIView { + let logger = Logger(subsystem: "StatusMetricView", category: "View") + + weak var delegate: StatusMetricViewDelegate? + // container public let containerStackView: UIStackView = { let stackView = UIStackView() @@ -88,9 +98,21 @@ extension StatusMetricView { favoriteButton.setContentHuggingPriority(.required - 1, for: .horizontal) favoriteButton.setContentCompressionResistancePriority(.required - 1, for: .horizontal) - // TODO: - reblogButton.isAccessibilityElement = false - favoriteButton.isAccessibilityElement = false + reblogButton.addTarget(self, action: #selector(StatusMetricView.reblogButtonDidPressed(_:)), for: .touchUpInside) + favoriteButton.addTarget(self, action: #selector(StatusMetricView.favoriteButtonDidPressed(_:)), for: .touchUpInside) + } +} + +extension StatusMetricView { + + @objc private func reblogButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusMetricView(self, reblogButtonDidPressed: sender) + } + + @objc private func favoriteButtonDidPressed(_ sender: UIButton) { + logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public)") + delegate?.statusMetricView(self, favoriteButtonDidPressed: sender) } } diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift index 6d105a760..bdcbd473e 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView.swift @@ -25,6 +25,8 @@ public protocol StatusViewDelegate: AnyObject { func statusView(_ statusView: StatusView, menuButton button: UIButton, didSelectAction action: MastodonMenu.Action) func statusView(_ statusView: StatusView, spoilerOverlayViewDidPressed overlayView: SpoilerOverlayView) func statusView(_ statusView: StatusView, mediaGridContainerView: MediaGridContainerView, mediaSensitiveButtonDidPressed button: UIButton) + func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) + func statusView(_ statusView: StatusView, statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) // a11y func statusView(_ statusView: StatusView, accessibilityActivate: Void) @@ -318,8 +320,12 @@ extension StatusView { ]) pollTableView.delegate = self pollVoteButton.addTarget(self, action: #selector(StatusView.pollVoteButtonDidPressed(_:)), for: .touchUpInside) + // toolbar actionToolbarContainer.delegate = self + + // statusMetricView + statusMetricView.delegate = self } } @@ -802,6 +808,17 @@ extension StatusView: ActionToolbarContainerDelegate { } } +// MARK: - StatusMetricViewDelegate +extension StatusView: StatusMetricViewDelegate { + func statusMetricView(_ statusMetricView: StatusMetricView, reblogButtonDidPressed button: UIButton) { + delegate?.statusView(self, statusMetricView: statusMetricView, reblogButtonDidPressed: button) + } + + func statusMetricView(_ statusMetricView: StatusMetricView, favoriteButtonDidPressed button: UIButton) { + delegate?.statusView(self, statusMetricView: statusMetricView, favoriteButtonDidPressed: button) + } +} + // MARK: - MastodonMenuDelegate extension StatusView: MastodonMenuDelegate { public func menuAction(_ action: MastodonMenu.Action) {