From 503fcfab2a2c6ac30c00dc1d107e4d183aaa1124 Mon Sep 17 00:00:00 2001 From: CMK Date: Fri, 13 May 2022 17:23:35 +0800 Subject: [PATCH] chore: [WIP] refactor profile UI --- Documentation/Acknowledgments.md | 1 + Mastodon.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 9 + Mastodon/Protocol/ScrollViewContainer.swift | 4 +- .../DiscoveryCommunityViewController.swift | 4 +- .../Discovery/DiscoveryViewController.swift | 4 +- .../DiscoveryForYouViewController.swift | 4 +- .../DiscoveryHashtagsViewController.swift | 4 +- .../News/DiscoveryNewsViewController.swift | 4 +- .../Posts/DiscoveryPostsViewController.swift | 4 +- .../HomeTimelineViewController.swift | 6 +- .../NotificationTimelineViewController.swift | 4 +- .../NotificationViewController.swift | 4 +- .../About/ProfileAboutViewController.swift | 17 +- .../Header/ProfileHeaderViewController.swift | 190 +- .../Scene/Profile/ProfileViewController.swift | 2021 +++++++++-------- Mastodon/Scene/Profile/ProfileViewModel.swift | 452 ++-- .../Paging/ProfilePagingViewController.swift | 74 +- .../Paging/ProfilePagingViewModel.swift | 71 +- .../Timeline/UserTimelineViewController.swift | 18 +- .../UserTimelineViewModel+Diffable.swift | 19 +- .../UserTimelineViewModel+State.swift | 4 +- .../Timeline/UserTimelineViewModel.swift | 19 +- .../{ => Helper}/DateTimeProvider.swift | 0 .../MastodonUI/Model/UserIdentifier.swift | 28 + .../Sources/MastodonUI/UserIdentifier.swift | 14 - Podfile | 1 + Podfile.lock | 6 +- 28 files changed, 1586 insertions(+), 1417 deletions(-) rename MastodonSDK/Sources/MastodonUI/{ => Helper}/DateTimeProvider.swift (100%) create mode 100644 MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift delete mode 100644 MastodonSDK/Sources/MastodonUI/UserIdentifier.swift diff --git a/Documentation/Acknowledgments.md b/Documentation/Acknowledgments.md index eab4b93f5..ff6dbc081 100644 --- a/Documentation/Acknowledgments.md +++ b/Documentation/Acknowledgments.md @@ -27,6 +27,7 @@ - [SwiftUI-Introspect](https://github.com/siteline/SwiftUI-Introspect) - [SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON) - [Tabman](https://github.com/uias/Tabman) +- [TabBarPager](https://github.com/TwidereProject/TabBarPager) - [TwidereX-iOS](https://github.com/TwidereProject/TwidereX-iOS) - [ThirdPartyMailer](https://github.com/vtourraine/ThirdPartyMailer) - [TOCropViewController](https://github.com/TimOliver/TOCropViewController) diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 2187fa0c5..f0bc7406a 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -267,6 +267,7 @@ DB47AB6227CF752B00CD73C7 /* MastodonUISnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB47AB6127CF752B00CD73C7 /* MastodonUISnapshotTests.swift */; }; DB482A3F261331E8008AE74C /* UserTimelineViewModel+State.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A3E261331E8008AE74C /* UserTimelineViewModel+State.swift */; }; DB482A4B261340A7008AE74C /* APIService+UserTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB482A4A261340A7008AE74C /* APIService+UserTimeline.swift */; }; + DB486C0F282E41F200F69423 /* TabBarPager in Frameworks */ = {isa = PBXBuildFile; productRef = DB486C0E282E41F200F69423 /* TabBarPager */; }; DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4924E126312AB200E9DB22 /* NotificationService.swift */; }; DB4932B126F1FB5300EF46D4 /* WizardCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4932B026F1FB5300EF46D4 /* WizardCardView.swift */; }; DB4932B726F30F0700EF46D4 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F20223826146553000C64BF /* Array.swift */; }; @@ -1429,6 +1430,7 @@ DB6804862637CD4C00430867 /* AppShared.framework in Frameworks */, DBF96326262EC0A6001D8D25 /* AuthenticationServices.framework in Frameworks */, DBAC6483267D0B21007FE9FD /* DifferenceKit in Frameworks */, + DB486C0F282E41F200F69423 /* TabBarPager in Frameworks */, DB552D4F26BBD10C00E481F6 /* OrderedCollections in Frameworks */, 2D61336925C18A4F00CAE157 /* AlamofireNetworkActivityIndicator in Frameworks */, DBAC64A1267E6D02007FE9FD /* Fuzi in Frameworks */, @@ -3448,6 +3450,7 @@ DBA5A52E26F07ED800CACBAA /* PanModal */, DB3EA911281BBEA800598866 /* AlamofireImage */, DB3EA913281BBEA800598866 /* Alamofire */, + DB486C0E282E41F200F69423 /* TabBarPager */, ); productName = Mastodon; productReference = DB427DD225BAA00100D1B89D /* Mastodon.app */; @@ -3670,6 +3673,7 @@ DBA5A52D26F07ED800CACBAA /* XCRemoteSwiftPackageReference "PanModal" */, DB8D8E2D28192EED009FD90F /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */, + DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -5795,6 +5799,14 @@ minimumVersion = 5.4.0; }; }; + DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/TwidereProject/TabBarPager.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.1.0; + }; + }; DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-collections.git"; @@ -5959,6 +5971,11 @@ package = DB3EA8F6281BBA4C00598866 /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; + DB486C0E282E41F200F69423 /* TabBarPager */ = { + isa = XCSwiftPackageProductDependency; + package = DB486C0D282E41F200F69423 /* XCRemoteSwiftPackageReference "TabBarPager" */; + productName = TabBarPager; + }; DB552D4E26BBD10C00E481F6 /* OrderedCollections */ = { isa = XCSwiftPackageProductDependency; package = DB552D4D26BBD10C00E481F6 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index cca51e911..517917535 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -208,6 +208,15 @@ "version": "5.0.1" } }, + { + "package": "TabBarPager", + "repositoryURL": "https://github.com/TwidereProject/TabBarPager.git", + "state": { + "branch": null, + "revision": "488aa66d157a648901b61721212c0dec23d27ee5", + "version": "0.1.0" + } + }, { "package": "Tabman", "repositoryURL": "https://github.com/uias/Tabman", diff --git a/Mastodon/Protocol/ScrollViewContainer.swift b/Mastodon/Protocol/ScrollViewContainer.swift index c9f10ba3a..ae79d0e0f 100644 --- a/Mastodon/Protocol/ScrollViewContainer.swift +++ b/Mastodon/Protocol/ScrollViewContainer.swift @@ -8,12 +8,12 @@ import UIKit protocol ScrollViewContainer: UIViewController { - var scrollView: UIScrollView? { get } + var scrollView: UIScrollView { get } func scrollToTop(animated: Bool) } extension ScrollViewContainer { func scrollToTop(animated: Bool) { - scrollView?.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) + scrollView.scrollRectToVisible(CGRect(origin: .zero, size: CGSize(width: 1, height: 1)), animated: animated) } } diff --git a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift index 4cc32c250..524805ad7 100644 --- a/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift +++ b/Mastodon/Scene/Discovery/Community/DiscoveryCommunityViewController.swift @@ -148,9 +148,7 @@ extension DiscoveryCommunityViewController: StatusTableViewCellDelegate { } // MARK: ScrollViewContainer extension DiscoveryCommunityViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } extension DiscoveryCommunityViewController { diff --git a/Mastodon/Scene/Discovery/DiscoveryViewController.swift b/Mastodon/Scene/Discovery/DiscoveryViewController.swift index 1803f687a..d94e6e592 100644 --- a/Mastodon/Scene/Discovery/DiscoveryViewController.swift +++ b/Mastodon/Scene/Discovery/DiscoveryViewController.swift @@ -130,8 +130,8 @@ extension DiscoveryViewController { // MARK: - ScrollViewContainer extension DiscoveryViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - return (currentViewController as? ScrollViewContainer)?.scrollView + var scrollView: UIScrollView { + return (currentViewController as? ScrollViewContainer)?.scrollView ?? UIScrollView() } } diff --git a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift index c7b8fb7f5..9f6368e63 100644 --- a/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift +++ b/Mastodon/Scene/Discovery/ForYou/DiscoveryForYouViewController.swift @@ -168,8 +168,6 @@ extension DiscoveryForYouViewController: ProfileCardTableViewCellDelegate { // MARK: ScrollViewContainer extension DiscoveryForYouViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } diff --git a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift index 6e6d96924..20ad408a2 100644 --- a/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift +++ b/Mastodon/Scene/Discovery/Hashtags/DiscoveryHashtagsViewController.swift @@ -127,9 +127,7 @@ extension DiscoveryHashtagsViewController: UITableViewDelegate { // MARK: ScrollViewContainer extension DiscoveryHashtagsViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } extension DiscoveryHashtagsViewController { diff --git a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift index f73602ae4..d2415145c 100644 --- a/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift +++ b/Mastodon/Scene/Discovery/News/DiscoveryNewsViewController.swift @@ -127,9 +127,7 @@ extension DiscoveryNewsViewController: UITableViewDelegate { // MARK: ScrollViewContainer extension DiscoveryNewsViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } extension DiscoveryNewsViewController { diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift index a1d5b5e76..537ca1c58 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewController.swift @@ -160,9 +160,7 @@ extension DiscoveryPostsViewController: StatusTableViewCellDelegate { } // MARK: ScrollViewContainer extension DiscoveryPostsViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - tableView - } + var scrollView: UIScrollView { tableView } } // MARK: - DiscoveryIntroBannerViewDelegate diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index 64d3d5941..871d47c28 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -537,13 +537,9 @@ extension HomeTimelineViewController: TimelineMiddleLoaderTableViewCellDelegate // MARK: - ScrollViewContainer extension HomeTimelineViewController: ScrollViewContainer { - var scrollView: UIScrollView? { return tableView } + var scrollView: UIScrollView { return tableView } func scrollToTop(animated: Bool) { - guard let scrollView = scrollView else { - return - } - if scrollView.contentOffset.y < scrollView.frame.height, viewModel.loadLatestStateMachine.canEnterState(HomeTimelineViewModel.LoadLatestState.Loading.self), (scrollView.contentOffset.y + scrollView.adjustedContentInset.top) == 0.0, diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift index 16130251c..300b9165d 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewController.swift @@ -203,9 +203,7 @@ extension NotificationTimelineViewController: NotificationTableViewCellDelegate // MARK: - ScrollViewContainer extension NotificationTimelineViewController: ScrollViewContainer { - - var scrollView: UIScrollView? { tableView } - + var scrollView: UIScrollView { tableView } } extension NotificationTimelineViewController { diff --git a/Mastodon/Scene/Notification/NotificationViewController.swift b/Mastodon/Scene/Notification/NotificationViewController.swift index dd4d97047..0935c9967 100644 --- a/Mastodon/Scene/Notification/NotificationViewController.swift +++ b/Mastodon/Scene/Notification/NotificationViewController.swift @@ -170,9 +170,9 @@ extension NotificationViewController { // MARK: - ScrollViewContainer extension NotificationViewController: ScrollViewContainer { - var scrollView: UIScrollView? { + var scrollView: UIScrollView { guard let viewController = currentViewController as? NotificationTimelineViewController else { - return nil + return UIScrollView() } return viewController.scrollView } diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift index 4879be744..47385813d 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift @@ -9,6 +9,9 @@ import os.log import UIKit import Combine import MetaTextKit +import MastodonLocalization +import TabBarPager +import XLPagerTabStrip protocol ProfileAboutViewControllerDelegate: AnyObject { func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) @@ -162,7 +165,17 @@ extension ProfileAboutViewController: ProfileFieldEditCollectionViewCellDelegate // MARK: - ScrollViewContainer extension ProfileAboutViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - collectionView + var scrollView: UIScrollView { collectionView } +} + +// MARK: - TabBarPage +extension ProfileAboutViewController: TabBarPage { + var pageScrollView: UIScrollView { scrollView } +} + +// MARK: - IndicatorInfoProvider +extension ProfileAboutViewController: IndicatorInfoProvider { + func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { + return IndicatorInfo(title: L10n.Scene.Profile.SegmentedControl.about) } } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index de6ad5415..984ba44fb 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -15,7 +15,7 @@ import MastodonMeta import MetaTextKit import MastodonAsset import MastodonLocalization -import Tabman +import TabBarPager protocol ProfileHeaderViewControllerDelegate: AnyObject { func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) @@ -28,6 +28,7 @@ final class ProfileHeaderViewController: UIViewController { var disposeBag = Set() weak var delegate: ProfileHeaderViewControllerDelegate? + weak var headerDelegate: TabBarPagerHeaderDelegate? var viewModel: ProfileHeaderViewModel! @@ -44,35 +45,35 @@ final class ProfileHeaderViewController: UIViewController { let profileHeaderView = ProfileHeaderView() - let buttonBar: TMBar.ButtonBar = { - let buttonBar = TMBar.ButtonBar() - buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color - buttonBar.backgroundView.style = .clear - buttonBar.layout.contentInset = .zero - return buttonBar - }() +// let buttonBar: TMBar.ButtonBar = { +// let buttonBar = TMBar.ButtonBar() +// buttonBar.indicator.backgroundColor = Asset.Colors.Label.primary.color +// buttonBar.backgroundView.style = .clear +// buttonBar.layout.contentInset = .zero +// return buttonBar +// }() - func customizeButtonBarAppearance() { - // The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors - // Needs trigger update when `userInterfaceStyle` chagnes - let userInterfaceStyle = traitCollection.userInterfaceStyle - buttonBar.buttons.customize { button in - switch userInterfaceStyle { - case .dark: - // Asset.Colors.Label.primary.color - button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0) - // Asset.Colors.Label.secondary.color - button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0) - default: - // Asset.Colors.Label.primary.color - button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0) - // Asset.Colors.Label.secondary.color - button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6) - } - - button.backgroundColor = .clear - } - } +// func customizeButtonBarAppearance() { +// // The implmention use CATextlayer. Adapt for Dark Mode without dynamic colors +// // Needs trigger update when `userInterfaceStyle` chagnes +// let userInterfaceStyle = traitCollection.userInterfaceStyle +// buttonBar.buttons.customize { button in +// switch userInterfaceStyle { +// case .dark: +// // Asset.Colors.Label.primary.color +// button.selectedTintColor = UIColor(red: 238.0/255.0, green: 238.0/255.0, blue: 238.0/255.0, alpha: 1.0) +// // Asset.Colors.Label.secondary.color +// button.tintColor = UIColor(red: 151.0/255.0, green: 157.0/255.0, blue: 173.0/255.0, alpha: 1.0) +// default: +// // Asset.Colors.Label.primary.color +// button.selectedTintColor = UIColor(red: 40.0/255.0, green: 44.0/255.0, blue: 55.0/255.0, alpha: 1.0) +// // Asset.Colors.Label.secondary.color +// button.tintColor = UIColor(red: 60.0/255.0, green: 60.0/255.0, blue: 67.0/255.0, alpha: 0.6) +// } +// +// button.backgroundColor = .clear +// } +// } private var isBannerPinned = false private var bottomShadowAlpha: CGFloat = 0.0 @@ -113,7 +114,7 @@ extension ProfileHeaderViewController { override func viewDidLoad() { super.viewDidLoad() - customizeButtonBarAppearance() +// customizeButtonBarAppearance() view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor ThemeService.shared.currentTheme @@ -130,6 +131,7 @@ extension ProfileHeaderViewController { profileHeaderView.topAnchor.constraint(equalTo: view.topAnchor), profileHeaderView.leadingAnchor.constraint(equalTo: view.leadingAnchor), profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor), ]) profileHeaderView.preservesSuperviewLayoutMargins = true @@ -262,14 +264,19 @@ extension ProfileHeaderViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - delegate?.profileHeaderViewController(self, viewLayoutDidUpdate: view) - setupBottomShadow() + switch UIApplication.shared.applicationState { + case .active: + headerDelegate?.viewLayoutDidUpdate(self) + setupBottomShadow() + default: + break + } } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - customizeButtonBarAppearance() +// customizeButtonBarAppearance() } } @@ -338,63 +345,63 @@ extension ProfileHeaderViewController { } } - func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) { - // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) - updateHeaderBottomShadow(progress: progress) - - let bannerImageView = profileHeaderView.bannerImageView - guard bannerImageView.bounds != .zero else { - // wait layout finish - return - } - - let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) - let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height - - // scroll from bottom to top: 1 -> 2 -> 3 - if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { - // 1 - // banner top pin to window top and expand - bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y - bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height - } else if bannerContainerBottomOffset < containerSafeAreaInset.top { - // 3 - // banner bottom pin to navigation bar bottom and - // the `progress` growth to 1 then segmented control pin to top - bannerImageView.frame.origin.y = -containerSafeAreaInset.top - let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) - bannerImageView.frame.size.height = bannerImageHeight - } else { - // 2 - // banner move with scrolling from bottom to top until the - // banner bottom higher than navigation bar bottom - bannerImageView.frame.origin.y = -containerSafeAreaInset.top - bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top - } - - // set title view offset - let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) - let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y - let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset - let transformY = max(0, titleViewContentOffset) - titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) - viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height - - if viewModel.viewDidAppear.value { - viewModel.isTitleViewContentOffsetSet.value = true - } - - // set avatar fade - if progress > 0 { - setProfileAvatar(alpha: 0) - } else if progress > -abs(throttle) { - // y = -(1/0.8T)x - let alpha = -1 / abs(0.8 * throttle) * progress - setProfileAvatar(alpha: alpha) - } else { - setProfileAvatar(alpha: 1) - } - } +// func updateHeaderScrollProgress(_ progress: CGFloat, throttle: CGFloat) { +// // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: progress: %.2f", ((#file as NSString).lastPathComponent), #line, #function, progress) +// updateHeaderBottomShadow(progress: progress) +// +// let bannerImageView = profileHeaderView.bannerImageView +// guard bannerImageView.bounds != .zero else { +// // wait layout finish +// return +// } +// +// let bannerContainerInWindow = profileHeaderView.convert(profileHeaderView.bannerContainerView.frame, to: nil) +// let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height +// +// // scroll from bottom to top: 1 -> 2 -> 3 +// if bannerContainerInWindow.origin.y > containerSafeAreaInset.top { +// // 1 +// // banner top pin to window top and expand +// bannerImageView.frame.origin.y = -bannerContainerInWindow.origin.y +// bannerImageView.frame.size.height = bannerContainerInWindow.origin.y + bannerContainerInWindow.size.height +// } else if bannerContainerBottomOffset < containerSafeAreaInset.top { +// // 3 +// // banner bottom pin to navigation bar bottom and +// // the `progress` growth to 1 then segmented control pin to top +// bannerImageView.frame.origin.y = -containerSafeAreaInset.top +// let bannerImageHeight = bannerContainerInWindow.size.height + containerSafeAreaInset.top + (containerSafeAreaInset.top - bannerContainerBottomOffset) +// bannerImageView.frame.size.height = bannerImageHeight +// } else { +// // 2 +// // banner move with scrolling from bottom to top until the +// // banner bottom higher than navigation bar bottom +// bannerImageView.frame.origin.y = -containerSafeAreaInset.top +// bannerImageView.frame.size.height = bannerContainerInWindow.size.height + containerSafeAreaInset.top +// } +// +// // set title view offset +// let nameTextFieldInWindow = profileHeaderView.nameTextField.superview!.convert(profileHeaderView.nameTextField.frame, to: nil) +// let nameTextFieldTopToNavigationBarBottomOffset = containerSafeAreaInset.top - nameTextFieldInWindow.origin.y +// let titleViewContentOffset: CGFloat = titleView.frame.height - nameTextFieldTopToNavigationBarBottomOffset +// let transformY = max(0, titleViewContentOffset) +// titleView.containerView.transform = CGAffineTransform(translationX: 0, y: transformY) +// viewModel.isTitleViewDisplaying.value = transformY < titleView.containerView.frame.height +// +// if viewModel.viewDidAppear.value { +// viewModel.isTitleViewContentOffsetSet.value = true +// } +// +// // set avatar fade +// if progress > 0 { +// setProfileAvatar(alpha: 0) +// } else if progress > -abs(throttle) { +// // y = -(1/0.8T)x +// let alpha = -1 / abs(0.8 * throttle) * progress +// setProfileAvatar(alpha: alpha) +// } else { +// setProfileAvatar(alpha: 1) +// } +// } private func setProfileAvatar(alpha: CGFloat) { profileHeaderView.avatarImageViewBackgroundView.alpha = alpha @@ -488,3 +495,6 @@ extension ProfileHeaderViewController: CropViewControllerDelegate { cropViewController.dismiss(animated: true, completion: nil) } } + +// MARK: - TabBarPagerHeader +extension ProfileHeaderViewController: TabBarPagerHeader { } diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index 4c3f9820a..ec43f4d4a 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -14,8 +14,8 @@ import MastodonAsset import MastodonLocalization import MastodonUI import CoreDataStack -import Tabman -import Pageboy +import TabBarPager +import XLPagerTabStrip protocol ProfileViewModelEditable { func isEdited() -> Bool @@ -41,7 +41,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.ObjectsAndTools.gear.image.withRenderingMode(.alwaysTemplate), @@ -52,7 +52,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.Arrow.squareAndArrowUp.image.withRenderingMode(.alwaysTemplate), @@ -63,7 +63,7 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate), @@ -74,53 +74,77 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) barButtonItem.tintColor = .white return barButtonItem }() - + let moreMenuBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) barButtonItem.tintColor = .white return barButtonItem }() - + let refreshControl: UIRefreshControl = { let refreshControl = UIRefreshControl() refreshControl.tintColor = .white return refreshControl }() - let containerScrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.scrollsToTop = false - scrollView.showsVerticalScrollIndicator = false - scrollView.preservesSuperviewLayoutMargins = true - scrollView.delaysContentTouches = false - return scrollView - }() - - let overlayScrollView: UIScrollView = { - let scrollView = UIScrollView() - scrollView.showsVerticalScrollIndicator = false - scrollView.backgroundColor = .clear - scrollView.delaysContentTouches = false - return scrollView - }() - - private(set) lazy var profileSegmentedViewController = ProfileSegmentedViewController() + private(set) lazy var tabBarPagerController = TabBarPagerController() + private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { let viewController = ProfileHeaderViewController() viewController.viewModel = ProfileHeaderViewModel(context: context) return viewController }() - private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! - - private var contentOffsets: [Int: CGFloat] = [:] - var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + private(set) lazy var profilePagingViewController: ProfilePagingViewController = { + let profilePagingViewController = ProfilePagingViewController() + profilePagingViewController.viewModel = { + let profilePagingViewModel = ProfilePagingViewModel( + postsUserTimelineViewModel: viewModel.postsUserTimelineViewModel, + repliesUserTimelineViewModel: viewModel.repliesUserTimelineViewModel, + mediaUserTimelineViewModel: viewModel.mediaUserTimelineViewModel, + profileAboutViewModel: viewModel.profileAboutViewModel + ) + profilePagingViewModel.viewControllers.forEach { viewController in + if let viewController = viewController as? NeedsDependency { + viewController.context = context + viewController.coordinator = coordinator + } + } + return profilePagingViewModel + }() + return profilePagingViewController + }() + +// let containerScrollView: UIScrollView = { +// let scrollView = UIScrollView() +// scrollView.scrollsToTop = false +// scrollView.showsVerticalScrollIndicator = false +// scrollView.preservesSuperviewLayoutMargins = true +// scrollView.delaysContentTouches = false +// return scrollView +// }() +// +// let overlayScrollView: UIScrollView = { +// let scrollView = UIScrollView() +// scrollView.showsVerticalScrollIndicator = false +// scrollView.backgroundColor = .clear +// scrollView.delaysContentTouches = false +// return scrollView +// }() +// + +//// private var profileBannerImageViewLayoutConstraint: NSLayoutConstraint! +// private(set) lazy var profileSegmentedViewController = ProfilePagingViewController() +// +// private var contentOffsets: [Int: CGFloat] = [:] +// var currentPostTimelineTableViewContentSizeObservation: NSKeyValueObservation? + // title view nested in header var titleView: DoubleTitleLabelNavigationBarTitleView { profileHeaderViewController.titleView @@ -132,43 +156,43 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi } -extension ProfileViewController { - - func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation { - updateOverlayScrollViewContentSize(scrollView: scrollView) - return scrollView.observe(\.contentSize, options: .new) { scrollView, change in - self.updateOverlayScrollViewContentSize(scrollView: scrollView) - } - } - - func updateOverlayScrollViewContentSize(scrollView: UIScrollView) { - let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom) - let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height - let contentSize = CGSize( - width: self.containerScrollView.contentSize.width, - height: bottomPageHeight + headerViewHeight - ) - self.overlayScrollView.contentSize = contentSize - // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) - } - -} +//extension ProfileViewController { +// +// func observeTableViewContentSize(scrollView: UIScrollView) -> NSKeyValueObservation { +// updateOverlayScrollViewContentSize(scrollView: scrollView) +// return scrollView.observe(\.contentSize, options: .new) { scrollView, change in +// self.updateOverlayScrollViewContentSize(scrollView: scrollView) +// } +// } +// +// func updateOverlayScrollViewContentSize(scrollView: UIScrollView) { +// let bottomPageHeight = max(scrollView.contentSize.height, self.containerScrollView.frame.height - ProfileHeaderViewController.headerMinHeight - self.containerScrollView.safeAreaInsets.bottom) +// let headerViewHeight: CGFloat = profileHeaderViewController.view.frame.height +// let contentSize = CGSize( +// width: self.containerScrollView.contentSize.width, +// height: bottomPageHeight + headerViewHeight +// ) +// self.overlayScrollView.contentSize = contentSize +// // os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: contentSize: %s", ((#file as NSString).lastPathComponent), #line, #function, contentSize.debugDescription) +// } +// +//} extension ProfileViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { - return .lightContent - } - - override func viewSafeAreaInsetsDidChange() { - super.viewSafeAreaInsetsDidChange() - - profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) - } - - override var isViewLoaded: Bool { - return super.isViewLoaded - } +// override var preferredStatusBarStyle: UIStatusBarStyle { +// return .lightContent +// } +// +// override func viewSafeAreaInsetsDidChange() { +// super.viewSafeAreaInsetsDidChange() +// +// profileHeaderViewController.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) +// } +// +// override var isViewLoaded: Bool { +// return super.isViewLoaded +// } override func viewDidLoad() { super.viewDidLoad() @@ -191,193 +215,210 @@ extension ProfileViewController { navigationItem.standardAppearance = barAppearance navigationItem.compactAppearance = barAppearance navigationItem.scrollEdgeAppearance = barAppearance - + navigationItem.titleView = titleView - let editingAndUpdatingPublisher = Publishers.CombineLatest( - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.isUpdating.eraseToAnyPublisher() - ) - // note: not add .share() here - - let barButtonItemHiddenPublisher = Publishers.CombineLatest3( - viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), - viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), - viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() - ) - - editingAndUpdatingPublisher - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, isUpdating in - guard let self = self else { return } - self.cancelEditingBarButtonItem.isEnabled = !isUpdating - } - .store(in: &disposeBag) - - Publishers.CombineLatest4 ( - viewModel.suspended.eraseToAnyPublisher(), - profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(), - editingAndUpdatingPublisher.eraseToAnyPublisher(), - barButtonItemHiddenPublisher.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in - guard let self = self else { return } - let (isEditing, _) = tuple1 - let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 - - var items: [UIBarButtonItem] = [] - defer { - self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil - } +// let editingAndUpdatingPublisher = Publishers.CombineLatest( +// viewModel.$isEditing, +// viewModel.$isUpdating +// ) +// // note: not add .share() here +// +// let barButtonItemHiddenPublisher = Publishers.CombineLatest3( +// viewModel.isMeBarButtonItemsHidden.eraseToAnyPublisher(), +// viewModel.isReplyBarButtonItemHidden.eraseToAnyPublisher(), +// viewModel.isMoreMenuBarButtonItemHidden.eraseToAnyPublisher() +// ) +// +// editingAndUpdatingPublisher +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isEditing, isUpdating in +// guard let self = self else { return } +// self.cancelEditingBarButtonItem.isEnabled = !isUpdating +// } +// .store(in: &disposeBag) - guard !suspended else { - return - } - - guard !isEditing else { - items.append(self.cancelEditingBarButtonItem) - return - } - - guard !isTitleViewDisplaying else { - return - } - - guard isMeBarButtonItemsHidden else { - items.append(self.settingBarButtonItem) - items.append(self.shareBarButtonItem) - items.append(self.favoriteBarButtonItem) - return - } - - if !isMoreMenuBarButtonItemHidden { - items.append(self.moreMenuBarButtonItem) - } - if !isReplyBarButtonItemHidden { - items.append(self.replyBarButtonItem) - } - } - .store(in: &disposeBag) +// Publishers.CombineLatest4 ( +// viewModel.suspended.eraseToAnyPublisher(), +// profileHeaderViewController.viewModel.isTitleViewDisplaying.eraseToAnyPublisher(), +// editingAndUpdatingPublisher.eraseToAnyPublisher(), +// barButtonItemHiddenPublisher.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] suspended, isTitleViewDisplaying, tuple1, tuple2 in +// guard let self = self else { return } +// let (isEditing, _) = tuple1 +// let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 +// +// var items: [UIBarButtonItem] = [] +// defer { +// self.navigationItem.rightBarButtonItems = !items.isEmpty ? items : nil +// } +// +// guard !suspended else { +// return +// } +// +// guard !isEditing else { +// items.append(self.cancelEditingBarButtonItem) +// return +// } +// +// guard !isTitleViewDisplaying else { +// return +// } +// +// guard isMeBarButtonItemsHidden else { +// items.append(self.settingBarButtonItem) +// items.append(self.shareBarButtonItem) +// items.append(self.favoriteBarButtonItem) +// return +// } +// +// if !isMoreMenuBarButtonItemHidden { +// items.append(self.moreMenuBarButtonItem) +// } +// if !isReplyBarButtonItemHidden { +// items.append(self.replyBarButtonItem) +// } +// } +// .store(in: &disposeBag) + + addChild(tabBarPagerController) + tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tabBarPagerController.view) + tabBarPagerController.didMove(toParent: self) + NSLayoutConstraint.activate([ + tabBarPagerController.view.topAnchor.constraint(equalTo: view.topAnchor), + tabBarPagerController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabBarPagerController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabBarPagerController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) - overlayScrollView.refreshControl = refreshControl + tabBarPagerController.delegate = self + tabBarPagerController.dataSource = self + + tabBarPagerController.relayScrollView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) - let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) - bind(userTimelineViewModel: postsUserTimelineViewModel) - - let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: false)) - bind(userTimelineViewModel: repliesUserTimelineViewModel) - - let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) - bind(userTimelineViewModel: mediaUserTimelineViewModel) - - let profileAboutViewModel = ProfileAboutViewModel(context: context) - - profileSegmentedViewController.pagingViewController.viewModel = { - let profilePagingViewModel = ProfilePagingViewModel( - postsUserTimelineViewModel: postsUserTimelineViewModel, - repliesUserTimelineViewModel: repliesUserTimelineViewModel, - mediaUserTimelineViewModel: mediaUserTimelineViewModel, - profileAboutViewModel: profileAboutViewModel - ) - profilePagingViewModel.viewControllers.forEach { viewController in - if let viewController = viewController as? NeedsDependency { - viewController.context = context - viewController.coordinator = coordinator - } - } - return profilePagingViewModel - }() - - profileSegmentedViewController.pagingViewController.addBar( - profileHeaderViewController.buttonBar, - dataSource: profileSegmentedViewController.pagingViewController.viewModel, - at: .custom(view: profileHeaderViewController.view, layout: { buttonBar in - buttonBar.translatesAutoresizingMaskIntoConstraints = false - self.profileHeaderViewController.view.addSubview(buttonBar) - NSLayoutConstraint.activate([ - buttonBar.topAnchor.constraint(equalTo: self.profileHeaderViewController.profileHeaderView.bottomAnchor), - buttonBar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), - buttonBar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), - buttonBar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), - buttonBar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.required - 1), - ]) - }) - ) - updateBarButtonInsets() - - overlayScrollView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(overlayScrollView) - NSLayoutConstraint.activate([ - overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), - overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor), - view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor), - overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), - ]) - - containerScrollView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(containerScrollView) - NSLayoutConstraint.activate([ - containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), - containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor), - view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor), - containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), - ]) - - // add segmented list - addChild(profileSegmentedViewController) - profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false - containerScrollView.addSubview(profileSegmentedViewController.view) - profileSegmentedViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), - profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), - profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), - profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), - ]) - - // add header - addChild(profileHeaderViewController) - profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false - containerScrollView.addSubview(profileHeaderViewController.view) - profileHeaderViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor), - profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), - containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor), - profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor), - ]) - - containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer) - overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most - overlayScrollView.delegate = self - profileHeaderViewController.delegate = self - profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.delegate = self - profileSegmentedViewController.pagingViewController.pagingDelegate = self - - // bind view model - bindProfile( - headerViewModel: profileHeaderViewController.viewModel, - aboutViewModel: profileAboutViewModel - ) - - bindTitleView() - bindHeader() - bindProfileRelationship() - bindProfileDashboard() - - viewModel.needsPagingEnabled - .receive(on: DispatchQueue.main) - .sink { [weak self] needsPaingEnabled in - guard let self = self else { return } - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = needsPaingEnabled - } - .store(in: &disposeBag) - - profileHeaderViewController.profileHeaderView.delegate = self +// overlayScrollView.refreshControl = refreshControl +// refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) +// +// let postsUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: true)) +// bind(userTimelineViewModel: postsUserTimelineViewModel) +// +// let repliesUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(excludeReplies: false)) +// bind(userTimelineViewModel: repliesUserTimelineViewModel) +// +// let mediaUserTimelineViewModel = UserTimelineViewModel(context: context, domain: viewModel.domain.value, userID: viewModel.userID.value, queryFilter: UserTimelineViewModel.QueryFilter(onlyMedia: true)) +// bind(userTimelineViewModel: mediaUserTimelineViewModel) +// +// let profileAboutViewModel = ProfileAboutViewModel(context: context) +// +// profileSegmentedViewController.pagingViewController.viewModel = { +// let profilePagingViewModel = ProfilePagingViewModel( +// postsUserTimelineViewModel: postsUserTimelineViewModel, +// repliesUserTimelineViewModel: repliesUserTimelineViewModel, +// mediaUserTimelineViewModel: mediaUserTimelineViewModel, +// profileAboutViewModel: profileAboutViewModel +// ) +// profilePagingViewModel.viewControllers.forEach { viewController in +// if let viewController = viewController as? NeedsDependency { +// viewController.context = context +// viewController.coordinator = coordinator +// } +// } +// return profilePagingViewModel +// }() +// +// profileSegmentedViewController.pagingViewController.addBar( +// profileHeaderViewController.buttonBar, +// dataSource: profileSegmentedViewController.pagingViewController.viewModel, +// at: .custom(view: profileHeaderViewController.view, layout: { buttonBar in +// buttonBar.translatesAutoresizingMaskIntoConstraints = false +// self.profileHeaderViewController.view.addSubview(buttonBar) +// NSLayoutConstraint.activate([ +// buttonBar.topAnchor.constraint(equalTo: self.profileHeaderViewController.profileHeaderView.bottomAnchor), +// buttonBar.leadingAnchor.constraint(equalTo: self.profileHeaderViewController.view.leadingAnchor), +// buttonBar.trailingAnchor.constraint(equalTo: self.profileHeaderViewController.view.trailingAnchor), +// buttonBar.bottomAnchor.constraint(equalTo: self.profileHeaderViewController.view.bottomAnchor), +// buttonBar.heightAnchor.constraint(equalToConstant: ProfileHeaderViewController.segmentedControlHeight).priority(.required - 1), +// ]) +// }) +// ) +// updateBarButtonInsets() +// +// overlayScrollView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(overlayScrollView) +// NSLayoutConstraint.activate([ +// overlayScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), +// overlayScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// view.trailingAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.trailingAnchor), +// view.bottomAnchor.constraint(equalTo: overlayScrollView.frameLayoutGuide.bottomAnchor), +// overlayScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), +// ]) +// +// containerScrollView.translatesAutoresizingMaskIntoConstraints = false +// view.addSubview(containerScrollView) +// NSLayoutConstraint.activate([ +// containerScrollView.frameLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor), +// containerScrollView.frameLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor), +// view.trailingAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.trailingAnchor), +// view.bottomAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.bottomAnchor), +// containerScrollView.contentLayoutGuide.widthAnchor.constraint(equalTo: view.widthAnchor), +// ]) +// +// // add segmented list +// addChild(profileSegmentedViewController) +// profileSegmentedViewController.view.translatesAutoresizingMaskIntoConstraints = false +// containerScrollView.addSubview(profileSegmentedViewController.view) +// profileSegmentedViewController.didMove(toParent: self) +// NSLayoutConstraint.activate([ +// profileSegmentedViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), +// profileSegmentedViewController.view.trailingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.trailingAnchor), +// profileSegmentedViewController.view.bottomAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.bottomAnchor), +// profileSegmentedViewController.view.heightAnchor.constraint(equalTo: containerScrollView.frameLayoutGuide.heightAnchor), +// ]) +// +// // add header +// addChild(profileHeaderViewController) +// profileHeaderViewController.view.translatesAutoresizingMaskIntoConstraints = false +// containerScrollView.addSubview(profileHeaderViewController.view) +// profileHeaderViewController.didMove(toParent: self) +// NSLayoutConstraint.activate([ +// profileHeaderViewController.view.topAnchor.constraint(equalTo: containerScrollView.topAnchor), +// profileHeaderViewController.view.leadingAnchor.constraint(equalTo: containerScrollView.contentLayoutGuide.leadingAnchor), +// containerScrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: profileHeaderViewController.view.trailingAnchor), +// profileSegmentedViewController.view.topAnchor.constraint(equalTo: profileHeaderViewController.view.bottomAnchor), +// ]) +// +// containerScrollView.addGestureRecognizer(overlayScrollView.panGestureRecognizer) +// overlayScrollView.layer.zPosition = .greatestFiniteMagnitude // make vision top-most +// overlayScrollView.delegate = self +// profileHeaderViewController.delegate = self +// profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.delegate = self +// profileSegmentedViewController.pagingViewController.pagingDelegate = self +// +// // bind view model +// bindProfile( +// headerViewModel: profileHeaderViewController.viewModel, +// aboutViewModel: profileAboutViewModel +// ) +// +// bindTitleView() +// bindHeader() +// bindProfileRelationship() +// bindProfileDashboard() +// +// viewModel.needsPagingEnabled +// .receive(on: DispatchQueue.main) +// .sink { [weak self] needsPaingEnabled in +// guard let self = self else { return } +// self.profileSegmentedViewController.pagingViewController.isScrollEnabled = needsPaingEnabled +// } +// .store(in: &disposeBag) +// +// profileHeaderViewController.profileHeaderView.delegate = self } override func viewWillAppear(_ animated: Bool) { @@ -386,798 +427,834 @@ extension ProfileViewController { // set back button tint color in SceneCoordinator.present(scene:from:transition:) // force layout to make banner image tweak take effect - view.layoutIfNeeded() +// view.layoutIfNeeded() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppear.send() - - // set overlay scroll view initial content size - guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer, - let scrollView = currentViewController.scrollView - else { return } - - currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) - scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) +// viewModel.viewDidAppear.send() +// +// // set overlay scroll view initial content size +// guard let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer, +// let scrollView = currentViewController.scrollView +// else { return } +// +// currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) +// scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - currentPostTimelineTableViewContentSizeObservation = nil +// currentPostTimelineTableViewContentSizeObservation = nil } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) - updateBarButtonInsets() +// updateBarButtonInsets() } } extension ProfileViewController { - private func updateBarButtonInsets() { - let margin: CGFloat = { - switch traitCollection.userInterfaceIdiom { - case .phone: - return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass - default: - return traitCollection.horizontalSizeClass == .regular ? - ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : - ProfileViewController.containerViewMarginForCompactHorizontalSizeClass - } - }() - - profileHeaderViewController.buttonBar.layout.contentInset.left = margin - profileHeaderViewController.buttonBar.layout.contentInset.right = margin - } +// private func updateBarButtonInsets() { +// let margin: CGFloat = { +// switch traitCollection.userInterfaceIdiom { +// case .phone: +// return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass +// default: +// return traitCollection.horizontalSizeClass == .regular ? +// ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : +// ProfileViewController.containerViewMarginForCompactHorizontalSizeClass +// } +// }() +// +// profileHeaderViewController.buttonBar.layout.contentInset.left = margin +// profileHeaderViewController.buttonBar.layout.contentInset.right = margin +// } } extension ProfileViewController { - private func bind(userTimelineViewModel: UserTimelineViewModel) { - viewModel.domain.assign(to: \.domain, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.userID.assign(to: \.userID, on: userTimelineViewModel).store(in: &disposeBag) - viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) - viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) - viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) - viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) - } - - private func bindProfile( - headerViewModel: ProfileHeaderViewModel, - aboutViewModel: ProfileAboutViewModel - ) { - // header - viewModel.avatarImageURL - .receive(on: DispatchQueue.main) - .assign(to: \.avatarImageURL, on: headerViewModel.displayProfileInfo) - .store(in: &disposeBag) - viewModel.name - .map { $0 ?? "" } - .receive(on: DispatchQueue.main) - .assign(to: \.name, on: headerViewModel.displayProfileInfo) - .store(in: &disposeBag) - viewModel.bioDescription - .receive(on: DispatchQueue.main) - .assign(to: \.note, on: headerViewModel.displayProfileInfo) - .store(in: &disposeBag) - - // about - Publishers.CombineLatest( - viewModel.fields.removeDuplicates(), - viewModel.emojiMeta.removeDuplicates() - ) - .map { fields, emojiMeta -> [ProfileFieldItem.FieldValue] in - fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } - } - .receive(on: DispatchQueue.main) - .assign(to: \.fields, on: aboutViewModel.displayProfileInfo) - .store(in: &disposeBag) - - // common - viewModel.accountForEdit - .assign(to: \.accountForEdit, on: headerViewModel) - .store(in: &disposeBag) - viewModel.accountForEdit - .assign(to: \.accountForEdit, on: aboutViewModel) - .store(in: &disposeBag) - viewModel.emojiMeta - .receive(on: DispatchQueue.main) - .assign(to: \.emojiMeta, on: headerViewModel) - .store(in: &disposeBag) - viewModel.emojiMeta - .receive(on: DispatchQueue.main) - .assign(to: \.emojiMeta, on: aboutViewModel) - .store(in: &disposeBag) - viewModel.isEditing - .assign(to: \.isEditing, on: headerViewModel) - .store(in: &disposeBag) - viewModel.isEditing - .assign(to: \.isEditing, on: aboutViewModel) - .store(in: &disposeBag) - } - - private func bindTitleView() { - Publishers.CombineLatest3( - viewModel.name, - viewModel.emojiMeta, - viewModel.statusesCount - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] name, emojiMeta, statusesCount in - guard let self = self else { return } - guard let title = name, let statusesCount = statusesCount, - let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { - self.titleView.isHidden = true - return - } - self.titleView.isHidden = false - let subtitle = L10n.Plural.Count.MetricFormatted.post(formattedStatusCount, statusesCount) - let mastodonContent = MastodonContent(content: title, emojis: emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.titleView.update(titleMetaContent: metaContent, subtitle: subtitle) - } catch { +// private func bind(userTimelineViewModel: UserTimelineViewModel) { +// viewModel.domain +// viewModel.domain.assign(to: \.domain, on: userTimelineViewModel).store(in: &disposeBag) +// viewModel.userID.assign(to: \.userID, on: userTimelineViewModel).store(in: &disposeBag) +// viewModel.isBlocking.assign(to: \.value, on: userTimelineViewModel.isBlocking).store(in: &disposeBag) +// viewModel.isBlockedBy.assign(to: \.value, on: userTimelineViewModel.isBlockedBy).store(in: &disposeBag) +// viewModel.suspended.assign(to: \.value, on: userTimelineViewModel.isSuspended).store(in: &disposeBag) +// viewModel.name.assign(to: \.value, on: userTimelineViewModel.userDisplayName).store(in: &disposeBag) +// } - } - } - .store(in: &disposeBag) - viewModel.name - .receive(on: DispatchQueue.main) - .sink { [weak self] name in - guard let self = self else { return } - self.navigationItem.title = name - } - .store(in: &disposeBag) - } - - private func bindHeader() { - // heaer UI - Publishers.CombineLatest( - viewModel.bannerImageURL.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] bannerImageURL, _ in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() - let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) - guard let bannerImageURL = bannerImageURL else { - self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder - return - } - self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( - withURL: bannerImageURL, - placeholderImage: placeholder, - imageTransition: .crossDissolve(0.3), - runImageTransitionIfCached: false, - completion: { [weak self] response in - guard let self = self else { return } - guard let image = response.value else { return } - guard image.size.width > 1 && image.size.height > 1 else { - // restore to placeholder when image invalid - self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder - return - } - } - ) - } - .store(in: &disposeBag) - - viewModel.username - .map { username in username.flatMap { "@" + $0 } ?? " " } - .receive(on: DispatchQueue.main) - .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) - .store(in: &disposeBag) - - viewModel.isEditing - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing in - guard let self = self else { return } - // set first responder for key command - if !isEditing { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.profileSegmentedViewController.pagingViewController.becomeFirstResponder() - } - } - - // dismiss keyboard if needs - if !isEditing { self.view.endEditing(true) } - - self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isEditing - if isEditing { - // scroll to About page - self.profileSegmentedViewController.pagingViewController.scrollToPage( - .last, - animated: true, - completion: nil - ) - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = false - } else { - self.profileSegmentedViewController.pagingViewController.isScrollEnabled = true - } - - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - animator.addAnimations { - self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 - } - animator.startAnimation() - } - .store(in: &disposeBag) - - viewModel.needsImageOverlayBlurred - .receive(on: DispatchQueue.main) - .sink { [weak self] needsImageOverlayBlurred in - guard let self = self else { return } - UIView.animate(withDuration: 0.33) { - let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil - self.profileHeaderViewController.profileHeaderView.bannerImageViewOverlayVisualEffectView.effect = bannerEffect - let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil - self.profileHeaderViewController.profileHeaderView.avatarImageViewOverlayVisualEffectView.effect = avatarEffect - } - } - .store(in: &disposeBag) - } - - private func bindProfileRelationship() { - Publishers.CombineLatest( - viewModel.$user, - viewModel.relationshipActionOptionSet - ) - .asyncMap { [weak self] user, relationshipSet -> UIMenu? in - guard let self = self else { return nil } - guard let user = user else { - return nil - } - let name = user.displayNameWithFallback - let _ = ManagedObjectRecord(objectID: user.objectID) - let menu = MastodonMenu.setupMenu( - actions: [ - .muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)), - .blockUser(.init(name: name, isBlocking: self.viewModel.isBlocking.value)), - .reportUser(.init(name: name)), - .shareUser(.init(name: name)), - ], - delegate: self - ) - return menu - } - .sink { [weak self] completion in - guard let self = self else { return } - switch completion { - case .failure: - self.moreMenuBarButtonItem.menu = nil - case .finished: - break - } - } receiveValue: { [weak self] menu in - guard let self = self else { return } - self.moreMenuBarButtonItem.menu = menu - } - .store(in: &disposeBag) - - viewModel.isRelationshipActionButtonHidden - .receive(on: DispatchQueue.main) - .sink { [weak self] isHidden in - guard let self = self else { return } - self.profileHeaderViewController.profileHeaderView.relationshipActionButtonShadowContainer.isHidden = isHidden - } - .store(in: &disposeBag) - - Publishers.CombineLatest3( - viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), - viewModel.isEditing.eraseToAnyPublisher(), - viewModel.isUpdating.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] relationshipActionSet, isEditing, isUpdating in - guard let self = self else { return } - let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton - if relationshipActionSet.contains(.edit) { - // check .edit state and set .editing when isEditing - friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) - self.profileHeaderViewController.profileHeaderView.configure(state: isEditing ? .editing : .normal) - } else { - friendshipButton.configure(actionOptionSet: relationshipActionSet) - } - } - .store(in: &disposeBag) - - Publishers.CombineLatest3( - viewModel.isBlocking.eraseToAnyPublisher(), - viewModel.isBlockedBy.eraseToAnyPublisher(), - viewModel.suspended.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isBlocking, isBlockedBy, suspended in - guard let self = self else { return } - let isNeedSetHidden = isBlocking || isBlockedBy || suspended - self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden - self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden - self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden - self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden - self.viewModel.needsPagePinToTop.value = isNeedSetHidden - } - .store(in: &disposeBag) - } // end func bindProfileRelationship - - private func bindProfileDashboard() { - viewModel.statusesCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0) - } - .store(in: &disposeBag) - viewModel.followingCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0) - } - .store(in: &disposeBag) - viewModel.followersCount - .receive(on: DispatchQueue.main) - .sink { [weak self] count in - guard let self = self else { return } - let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true - self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) - } - .store(in: &disposeBag) - } - - private func handleMetaPress(_ meta: Meta) { - switch meta { - case .url(_, _, let url, _): - guard let url = URL(string: url) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - case .mention(_, _, let userInfo): - guard let href = userInfo?["href"] as? String, - let url = URL(string: href) else { return } - coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) - case .hashtag(_, let hashtag, _): - let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag) - coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show) - case .email, .emoji: - break - } - } +// private func bindProfile( +// headerViewModel: ProfileHeaderViewModel, +// aboutViewModel: ProfileAboutViewModel +// ) { +// // header +// viewModel.avatarImageURL +// .receive(on: DispatchQueue.main) +// .assign(to: \.avatarImageURL, on: headerViewModel.displayProfileInfo) +// .store(in: &disposeBag) +// viewModel.name +// .map { $0 ?? "" } +// .receive(on: DispatchQueue.main) +// .assign(to: \.name, on: headerViewModel.displayProfileInfo) +// .store(in: &disposeBag) +// viewModel.bioDescription +// .receive(on: DispatchQueue.main) +// .assign(to: \.note, on: headerViewModel.displayProfileInfo) +// .store(in: &disposeBag) +// +// // about +// Publishers.CombineLatest( +// viewModel.fields.removeDuplicates(), +// viewModel.emojiMeta.removeDuplicates() +// ) +// .map { fields, emojiMeta -> [ProfileFieldItem.FieldValue] in +// fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } +// } +// .receive(on: DispatchQueue.main) +// .assign(to: \.fields, on: aboutViewModel.displayProfileInfo) +// .store(in: &disposeBag) +// +// // common +// viewModel.accountForEdit +// .assign(to: \.accountForEdit, on: headerViewModel) +// .store(in: &disposeBag) +// viewModel.accountForEdit +// .assign(to: \.accountForEdit, on: aboutViewModel) +// .store(in: &disposeBag) +// viewModel.emojiMeta +// .receive(on: DispatchQueue.main) +// .assign(to: \.emojiMeta, on: headerViewModel) +// .store(in: &disposeBag) +// viewModel.emojiMeta +// .receive(on: DispatchQueue.main) +// .assign(to: \.emojiMeta, on: aboutViewModel) +// .store(in: &disposeBag) +// viewModel.isEditing +// .assign(to: \.isEditing, on: headerViewModel) +// .store(in: &disposeBag) +// viewModel.isEditing +// .assign(to: \.isEditing, on: aboutViewModel) +// .store(in: &disposeBag) +// } +// +// private func bindTitleView() { +// Publishers.CombineLatest3( +// viewModel.name, +// viewModel.emojiMeta, +// viewModel.statusesCount +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] name, emojiMeta, statusesCount in +// guard let self = self else { return } +// guard let title = name, let statusesCount = statusesCount, +// let formattedStatusCount = MastodonMetricFormatter().string(from: statusesCount) else { +// self.titleView.isHidden = true +// return +// } +// self.titleView.isHidden = false +// let subtitle = L10n.Plural.Count.MetricFormatted.post(formattedStatusCount, statusesCount) +// let mastodonContent = MastodonContent(content: title, emojis: emojiMeta) +// do { +// let metaContent = try MastodonMetaContent.convert(document: mastodonContent) +// self.titleView.update(titleMetaContent: metaContent, subtitle: subtitle) +// } catch { +// +// } +// } +// .store(in: &disposeBag) +// viewModel.name +// .receive(on: DispatchQueue.main) +// .sink { [weak self] name in +// guard let self = self else { return } +// self.navigationItem.title = name +// } +// .store(in: &disposeBag) +// } +// +// private func bindHeader() { +// // heaer UI +// Publishers.CombineLatest( +// viewModel.bannerImageURL.eraseToAnyPublisher(), +// viewModel.viewDidAppear.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] bannerImageURL, _ in +// guard let self = self else { return } +// self.profileHeaderViewController.profileHeaderView.bannerImageView.af.cancelImageRequest() +// let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) +// guard let bannerImageURL = bannerImageURL else { +// self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder +// return +// } +// self.profileHeaderViewController.profileHeaderView.bannerImageView.af.setImage( +// withURL: bannerImageURL, +// placeholderImage: placeholder, +// imageTransition: .crossDissolve(0.3), +// runImageTransitionIfCached: false, +// completion: { [weak self] response in +// guard let self = self else { return } +// guard let image = response.value else { return } +// guard image.size.width > 1 && image.size.height > 1 else { +// // restore to placeholder when image invalid +// self.profileHeaderViewController.profileHeaderView.bannerImageView.image = placeholder +// return +// } +// } +// ) +// } +// .store(in: &disposeBag) +// +// viewModel.username +// .map { username in username.flatMap { "@" + $0 } ?? " " } +// .receive(on: DispatchQueue.main) +// .assign(to: \.text, on: profileHeaderViewController.profileHeaderView.usernameLabel) +// .store(in: &disposeBag) +// +// viewModel.isEditing +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isEditing in +// guard let self = self else { return } +// // set first responder for key command +// if !isEditing { +// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { +// self.profileSegmentedViewController.pagingViewController.becomeFirstResponder() +// } +// } +// +// // dismiss keyboard if needs +// if !isEditing { self.view.endEditing(true) } +// +// self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isEditing +// if isEditing { +// // scroll to About page +// self.profileSegmentedViewController.pagingViewController.scrollToPage( +// .last, +// animated: true, +// completion: nil +// ) +// self.profileSegmentedViewController.pagingViewController.isScrollEnabled = false +// } else { +// self.profileSegmentedViewController.pagingViewController.isScrollEnabled = true +// } +// +// let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) +// animator.addAnimations { +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 +// } +// animator.startAnimation() +// } +// .store(in: &disposeBag) +// +// viewModel.needsImageOverlayBlurred +// .receive(on: DispatchQueue.main) +// .sink { [weak self] needsImageOverlayBlurred in +// guard let self = self else { return } +// UIView.animate(withDuration: 0.33) { +// let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil +// self.profileHeaderViewController.profileHeaderView.bannerImageViewOverlayVisualEffectView.effect = bannerEffect +// let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil +// self.profileHeaderViewController.profileHeaderView.avatarImageViewOverlayVisualEffectView.effect = avatarEffect +// } +// } +// .store(in: &disposeBag) +// } +// +// private func bindProfileRelationship() { +// Publishers.CombineLatest( +// viewModel.$user, +// viewModel.relationshipActionOptionSet +// ) +// .asyncMap { [weak self] user, relationshipSet -> UIMenu? in +// guard let self = self else { return nil } +// guard let user = user else { +// return nil +// } +// let name = user.displayNameWithFallback +// let _ = ManagedObjectRecord(objectID: user.objectID) +// let menu = MastodonMenu.setupMenu( +// actions: [ +// .muteUser(.init(name: name, isMuting: self.viewModel.isMuting.value)), +// .blockUser(.init(name: name, isBlocking: self.viewModel.isBlocking.value)), +// .reportUser(.init(name: name)), +// .shareUser(.init(name: name)), +// ], +// delegate: self +// ) +// return menu +// } +// .sink { [weak self] completion in +// guard let self = self else { return } +// switch completion { +// case .failure: +// self.moreMenuBarButtonItem.menu = nil +// case .finished: +// break +// } +// } receiveValue: { [weak self] menu in +// guard let self = self else { return } +// self.moreMenuBarButtonItem.menu = menu +// } +// .store(in: &disposeBag) +// +// viewModel.isRelationshipActionButtonHidden +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isHidden in +// guard let self = self else { return } +// self.profileHeaderViewController.profileHeaderView.relationshipActionButtonShadowContainer.isHidden = isHidden +// } +// .store(in: &disposeBag) +// +// Publishers.CombineLatest3( +// viewModel.relationshipActionOptionSet.eraseToAnyPublisher(), +// viewModel.isEditing.eraseToAnyPublisher(), +// viewModel.isUpdating.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] relationshipActionSet, isEditing, isUpdating in +// guard let self = self else { return } +// let friendshipButton = self.profileHeaderViewController.profileHeaderView.relationshipActionButton +// if relationshipActionSet.contains(.edit) { +// // check .edit state and set .editing when isEditing +// friendshipButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) +// self.profileHeaderViewController.profileHeaderView.configure(state: isEditing ? .editing : .normal) +// } else { +// friendshipButton.configure(actionOptionSet: relationshipActionSet) +// } +// } +// .store(in: &disposeBag) +// +// Publishers.CombineLatest3( +// viewModel.isBlocking.eraseToAnyPublisher(), +// viewModel.isBlockedBy.eraseToAnyPublisher(), +// viewModel.suspended.eraseToAnyPublisher() +// ) +// .receive(on: DispatchQueue.main) +// .sink { [weak self] isBlocking, isBlockedBy, suspended in +// guard let self = self else { return } +// let isNeedSetHidden = isBlocking || isBlockedBy || suspended +// self.profileHeaderViewController.viewModel.needsSetupBottomShadow.value = !isNeedSetHidden +// self.profileHeaderViewController.profileHeaderView.bioContainerView.isHidden = isNeedSetHidden +// self.profileHeaderViewController.viewModel.needsFiledCollectionViewHidden.value = isNeedSetHidden +// self.profileHeaderViewController.buttonBar.isUserInteractionEnabled = !isNeedSetHidden +// self.viewModel.needsPagePinToTop.value = isNeedSetHidden +// } +// .store(in: &disposeBag) +// } // end func bindProfileRelationship +// +// private func bindProfileDashboard() { +// viewModel.statusesCount +// .receive(on: DispatchQueue.main) +// .sink { [weak self] count in +// guard let self = self else { return } +// let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.numberLabel.text = text +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0) +// } +// .store(in: &disposeBag) +// viewModel.followingCount +// .receive(on: DispatchQueue.main) +// .sink { [weak self] count in +// guard let self = self else { return } +// let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.numberLabel.text = text +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0) +// } +// .store(in: &disposeBag) +// viewModel.followersCount +// .receive(on: DispatchQueue.main) +// .sink { [weak self] count in +// guard let self = self else { return } +// let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.numberLabel.text = text +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true +// self.profileHeaderViewController.profileHeaderView.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) +// } +// .store(in: &disposeBag) +// } +// +// private func handleMetaPress(_ meta: Meta) { +// switch meta { +// case .url(_, _, let url, _): +// guard let url = URL(string: url) else { return } +// coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) +// case .mention(_, _, let userInfo): +// guard let href = userInfo?["href"] as? String, +// let url = URL(string: href) else { return } +// coordinator.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) +// case .hashtag(_, let hashtag, _): +// let hashtagTimelineViewModel = HashtagTimelineViewModel(context: context, hashtag: hashtag) +// coordinator.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show) +// case .email, .emoji: +// break +// } +// } } extension ProfileViewController { - + @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - viewModel.isEditing.value = false +// viewModel.isEditing.value = false } - + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let setting = context.settingService.currentSetting.value else { return } - let settingsViewModel = SettingsViewModel(context: context, setting: setting) - coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) +// guard let setting = context.settingService.currentSetting.value else { return } +// let settingsViewModel = SettingsViewModel(context: context, setting: setting) +// coordinator.present(scene: .settings(viewModel: settingsViewModel), from: self, transition: .modal(animated: true, completion: nil)) } - + @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - Task { - let _activityViewController = try await DataSourceFacade.createActivityViewController( - dependency: self, - user: record - ) - guard let activityViewController = _activityViewController else { return } - self.coordinator.present( - scene: .activityViewController( - activityViewController: activityViewController, - sourceView: nil, - barButtonItem: sender - ), - from: self, - transition: .activityViewControllerPresent(animated: true, completion: nil) - ) - } // end Task +// guard let user = viewModel.user else { return } +// let record: ManagedObjectRecord = .init(objectID: user.objectID) +// Task { +// let _activityViewController = try await DataSourceFacade.createActivityViewController( +// dependency: self, +// user: record +// ) +// guard let activityViewController = _activityViewController else { return } +// self.coordinator.present( +// scene: .activityViewController( +// activityViewController: activityViewController, +// sourceView: nil, +// barButtonItem: sender +// ), +// from: self, +// transition: .activityViewControllerPresent(animated: true, completion: nil) +// ) +// } // end Task } - + @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let favoriteViewModel = FavoriteViewModel(context: context) - coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) +// let favoriteViewModel = FavoriteViewModel(context: context) +// coordinator.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) } - + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let mastodonUser = viewModel.user else { return } - let composeViewModel = ComposeViewModel( - context: context, - composeKind: .mention(user: .init(objectID: mastodonUser.objectID)), - authenticationBox: authenticationBox - ) - coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) +// guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// guard let mastodonUser = viewModel.user else { return } +// let composeViewModel = ComposeViewModel( +// context: context, +// composeKind: .mention(user: .init(objectID: mastodonUser.objectID)), +// authenticationBox: authenticationBox +// ) +// coordinator.present(scene: .compose(viewModel: composeViewModel), from: self, transition: .modal(animated: true, completion: nil)) } - + @objc private func refreshControlValueChanged(_ sender: UIRefreshControl) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - let currentViewController = profileSegmentedViewController.pagingViewController.currentViewController - if let currentViewController = currentViewController as? UserTimelineViewController { - currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - sender.endRefreshing() - } - } - -} - -// MARK: - UIScrollViewDelegate -extension ProfileViewController: UIScrollViewDelegate { - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y - let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top - if scrollView.contentOffset.y < topMaxContentOffsetY { - self.containerScrollView.contentOffset.y = scrollView.contentOffset.y - for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { - postTimelineView.scrollView?.contentOffset.y = 0 - } - contentOffsets.removeAll() - } else { - containerScrollView.contentOffset.y = topMaxContentOffsetY - if viewModel.needsPagePinToTop.value { - // do nothing - } else { - if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { - let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y - customScrollViewContainerController.scrollView?.contentOffset.y = contentOffsetY - } - } - - } - - // elastically banner image - let headerScrollProgress = (containerScrollView.contentOffset.y - containerScrollView.safeAreaInsets.top) / topMaxContentOffsetY - let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY - profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle) - } - -} - -// MARK: - ProfileHeaderViewControllerDelegate -extension ProfileViewController: ProfileHeaderViewControllerDelegate { - - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) { - guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else { - // assertionFailure() - return - } - - updateOverlayScrollViewContentSize(scrollView: scrollView) - } - -} - -// MARK: - ProfilePagingViewControllerDelegate -extension ProfileViewController: ProfilePagingViewControllerDelegate { - - func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { - os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) - -// // update segemented control -// if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments { -// profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index + let currentViewPage = profilePagingViewController.currentPage +// if let currentViewController = currentViewController as? UserTimelineViewController { +// currentViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) +// } +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { +// sender.endRefreshing() // } - - // save content offset - overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y - - // setup observer and gesture fallback - if let scrollView = postTimelineViewController.scrollView { - currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) - scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) - } } } -// MARK: - ProfileHeaderViewDelegate -extension ProfileViewController: ProfileHeaderViewDelegate { - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - - Task { - try await DataSourceFacade.coordinateToMediaPreviewScene( - dependency: self, - user: record, - previewContext: DataSourceFacade.ImagePreviewContext( - imageView: button.avatarImageView, - containerView: .profileAvatar(profileHeaderView) - ) - ) - } // end Task +// MARK: - TabBarPagerDelegate +extension ProfileViewController: TabBarPagerDelegate { + + func tabBarMinimalHeight() -> CGFloat { + return ProfileHeaderViewController.headerMinHeight } - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { - guard let user = viewModel.user else { return } - let record: ManagedObjectRecord = .init(objectID: user.objectID) - - Task { - try await DataSourceFacade.coordinateToMediaPreviewScene( - dependency: self, - user: record, - previewContext: DataSourceFacade.ImagePreviewContext( - imageView: imageView, - containerView: .profileBanner(profileHeaderView) - ) - ) - } // end Task + func resetPageContentOffset(_ tabBarPagerController: TabBarPagerController) { + for viewController in profilePagingViewController.viewModel.viewControllers { + viewController.pageScrollView.contentOffset = .zero + } } - func profileHeaderView( - _ profileHeaderView: ProfileHeaderView, - relationshipButtonDidPressed button: ProfileRelationshipActionButton - ) { - let relationshipActionSet = viewModel.relationshipActionOptionSet.value - - // handle edit logic for editable profile - // handle relationship logic for non-editable profile - if relationshipActionSet.contains(.edit) { - // do nothing when updating - guard !viewModel.isUpdating.value else { return } - - guard let profileHeaderViewModel = profileHeaderViewController.viewModel else { return } - guard let profileAboutViewModel = profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.viewModel else { return } - - let isEdited = profileHeaderViewModel.isEdited() - || profileAboutViewModel.isEdited() - - if isEdited { - // update profile if changed - viewModel.isUpdating.value = true - Task { - do { - // TODO: handle error - _ = try await viewModel.updateProfileInfo( - headerProfileInfo: profileHeaderViewModel.editProfileInfo, - aboutProfileInfo: profileAboutViewModel.editProfileInfo - ) - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info success") - self.viewModel.isEditing.value = false - - } catch { - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info fail: \(error.localizedDescription)") - } - - // finish updating - self.viewModel.isUpdating.value = false - } - } else { - // set `updating` then toggle `edit` state - viewModel.isUpdating.value = true - viewModel.fetchEditProfileInfo() - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self = self else { return } - defer { - // finish updating - self.viewModel.isUpdating.value = false - } - switch completion { - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch profile info for edit fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - let alertController = UIAlertController(for: error, title: L10n.Common.Alerts.EditProfileFailure.title, preferredStyle: .alert) - let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) - alertController.addAction(okAction) - self.coordinator.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - case .finished: - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch profile info for edit success", ((#file as NSString).lastPathComponent), #line, #function) - // enter editing mode - self.viewModel.isEditing.value.toggle() - } - } receiveValue: { [weak self] response in - guard let self = self else { return } - self.viewModel.accountForEdit.value = response.value - } - .store(in: &disposeBag) - } + func tabBarPagerController(_ tabBarPagerController: TabBarPagerController, didScroll scrollView: UIScrollView) { + // elastically banner + if scrollView.contentOffset.y < -scrollView.safeAreaInsets.top { + let offset = scrollView.contentOffset.y - (-scrollView.safeAreaInsets.top) + profileHeaderViewController.profileHeaderView.bannerImageViewTopLayoutConstraint.constant = offset } else { - guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } - switch relationshipAction { - case .none: - break - case .follow, .request, .pending, .following: - guard let user = viewModel.user else { return } - let reocrd = ManagedObjectRecord(objectID: user.objectID) - guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - Task { - try await DataSourceFacade.responseToUserFollowAction( - dependency: self, - user: reocrd, - authenticationBox: authenticationBox - ) - } - case .muting: - guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let user = viewModel.user else { return } - let name = user.displayNameWithFallback - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), - preferredStyle: .alert - ) - let record = ManagedObjectRecord(objectID: user.objectID) - let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in - guard let self = self else { return } - Task { - try await DataSourceFacade.responseToUserMuteAction( - dependency: self, - user: record, - authenticationBox: authenticationBox - ) - } - } - alertController.addAction(unmuteAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) - case .blocking: - guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let user = viewModel.user else { return } - let name = user.displayNameWithFallback - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(name), - preferredStyle: .alert - ) - let record = ManagedObjectRecord(objectID: user.objectID) - let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in - guard let self = self else { return } - Task { - try await DataSourceFacade.responseToUserBlockAction( - dependency: self, - user: record, - authenticationBox: authenticationBox - ) - } - } - alertController.addAction(unblockAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) - alertController.addAction(cancelAction) - present(alertController, animated: true, completion: nil) - case .blocked: - break - default: - assertionFailure() - } + profileHeaderViewController.profileHeaderView.bannerImageViewTopLayoutConstraint.constant = 0 } } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { - handleMetaPress(meta) - } - - func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) { - switch meter { - case .post: - // do nothing - break - case .follower: - guard let domain = viewModel.domain.value, - let userID = viewModel.userID.value - else { return } - let followerListViewModel = FollowerListViewModel( - context: context, - domain: domain, - userID: userID - ) - coordinator.present( - scene: .follower(viewModel: followerListViewModel), - from: self, - transition: .show - ) - case .following: - guard let domain = viewModel.domain.value, - let userID = viewModel.userID.value - else { return } - let followingListViewModel = FollowingListViewModel( - context: context, - domain: domain, - userID: userID - ) - coordinator.present( - scene: .following(viewModel: followingListViewModel), - from: self, - transition: .show - ) - } - } - } -// MARK: - ProfileAboutViewControllerDelegate -extension ProfileViewController: ProfileAboutViewControllerDelegate { - func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) { - handleMetaPress(meta) - } -} - -// MARK: - MastodonMenuDelegate -extension ProfileViewController: MastodonMenuDelegate { - func menuAction(_ action: MastodonMenu.Action) { - guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } - guard let user = viewModel.user else { return } - - let userRecord: ManagedObjectRecord = .init(objectID: user.objectID) - - Task { - try await DataSourceFacade.responseToMenuAction( - dependency: self, - action: action, - menuContext: DataSourceFacade.MenuContext( - author: userRecord, - status: nil, - button: nil, - barButtonItem: self.moreMenuBarButtonItem - ), - authenticationBox: authenticationBox - ) - } // end Task - } -} - -// MARK: - ScrollViewContainer -extension ProfileViewController: ScrollViewContainer { - var scrollView: UIScrollView? { - return overlayScrollView - } -} - -extension ProfileViewController { - - override var keyCommands: [UIKeyCommand]? { - if !viewModel.isEditing.value { - return pageboyNavigateKeyCommands - } - - return nil - } - -} - -// MARK: - PageboyNavigateable -extension ProfileViewController: PageboyNavigateable { - - var navigateablePageViewController: PageboyViewController { - return profileSegmentedViewController.pagingViewController +// MARK: - TabBarPagerDataSource +extension ProfileViewController: TabBarPagerDataSource { + func headerViewController() -> UIViewController & TabBarPagerHeader { + return profileHeaderViewController } - @objc func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - pageboyNavigateKeyCommandHandler(sender) + func pageViewController() -> UIViewController & TabBarPageViewController { + return profilePagingViewController } - } +//// MARK: - UIScrollViewDelegate +//extension ProfileViewController: UIScrollViewDelegate { +// +// func scrollViewDidScroll(_ scrollView: UIScrollView) { +// contentOffsets[profileSegmentedViewController.pagingViewController.currentIndex!] = scrollView.contentOffset.y +// let topMaxContentOffsetY = profileSegmentedViewController.view.frame.minY - ProfileHeaderViewController.headerMinHeight - containerScrollView.safeAreaInsets.top +// if scrollView.contentOffset.y < topMaxContentOffsetY { +// self.containerScrollView.contentOffset.y = scrollView.contentOffset.y +// for postTimelineView in profileSegmentedViewController.pagingViewController.viewModel.viewControllers { +// postTimelineView.scrollView?.contentOffset.y = 0 +// } +// contentOffsets.removeAll() +// } else { +// containerScrollView.contentOffset.y = topMaxContentOffsetY +// if viewModel.needsPagePinToTop.value { +// // do nothing +// } else { +// if let customScrollViewContainerController = profileSegmentedViewController.pagingViewController.currentViewController as? ScrollViewContainer { +// let contentOffsetY = scrollView.contentOffset.y - containerScrollView.contentOffset.y +// customScrollViewContainerController.scrollView?.contentOffset.y = contentOffsetY +// } +// } +// +// } +// +// // elastically banner image +// let headerScrollProgress = (containerScrollView.contentOffset.y - containerScrollView.safeAreaInsets.top) / topMaxContentOffsetY +// let throttle = ProfileHeaderViewController.headerMinHeight / topMaxContentOffsetY +// profileHeaderViewController.updateHeaderScrollProgress(headerScrollProgress, throttle: throttle) +// } +// +//} +// +//// MARK: - ProfileHeaderViewControllerDelegate +//extension ProfileViewController: ProfileHeaderViewControllerDelegate { +// +// func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) { +// guard let scrollView = (profileSegmentedViewController.pagingViewController.currentViewController as? UserTimelineViewController)?.scrollView else { +// // assertionFailure() +// return +// } +// +// updateOverlayScrollViewContentSize(scrollView: scrollView) +// } +// +//} +// +//// MARK: - ProfilePagingViewControllerDelegate +//extension ProfileViewController: ProfilePagingViewControllerDelegate { +// +// func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController postTimelineViewController: ScrollViewContainer, atIndex index: Int) { +// os_log("%{public}s[%{public}ld], %{public}s: select at index: %ld", ((#file as NSString).lastPathComponent), #line, #function, index) +// +//// // update segemented control +//// if index < profileHeaderViewController.pageSegmentedControl.numberOfSegments { +//// profileHeaderViewController.pageSegmentedControl.selectedSegmentIndex = index +//// } +// +// // save content offset +// overlayScrollView.contentOffset.y = contentOffsets[index] ?? containerScrollView.contentOffset.y +// +// // setup observer and gesture fallback +// if let scrollView = postTimelineViewController.scrollView { +// currentPostTimelineTableViewContentSizeObservation = observeTableViewContentSize(scrollView: scrollView) +// scrollView.panGestureRecognizer.require(toFail: overlayScrollView.panGestureRecognizer) +// } +// } +// +//} +// +//// MARK: - ProfileHeaderViewDelegate +//extension ProfileViewController: ProfileHeaderViewDelegate { +// func profileHeaderView(_ profileHeaderView: ProfileHeaderView, avatarButtonDidPressed button: AvatarButton) { +// guard let user = viewModel.user else { return } +// let record: ManagedObjectRecord = .init(objectID: user.objectID) +// +// Task { +// try await DataSourceFacade.coordinateToMediaPreviewScene( +// dependency: self, +// user: record, +// previewContext: DataSourceFacade.ImagePreviewContext( +// imageView: button.avatarImageView, +// containerView: .profileAvatar(profileHeaderView) +// ) +// ) +// } // end Task +// } +// +// func profileHeaderView(_ profileHeaderView: ProfileHeaderView, bannerImageViewDidPressed imageView: UIImageView) { +// guard let user = viewModel.user else { return } +// let record: ManagedObjectRecord = .init(objectID: user.objectID) +// +// Task { +// try await DataSourceFacade.coordinateToMediaPreviewScene( +// dependency: self, +// user: record, +// previewContext: DataSourceFacade.ImagePreviewContext( +// imageView: imageView, +// containerView: .profileBanner(profileHeaderView) +// ) +// ) +// } // end Task +// } +// +// func profileHeaderView( +// _ profileHeaderView: ProfileHeaderView, +// relationshipButtonDidPressed button: ProfileRelationshipActionButton +// ) { +// let relationshipActionSet = viewModel.relationshipActionOptionSet.value +// +// // handle edit logic for editable profile +// // handle relationship logic for non-editable profile +// if relationshipActionSet.contains(.edit) { +// // do nothing when updating +// guard !viewModel.isUpdating.value else { return } +// +// guard let profileHeaderViewModel = profileHeaderViewController.viewModel else { return } +// guard let profileAboutViewModel = profileSegmentedViewController.pagingViewController.viewModel.profileAboutViewController.viewModel else { return } +// +// let isEdited = profileHeaderViewModel.isEdited() +// || profileAboutViewModel.isEdited() +// +// if isEdited { +// // update profile if changed +// viewModel.isUpdating.value = true +// Task { +// do { +// // TODO: handle error +// _ = try await viewModel.updateProfileInfo( +// headerProfileInfo: profileHeaderViewModel.editProfileInfo, +// aboutProfileInfo: profileAboutViewModel.editProfileInfo +// ) +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info success") +// self.viewModel.isEditing.value = false +// +// } catch { +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info fail: \(error.localizedDescription)") +// } +// +// // finish updating +// self.viewModel.isUpdating.value = false +// } +// } else { +// // set `updating` then toggle `edit` state +// viewModel.isUpdating.value = true +// viewModel.fetchEditProfileInfo() +// .receive(on: DispatchQueue.main) +// .sink { [weak self] completion in +// guard let self = self else { return } +// defer { +// // finish updating +// self.viewModel.isUpdating.value = false +// } +// switch completion { +// case .failure(let error): +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch profile info for edit fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) +// let alertController = UIAlertController(for: error, title: L10n.Common.Alerts.EditProfileFailure.title, preferredStyle: .alert) +// let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default, handler: nil) +// alertController.addAction(okAction) +// self.coordinator.present( +// scene: .alertController(alertController: alertController), +// from: nil, +// transition: .alertController(animated: true, completion: nil) +// ) +// case .finished: +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch profile info for edit success", ((#file as NSString).lastPathComponent), #line, #function) +// // enter editing mode +// self.viewModel.isEditing.value.toggle() +// } +// } receiveValue: { [weak self] response in +// guard let self = self else { return } +// self.viewModel.accountForEdit.value = response.value +// } +// .store(in: &disposeBag) +// } +// } else { +// guard let relationshipAction = relationshipActionSet.highPriorityAction(except: .editOptions) else { return } +// switch relationshipAction { +// case .none: +// break +// case .follow, .request, .pending, .following: +// guard let user = viewModel.user else { return } +// let reocrd = ManagedObjectRecord(objectID: user.objectID) +// guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// Task { +// try await DataSourceFacade.responseToUserFollowAction( +// dependency: self, +// user: reocrd, +// authenticationBox: authenticationBox +// ) +// } +// case .muting: +// guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// guard let user = viewModel.user else { return } +// let name = user.displayNameWithFallback +// +// let alertController = UIAlertController( +// title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, +// message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), +// preferredStyle: .alert +// ) +// let record = ManagedObjectRecord(objectID: user.objectID) +// let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in +// guard let self = self else { return } +// Task { +// try await DataSourceFacade.responseToUserMuteAction( +// dependency: self, +// user: record, +// authenticationBox: authenticationBox +// ) +// } +// } +// alertController.addAction(unmuteAction) +// let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) +// alertController.addAction(cancelAction) +// present(alertController, animated: true, completion: nil) +// case .blocking: +// guard let authenticationBox = self.context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// guard let user = viewModel.user else { return } +// let name = user.displayNameWithFallback +// +// let alertController = UIAlertController( +// title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title, +// message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(name), +// preferredStyle: .alert +// ) +// let record = ManagedObjectRecord(objectID: user.objectID) +// let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in +// guard let self = self else { return } +// Task { +// try await DataSourceFacade.responseToUserBlockAction( +// dependency: self, +// user: record, +// authenticationBox: authenticationBox +// ) +// } +// } +// alertController.addAction(unblockAction) +// let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel, handler: nil) +// alertController.addAction(cancelAction) +// present(alertController, animated: true, completion: nil) +// case .blocked: +// break +// default: +// assertionFailure() +// } +// } +// } +// +// func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { +// handleMetaPress(meta) +// } +// +// func profileHeaderView(_ profileHeaderView: ProfileHeaderView, profileStatusDashboardView dashboardView: ProfileStatusDashboardView, dashboardMeterViewDidPressed dashboardMeterView: ProfileStatusDashboardMeterView, meter: ProfileStatusDashboardView.Meter) { +// switch meter { +// case .post: +// // do nothing +// break +// case .follower: +// guard let domain = viewModel.domain.value, +// let userID = viewModel.userID.value +// else { return } +// let followerListViewModel = FollowerListViewModel( +// context: context, +// domain: domain, +// userID: userID +// ) +// coordinator.present( +// scene: .follower(viewModel: followerListViewModel), +// from: self, +// transition: .show +// ) +// case .following: +// guard let domain = viewModel.domain.value, +// let userID = viewModel.userID.value +// else { return } +// let followingListViewModel = FollowingListViewModel( +// context: context, +// domain: domain, +// userID: userID +// ) +// coordinator.present( +// scene: .following(viewModel: followingListViewModel), +// from: self, +// transition: .show +// ) +// } +// } +// +//} +// +//// MARK: - ProfileAboutViewControllerDelegate +//extension ProfileViewController: ProfileAboutViewControllerDelegate { +// func profileAboutViewController(_ viewController: ProfileAboutViewController, profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, metaLabel: MetaLabel, didSelectMeta meta: Meta) { +// handleMetaPress(meta) +// } +//} +// +//// MARK: - MastodonMenuDelegate +//extension ProfileViewController: MastodonMenuDelegate { +// func menuAction(_ action: MastodonMenu.Action) { +// guard let authenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } +// guard let user = viewModel.user else { return } +// +// let userRecord: ManagedObjectRecord = .init(objectID: user.objectID) +// +// Task { +// try await DataSourceFacade.responseToMenuAction( +// dependency: self, +// action: action, +// menuContext: DataSourceFacade.MenuContext( +// author: userRecord, +// status: nil, +// button: nil, +// barButtonItem: self.moreMenuBarButtonItem +// ), +// authenticationBox: authenticationBox +// ) +// } // end Task +// } +//} +// +//// MARK: - ScrollViewContainer +//extension ProfileViewController: ScrollViewContainer { +// var scrollView: UIScrollView? { +// return overlayScrollView +// } +//} +// +//extension ProfileViewController { +// +// override var keyCommands: [UIKeyCommand]? { +// if !viewModel.isEditing.value { +// return pageboyNavigateKeyCommands +// } +// +// return nil +// } +// +//} +// +//// MARK: - PageboyNavigateable +//extension ProfileViewController: PageboyNavigateable { +// +// var navigateablePageViewController: PageboyViewController { +// return profileSegmentedViewController.pagingViewController +// } +// +// @objc func pageboyNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// pageboyNavigateKeyCommandHandler(sender) +// } +// +//} + diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index ac8c12e98..add87f312 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -27,149 +27,181 @@ class ProfileViewModel: NSObject { private var mastodonUserObserver: AnyCancellable? private var currentMastodonUserObserver: AnyCancellable? + let postsUserTimelineViewModel: UserTimelineViewModel + let repliesUserTimelineViewModel: UserTimelineViewModel + let mediaUserTimelineViewModel: UserTimelineViewModel + let profileAboutViewModel: ProfileAboutViewModel + // input let context: AppContext @Published var me: MastodonUser? @Published var user: MastodonUser? + let viewDidAppear = PassthroughSubject() + + @Published var isEditing = false + @Published var isUpdating = false // output - let domain: CurrentValueSubject - let userID: CurrentValueSubject - let bannerImageURL: CurrentValueSubject - let avatarImageURL: CurrentValueSubject - let name: CurrentValueSubject - let username: CurrentValueSubject - let bioDescription: CurrentValueSubject - let url: CurrentValueSubject - let statusesCount: CurrentValueSubject - let followingCount: CurrentValueSubject - let followersCount: CurrentValueSubject - let fields: CurrentValueSubject<[MastodonField], Never> - let emojiMeta: CurrentValueSubject + @Published var userIdentifier: UserIdentifier? = nil + +// let domain: CurrentValueSubject +// let userID: CurrentValueSubject +// let bannerImageURL: CurrentValueSubject +// let avatarImageURL: CurrentValueSubject +// let name: CurrentValueSubject +// let username: CurrentValueSubject +// let bioDescription: CurrentValueSubject +// let url: CurrentValueSubject +// let statusesCount: CurrentValueSubject +// let followingCount: CurrentValueSubject +// let followersCount: CurrentValueSubject +// let fields: CurrentValueSubject<[MastodonField], Never> +// let emojiMeta: CurrentValueSubject // fulfill this before editing let accountForEdit = CurrentValueSubject(nil) - let protected: CurrentValueSubject - let suspended: CurrentValueSubject +// let protected: CurrentValueSubject +// let suspended: CurrentValueSubject - let isEditing = CurrentValueSubject(false) - let isUpdating = CurrentValueSubject(false) - - let relationshipActionOptionSet = CurrentValueSubject(.none) - let isFollowedBy = CurrentValueSubject(false) - let isMuting = CurrentValueSubject(false) - let isBlocking = CurrentValueSubject(false) - let isBlockedBy = CurrentValueSubject(false) - - let isRelationshipActionButtonHidden = CurrentValueSubject(true) - let isReplyBarButtonItemHidden = CurrentValueSubject(true) - let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) - let isMeBarButtonItemsHidden = CurrentValueSubject(true) - - let needsPagePinToTop = CurrentValueSubject(false) - let needsPagingEnabled = CurrentValueSubject(true) - let needsImageOverlayBlurred = CurrentValueSubject(false) +// +// let relationshipActionOptionSet = CurrentValueSubject(.none) +// let isFollowedBy = CurrentValueSubject(false) +// let isMuting = CurrentValueSubject(false) +// let isBlocking = CurrentValueSubject(false) +// let isBlockedBy = CurrentValueSubject(false) +// +// let isRelationshipActionButtonHidden = CurrentValueSubject(true) +// let isReplyBarButtonItemHidden = CurrentValueSubject(true) +// let isMoreMenuBarButtonItemHidden = CurrentValueSubject(true) +// let isMeBarButtonItemsHidden = CurrentValueSubject(true) +// +// let needsPagePinToTop = CurrentValueSubject(false) +// let needsPagingEnabled = CurrentValueSubject(true) +// let needsImageOverlayBlurred = CurrentValueSubject(false) init(context: AppContext, optionalMastodonUser mastodonUser: MastodonUser?) { self.context = context self.user = mastodonUser - self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) - self.userID = CurrentValueSubject(mastodonUser?.id) - self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) - self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) - self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) - self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) - self.bioDescription = CurrentValueSubject(mastodonUser?.note) - self.url = CurrentValueSubject(mastodonUser?.url) - self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) }) - self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) }) - self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) }) - self.protected = CurrentValueSubject(mastodonUser?.locked) - self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) - self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) - self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:]) +// self.domain = CurrentValueSubject(context.authenticationService.activeMastodonAuthenticationBox.value?.domain) +// self.userID = CurrentValueSubject(mastodonUser?.id) +// self.bannerImageURL = CurrentValueSubject(mastodonUser?.headerImageURL()) +// self.avatarImageURL = CurrentValueSubject(mastodonUser?.avatarImageURL()) +// self.name = CurrentValueSubject(mastodonUser?.displayNameWithFallback) +// self.username = CurrentValueSubject(mastodonUser?.acctWithDomain) +// self.bioDescription = CurrentValueSubject(mastodonUser?.note) +// self.url = CurrentValueSubject(mastodonUser?.url) +// self.statusesCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.statusesCount) }) +// self.followingCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followingCount) }) +// self.followersCount = CurrentValueSubject(mastodonUser.flatMap { Int($0.followersCount) }) +// self.protected = CurrentValueSubject(mastodonUser?.locked) +// self.suspended = CurrentValueSubject(mastodonUser?.suspended ?? false) +// self.fields = CurrentValueSubject(mastodonUser?.fields ?? []) +// self.emojiMeta = CurrentValueSubject(mastodonUser?.emojis.asDictionary ?? [:]) + self.postsUserTimelineViewModel = UserTimelineViewModel( + context: context, + queryFilter: .init(excludeReplies: true) + ) + self.repliesUserTimelineViewModel = UserTimelineViewModel( + context: context, + queryFilter: .init(excludeReplies: true) + ) + self.mediaUserTimelineViewModel = UserTimelineViewModel( + context: context, + queryFilter: .init(onlyMedia: true) + ) + self.profileAboutViewModel = ProfileAboutViewModel(context: context) super.init() - relationshipActionOptionSet - .compactMap { $0.highPriorityAction(except: []) } - .map { $0 == .none } - .assign(to: \.value, on: isRelationshipActionButtonHidden) - .store(in: &disposeBag) - - // bind active authentication + // bind me context.authenticationService.activeMastodonAuthenticationBox + .receive(on: DispatchQueue.main) .sink { [weak self] authenticationBox in guard let self = self else { return } - guard let authenticationBox = authenticationBox else { - self.domain.value = nil - self.me = nil - return - } - self.domain.value = authenticationBox.domain - self.me = authenticationBox.authenticationRecord.object(in: context.managedObjectContext)?.user + self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user } .store(in: &disposeBag) - - // query relationship - let userRecord = $user.map { user -> ManagedObjectRecord? in - user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } - } - let pendingRetryPublisher = CurrentValueSubject(1) - - // observe friendship - Publishers.CombineLatest3( - userRecord, - context.authenticationService.activeMastodonAuthenticationBox, - pendingRetryPublisher - ) - .sink { [weak self] userRecord, authenticationBox, _ in - guard let self = self else { return } - guard let userRecord = userRecord, - let authenticationBox = authenticationBox - else { return } - Task { - do { - let response = try await self.updateRelationship( - record: userRecord, - authenticationBox: authenticationBox - ) - // there are seconds delay after request follow before requested -> following. Query again when needs - guard let relationship = response.value.first else { return } - if relationship.requested == true { - let delay = pendingRetryPublisher.value - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in - guard let _ = self else { return } - pendingRetryPublisher.value = min(2 * delay, 60) - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) - } - } - } catch { - self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)") - } + + // bind user + $user + .map { user -> UserIdentifier? in + guard let user = user else { return nil } + return MastodonUserIdentifier(domain: user.domain, userID: user.id) } - } - .store(in: &disposeBag) - - let isBlockingOrBlocked = Publishers.CombineLatest( - isBlocking, - isBlockedBy - ) - .map { $0 || $1 } - .share() + .assign(to: &$userIdentifier) + + $userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier) + $userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier) + $userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier) + // $userIdentifier.assign(to: &profileAboutViewModel.$userIdentifier) + +// relationshipActionOptionSet +// .compactMap { $0.highPriorityAction(except: []) } +// .map { $0 == .none } +// .assign(to: \.value, on: isRelationshipActionButtonHidden) +// .store(in: &disposeBag) +// - isBlockingOrBlocked - .map { !$0 } - .assign(to: \.value, on: needsPagingEnabled) - .store(in: &disposeBag) - - isBlockingOrBlocked - .map { $0 } - .assign(to: \.value, on: needsImageOverlayBlurred) - .store(in: &disposeBag) - - setup() +// +// // query relationship +// let userRecord = $user.map { user -> ManagedObjectRecord? in +// user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } +// } +// let pendingRetryPublisher = CurrentValueSubject(1) +// +// // observe friendship +// Publishers.CombineLatest3( +// userRecord, +// context.authenticationService.activeMastodonAuthenticationBox, +// pendingRetryPublisher +// ) +// .sink { [weak self] userRecord, authenticationBox, _ in +// guard let self = self else { return } +// guard let userRecord = userRecord, +// let authenticationBox = authenticationBox +// else { return } +// Task { +// do { +// let response = try await self.updateRelationship( +// record: userRecord, +// authenticationBox: authenticationBox +// ) +// // there are seconds delay after request follow before requested -> following. Query again when needs +// guard let relationship = response.value.first else { return } +// if relationship.requested == true { +// let delay = pendingRetryPublisher.value +// DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in +// guard let _ = self else { return } +// pendingRetryPublisher.value = min(2 * delay, 60) +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] fetch again due to pending", ((#file as NSString).lastPathComponent), #line, #function) +// } +// } +// } catch { +// self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): [Relationship] update user relationship failure: \(error.localizedDescription)") +// } +// } +// } +// .store(in: &disposeBag) +// +// let isBlockingOrBlocked = Publishers.CombineLatest( +// isBlocking, +// isBlockedBy +// ) +// .map { $0 || $1 } +// .share() +// +// isBlockingOrBlocked +// .map { !$0 } +// .assign(to: \.value, on: needsPagingEnabled) +// .store(in: &disposeBag) +// +// isBlockingOrBlocked +// .map { $0 } +// .assign(to: \.value, on: needsImageOverlayBlurred) +// .store(in: &disposeBag) +// +// setup() } } @@ -245,101 +277,101 @@ extension ProfileViewModel { } private func update(mastodonUser: MastodonUser?) { - self.userID.value = mastodonUser?.id - self.bannerImageURL.value = mastodonUser?.headerImageURL() - self.avatarImageURL.value = mastodonUser?.avatarImageURL() - self.name.value = mastodonUser?.displayNameWithFallback - self.username.value = mastodonUser?.acctWithDomain - self.bioDescription.value = mastodonUser?.note - self.url.value = mastodonUser?.url - self.statusesCount.value = mastodonUser.flatMap { Int($0.statusesCount) } - self.followingCount.value = mastodonUser.flatMap { Int($0.followingCount) } - self.followersCount.value = mastodonUser.flatMap { Int($0.followersCount) } - self.protected.value = mastodonUser?.locked - self.suspended.value = mastodonUser?.suspended ?? false - self.fields.value = mastodonUser?.fields ?? [] - self.emojiMeta.value = mastodonUser?.emojis.asDictionary ?? [:] +// self.userID.value = mastodonUser?.id +// self.bannerImageURL.value = mastodonUser?.headerImageURL() +// self.avatarImageURL.value = mastodonUser?.avatarImageURL() +// self.name.value = mastodonUser?.displayNameWithFallback +// self.username.value = mastodonUser?.acctWithDomain +// self.bioDescription.value = mastodonUser?.note +// self.url.value = mastodonUser?.url +// self.statusesCount.value = mastodonUser.flatMap { Int($0.statusesCount) } +// self.followingCount.value = mastodonUser.flatMap { Int($0.followingCount) } +// self.followersCount.value = mastodonUser.flatMap { Int($0.followersCount) } +// self.protected.value = mastodonUser?.locked +// self.suspended.value = mastodonUser?.suspended ?? false +// self.fields.value = mastodonUser?.fields ?? [] +// self.emojiMeta.value = mastodonUser?.emojis.asDictionary ?? [:] } private func update(mastodonUser: MastodonUser?, currentMastodonUser: MastodonUser?) { - guard let mastodonUser = mastodonUser, - let currentMastodonUser = currentMastodonUser else { - // set relationship - self.relationshipActionOptionSet.value = .none - self.isFollowedBy.value = false - self.isMuting.value = false - self.isBlocking.value = false - self.isBlockedBy.value = false - - // set bar button item state - self.isReplyBarButtonItemHidden.value = true - self.isMoreMenuBarButtonItemHidden.value = true - self.isMeBarButtonItemsHidden.value = true - return - } - - if mastodonUser == currentMastodonUser { - self.relationshipActionOptionSet.value = [.edit] - // set bar button item state - self.isReplyBarButtonItemHidden.value = true - self.isMoreMenuBarButtonItemHidden.value = true - self.isMeBarButtonItemsHidden.value = false - } else { - // set with follow action default - var relationshipActionSet = RelationshipActionOptionSet([.follow]) - - if mastodonUser.locked { - relationshipActionSet.insert(.request) - } - - if mastodonUser.suspended { - relationshipActionSet.insert(.suspended) - } - - let isFollowing = mastodonUser.followingBy.contains(currentMastodonUser) - if isFollowing { - relationshipActionSet.insert(.following) - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) - - let isPending = mastodonUser.followRequestedBy.contains(currentMastodonUser) - if isPending { - relationshipActionSet.insert(.pending) - } - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) - - let isFollowedBy = currentMastodonUser.followingBy.contains(mastodonUser) - self.isFollowedBy.value = isFollowedBy - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) - - let isMuting = mastodonUser.mutingBy.contains(currentMastodonUser) - if isMuting { - relationshipActionSet.insert(.muting) - } - self.isMuting.value = isMuting - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) - - let isBlocking = mastodonUser.blockingBy.contains(currentMastodonUser) - if isBlocking { - relationshipActionSet.insert(.blocking) - } - self.isBlocking.value = isBlocking - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) - - let isBlockedBy = currentMastodonUser.blockingBy.contains(mastodonUser) - if isBlockedBy { - relationshipActionSet.insert(.blocked) - } - self.isBlockedBy.value = isBlockedBy - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) - - self.relationshipActionOptionSet.value = relationshipActionSet - - // set bar button item state - self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy - self.isMoreMenuBarButtonItemHidden.value = false - self.isMeBarButtonItemsHidden.value = true - } +// guard let mastodonUser = mastodonUser, +// let currentMastodonUser = currentMastodonUser else { +// // set relationship +// self.relationshipActionOptionSet.value = .none +// self.isFollowedBy.value = false +// self.isMuting.value = false +// self.isBlocking.value = false +// self.isBlockedBy.value = false +// +// // set bar button item state +// self.isReplyBarButtonItemHidden.value = true +// self.isMoreMenuBarButtonItemHidden.value = true +// self.isMeBarButtonItemsHidden.value = true +// return +// } +// +// if mastodonUser == currentMastodonUser { +// self.relationshipActionOptionSet.value = [.edit] +// // set bar button item state +// self.isReplyBarButtonItemHidden.value = true +// self.isMoreMenuBarButtonItemHidden.value = true +// self.isMeBarButtonItemsHidden.value = false +// } else { +// // set with follow action default +// var relationshipActionSet = RelationshipActionOptionSet([.follow]) +// +// if mastodonUser.locked { +// relationshipActionSet.insert(.request) +// } +// +// if mastodonUser.suspended { +// relationshipActionSet.insert(.suspended) +// } +// +// let isFollowing = mastodonUser.followingBy.contains(currentMastodonUser) +// if isFollowing { +// relationshipActionSet.insert(.following) +// } +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowing: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowing.description) +// +// let isPending = mastodonUser.followRequestedBy.contains(currentMastodonUser) +// if isPending { +// relationshipActionSet.insert(.pending) +// } +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isPending: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isPending.description) +// +// let isFollowedBy = currentMastodonUser.followingBy.contains(mastodonUser) +// self.isFollowedBy.value = isFollowedBy +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isFollowedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isFollowedBy.description) +// +// let isMuting = mastodonUser.mutingBy.contains(currentMastodonUser) +// if isMuting { +// relationshipActionSet.insert(.muting) +// } +// self.isMuting.value = isMuting +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isMuting: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isMuting.description) +// +// let isBlocking = mastodonUser.blockingBy.contains(currentMastodonUser) +// if isBlocking { +// relationshipActionSet.insert(.blocking) +// } +// self.isBlocking.value = isBlocking +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlocking: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlocking.description) +// +// let isBlockedBy = currentMastodonUser.blockingBy.contains(mastodonUser) +// if isBlockedBy { +// relationshipActionSet.insert(.blocked) +// } +// self.isBlockedBy.value = isBlockedBy +// os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: [Relationship] update %s isBlockedBy: %s", ((#file as NSString).lastPathComponent), #line, #function, mastodonUser.id, isBlockedBy.description) +// +// self.relationshipActionOptionSet.value = relationshipActionSet +// +// // set bar button item state +// self.isReplyBarButtonItemHidden.value = isBlocking || isBlockedBy +// self.isMoreMenuBarButtonItemHidden.value = false +// self.isMeBarButtonItemsHidden.value = true +// } } } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift index 23630741f..4eca4268e 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift @@ -7,40 +7,42 @@ import os.log import UIKit -import Pageboy -import Tabman +import XLPagerTabStrip +import TabBarPager protocol ProfilePagingViewControllerDelegate: AnyObject { func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) } -final class ProfilePagingViewController: TabmanViewController { +final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController { + weak var tabBarPageViewDelegate: TabBarPageViewDelegate? weak var pagingDelegate: ProfilePagingViewControllerDelegate? + var viewModel: ProfilePagingViewModel! + // MARK: - TabBarPageViewController - // MARK: - PageboyViewControllerDelegate - override func pageboyViewController(_ pageboyViewController: PageboyViewController, didCancelScrollToPageAt index: PageboyViewController.PageIndex, returnToPageAt previousIndex: PageboyViewController.PageIndex) { - super.pageboyViewController(pageboyViewController, didCancelScrollToPageAt: index, returnToPageAt: previousIndex) - - // Fix the SDK bug for table view get row selected during swipe but cancel paging - guard previousIndex < viewModel.viewControllers.count else { return } - let viewController = viewModel.viewControllers[previousIndex] - - if let tableView = viewController.scrollView as? UITableView { - for cell in tableView.visibleCells { - cell.setHighlighted(false, animated: false) - } - } + var currentPage: TabBarPage? { + return viewModel.viewControllers[currentIndex] } - override func pageboyViewController(_ pageboyViewController: PageboyViewController, didScrollToPageAt index: TabmanViewController.PageIndex, direction: PageboyViewController.NavigationDirection, animated: Bool) { - super.pageboyViewController(pageboyViewController, didScrollToPageAt: index, direction: direction, animated: animated) + var currentPageIndex: Int? { + currentIndex + } + + // MARK: - ButtonBarPagerTabStripViewController + + override func viewControllers(for pagerTabStripController: PagerTabStripViewController) -> [UIViewController] { + return viewModel.viewControllers + } + + override func updateIndicator(for viewController: PagerTabStripViewController, fromIndex: Int, toIndex: Int, withProgressPercentage progressPercentage: CGFloat, indexWasChanged: Bool) { + super.updateIndicator(for: viewController, fromIndex: fromIndex, toIndex: toIndex, withProgressPercentage: progressPercentage, indexWasChanged: indexWasChanged) - let viewController = viewModel.viewControllers[index] - (viewController as? StatusTableViewControllerNavigateable)?.overrideNavigationScrollPosition = .top - pagingDelegate?.profilePagingViewController(self, didScrollToPostCustomScrollViewContainerController: viewController, atIndex: index) + guard indexWasChanged else { return } + let page = viewModel.viewControllers[toIndex] + tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex) } // make key commands works @@ -49,7 +51,7 @@ final class ProfilePagingViewController: TabmanViewController { } 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) } } @@ -59,8 +61,8 @@ extension ProfilePagingViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .clear - dataSource = viewModel +// view.backgroundColor = .clear +// dataSource = viewModel } override func viewDidAppear(_ animated: Bool) { @@ -74,17 +76,17 @@ extension ProfilePagingViewController { // workaround to fix tab man responder chain issue extension ProfilePagingViewController { - override var keyCommands: [UIKeyCommand]? { - return currentViewController?.keyCommands - } - - @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - (currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender) - - } - - @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - (currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender) - } +// override var keyCommands: [UIKeyCommand]? { +// return currentPage?.keyCommands +// } +// +// @objc func navigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// (currentViewController as? StatusTableViewControllerNavigateable)?.navigateKeyCommandHandlerRelay(sender) +// +// } +// +// @objc func statusKeyCommandHandlerRelay(_ sender: UIKeyCommand) { +// (currentViewController as? StatusTableViewControllerNavigateable)?.statusKeyCommandHandlerRelay(sender) +// } } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift index 67a0ca93d..f8174acde 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift +++ b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift @@ -7,10 +7,9 @@ import os.log import UIKit -import Pageboy -import Tabman import MastodonAsset import MastodonLocalization +import TabBarPager final class ProfilePagingViewModel: NSObject { @@ -32,7 +31,7 @@ final class ProfilePagingViewModel: NSObject { super.init() } - var viewControllers: [ScrollViewContainer] { + var viewControllers: [UIViewController & TabBarPage] { return [ postUserTimelineViewController, repliesUserTimelineViewController, @@ -41,42 +40,42 @@ final class ProfilePagingViewModel: NSObject { ] } - let barItems: [TMBarItemable] = { - let items = [ - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), - TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about), - ] - return items - }() +// let barItems: [TMBarItemable] = { +// let items = [ +// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.posts), +// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.postsAndReplies), +// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.media), +// TMBarItem(title: L10n.Scene.Profile.SegmentedControl.about), +// ] +// return items +// }() 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) } } -// MARK: - PageboyViewControllerDataSource -extension ProfilePagingViewModel: 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 ProfilePagingViewModel: TMBarDataSource { - func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { - return barItems[index] - } -} +//// MARK: - PageboyViewControllerDataSource +//extension ProfilePagingViewModel: 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 ProfilePagingViewModel: TMBarDataSource { +// func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { +// return barItems[index] +// } +//} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index d9e52a8c7..54f936f75 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -11,6 +11,8 @@ import AVKit import Combine import CoreDataStack import GameplayKit +import TabBarPager +import XLPagerTabStrip final class UserTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -143,7 +145,14 @@ extension UserTimelineViewController: UITableViewDelegate, AutoGenerateTableView // MARK: - CustomScrollViewContainerController extension UserTimelineViewController: ScrollViewContainer { - var scrollView: UIScrollView? { return tableView } + var scrollView: UIScrollView { return tableView } +} + +// MARK: - TabBarPage +extension UserTimelineViewController: TabBarPage { + var pageScrollView: UIScrollView { + scrollView + } } // MARK: - StatusTableViewCellDelegate @@ -165,3 +174,10 @@ extension UserTimelineViewController: StatusTableViewControllerNavigateable { statusKeyCommandHandler(sender) } } + +// MARK: - IndicatorInfoProvider +extension UserTimelineViewController: IndicatorInfoProvider { + func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { + return IndicatorInfo(title: "Hello") + } +} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index a0a1f52cd..0fc3368d1 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -30,17 +30,14 @@ extension UserTimelineViewModel { snapshot.appendSections([.main]) diffableDataSource?.apply(snapshot) - // trigger user timeline loading - Publishers.CombineLatest( - $domain.removeDuplicates(), - $userID.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] _ in - guard let self = self else { return } - self.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) - } - .store(in: &disposeBag) + // trigger timeline reloading + $userIdentifier + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self = self else { return } + self.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + .store(in: &disposeBag) let needsTimelineHidden = Publishers.CombineLatest3( isBlocking, diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index ae870f7b5..71213489f 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -50,7 +50,7 @@ extension UserTimelineViewModel.State { guard let viewModel = viewModel else { return false } switch stateClass { case is Reloading.Type: - return viewModel.userID != nil + return viewModel.userIdentifier != nil default: return false } @@ -132,7 +132,7 @@ extension UserTimelineViewModel.State { let maxID = viewModel.statusFetchedResultsController.statusIDs.value.last - guard let userID = viewModel.userID, !userID.isEmpty else { + guard let userID = viewModel.userIdentifier?.userID, !userID.isEmpty else { stateMachine.enter(Fail.self) return } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 9701ba480..8882990ea 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -19,8 +19,7 @@ final class UserTimelineViewModel { // input let context: AppContext - @Published var domain: String? - @Published var userID: String? + @Published var userIdentifier: UserIdentifier? @Published var queryFilter: QueryFilter let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() @@ -48,30 +47,25 @@ final class UserTimelineViewModel { init( context: AppContext, - domain: String?, - userID: String?, queryFilter: QueryFilter ) { self.context = context self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, - domain: domain, - additionalTweetPredicate: Status.notDeleted() + domain: nil, + additionalTweetPredicate: nil ) - self.domain = domain - self.userID = userID self.queryFilter = queryFilter // super.init() - $domain + context.authenticationService.activeMastodonAuthenticationBox + .map { $0?.domain } .assign(to: \.value, on: statusFetchedResultsController.domain) .store(in: &disposeBag) - - } 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) } } @@ -92,5 +86,4 @@ extension UserTimelineViewModel { self.onlyMedia = onlyMedia } } - } diff --git a/MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift b/MastodonSDK/Sources/MastodonUI/Helper/DateTimeProvider.swift similarity index 100% rename from MastodonSDK/Sources/MastodonUI/DateTimeProvider.swift rename to MastodonSDK/Sources/MastodonUI/Helper/DateTimeProvider.swift diff --git a/MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift b/MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift new file mode 100644 index 000000000..6db7499c6 --- /dev/null +++ b/MastodonSDK/Sources/MastodonUI/Model/UserIdentifier.swift @@ -0,0 +1,28 @@ +// +// UserIdentifier.swift +// +// +// Created by MainasuK on 2022-5-13. +// + +import Foundation +import MastodonSDK + +public protocol UserIdentifier { + var domain: String { get } + var userID: Mastodon.Entity.Account.ID { get } +} + +public struct MastodonUserIdentifier: UserIdentifier { + public let domain: String + public var userID: Mastodon.Entity.Account.ID + + + public init( + domain: String, + userID: Mastodon.Entity.Account.ID + ) { + self.domain = domain + self.userID = userID + } +} diff --git a/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift b/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift deleted file mode 100644 index ecde41d32..000000000 --- a/MastodonSDK/Sources/MastodonUI/UserIdentifier.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// UserIdentifier.swift -// -// -// Created by MainasuK on 2022-1-12. -// - -import Foundation -import MastodonSDK - -public protocol UserIdentifier { - var domain: String { get } - var userID: Mastodon.Entity.Account.ID { get } -} diff --git a/Podfile b/Podfile index 30f90a05e..a64cd0e55 100644 --- a/Podfile +++ b/Podfile @@ -8,6 +8,7 @@ target 'Mastodon' do # UI pod 'UITextField+Shake', '~> 1.2' + pod 'XLPagerTabStrip', '~> 9.0.0' # misc pod 'SwiftGen', '~> 6.4.0' diff --git a/Podfile.lock b/Podfile.lock index 0c156eadc..629a48a87 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -8,6 +8,7 @@ PODS: - Sourcery/CLI-Only (1.6.1) - SwiftGen (6.4.0) - "UITextField+Shake (1.2.1)" + - XLPagerTabStrip (9.0.0) DEPENDENCIES: - DateToolsSwift (~> 5.0.0) @@ -17,6 +18,7 @@ DEPENDENCIES: - Sourcery (~> 1.6.1) - SwiftGen (~> 6.4.0) - "UITextField+Shake (~> 1.2)" + - XLPagerTabStrip (~> 9.0.0) SPEC REPOS: trunk: @@ -26,6 +28,7 @@ SPEC REPOS: - Sourcery - SwiftGen - "UITextField+Shake" + - XLPagerTabStrip EXTERNAL SOURCES: Keys: @@ -39,7 +42,8 @@ SPEC CHECKSUMS: Sourcery: f3759f803bd0739f74fc92a4341eed0473ce61ac SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 + XLPagerTabStrip: 61c57fd61f611ee5f01ff1495ad6fbee8bf496c5 -PODFILE CHECKSUM: 335d0ca70493d4c280d0f8fd7f26fe9be6a4e289 +PODFILE CHECKSUM: 1ac960a2c981ef98f7c24a3bba57bdabc1f66103 COCOAPODS: 1.11.3