From ad63c512df0d00ce9e43aed16e041269c1232516 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 26 May 2022 23:19:47 +0800 Subject: [PATCH] fix: refactor the profile UI to fix internal AutoLayout crash issue. resolve #440 --- Mastodon.xcodeproj/project.pbxproj | 22 +- .../xcschemes/xcschememanagement.plist | 8 +- Mastodon/Coordinator/SceneCoordinator.swift | 1 + .../MediaPreviewViewController.swift | 12 + .../ProfileAboutViewModel+Diffable.swift | 24 +- .../Profile/About/ProfileAboutViewModel.swift | 63 +- .../Header/ProfileHeaderViewController.swift | 416 +++-- .../Header/ProfileHeaderViewModel.swift | 88 +- .../ProfileHeaderView+Configuration.swift | 56 + .../View/ProfileHeaderView+ViewModel.swift | 280 ++++ .../Header/View/ProfileHeaderView.swift | 98 +- .../Paging/ProfilePagingViewController.swift | 217 +++ .../Paging/ProfilePagingViewModel.swift | 37 +- .../Scene/Profile/ProfileViewController.swift | 1456 +++++++---------- Mastodon/Scene/Profile/ProfileViewModel.swift | 365 +---- .../Paging/ProfilePagingViewController.swift | 92 -- .../ProfileSegmentedViewController.swift | 39 - .../Timeline/UserTimelineViewController.swift | 2 +- .../UserTimelineViewModel+Diffable.swift | 6 +- .../UserTimelineViewModel+State.swift | 2 +- .../Timeline/UserTimelineViewModel.swift | 18 +- ...veStatusBarStyleNavigationController.swift | 4 +- .../ViewModel/RelationshipViewModel.swift | 11 +- 23 files changed, 1553 insertions(+), 1764 deletions(-) create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift create mode 100644 Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift create mode 100644 Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift rename Mastodon/Scene/Profile/{Segmented => }/Paging/ProfilePagingViewModel.swift (56%) delete mode 100644 Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift delete mode 100644 Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f0bc7406a..b3ab9234b 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -145,6 +145,8 @@ DB0EF72B26FDB1D200347686 /* SidebarListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72A26FDB1D200347686 /* SidebarListCollectionViewCell.swift */; }; DB0EF72E26FDB24F00347686 /* SidebarListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */; }; DB0F8150264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */; }; + DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */; }; + DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */; }; DB0FCB68279507EF006C02E2 /* DataSourceFacade+Meta.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */; }; DB0FCB6C27950E29006C02E2 /* MastodonMentionContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */; }; DB0FCB6E27950E6B006C02E2 /* MastodonMention.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */; }; @@ -508,7 +510,6 @@ DBB45B6027B50A4F002DC5A7 /* RecommendAccountItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */; }; DBB45B6227B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; - DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; DBB525212611EBD6002F1F29 /* ProfilePagingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */; }; DBB525302611EBF3002F1F29 /* ProfilePagingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */; }; DBB525362611ECEB002F1F29 /* UserTimelineViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */; }; @@ -889,6 +890,8 @@ DB0EF72D26FDB24F00347686 /* SidebarListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarListContentView.swift; sourceTree = ""; }; DB0F814E264CFFD300F2A12B /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/InfoPlist.strings; sourceTree = ""; }; DB0F814F264D1E2500F2A12B /* PickServerLoaderTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickServerLoaderTableViewCell.swift; sourceTree = ""; }; + DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+ViewModel.swift"; sourceTree = ""; }; + DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProfileHeaderView+Configuration.swift"; sourceTree = ""; }; DB0FCB67279507EF006C02E2 /* DataSourceFacade+Meta.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Meta.swift"; sourceTree = ""; }; DB0FCB6B27950E29006C02E2 /* MastodonMentionContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMentionContainer.swift; sourceTree = ""; }; DB0FCB6D27950E6B006C02E2 /* MastodonMention.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMention.swift; sourceTree = ""; }; @@ -1286,7 +1289,6 @@ DBB45B5A27B3A109002DC5A7 /* MediaPreviewTransitionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPreviewTransitionViewController.swift; sourceTree = ""; }; DBB45B5F27B50A4F002DC5A7 /* RecommendAccountItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendAccountItem.swift; sourceTree = ""; }; DBB45B6127B51112002DC5A7 /* SuggestionAccountViewModel+Diffable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionAccountViewModel+Diffable.swift"; sourceTree = ""; }; - DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; DBB5252F2611EBF3002F1F29 /* ProfilePagingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewModel.swift; sourceTree = ""; }; DBB525352611ECEB002F1F29 /* UserTimelineViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserTimelineViewController.swift; sourceTree = ""; }; @@ -3006,8 +3008,8 @@ DB9D6C0825E4F5A60051B173 /* Profile */ = { isa = PBXGroup; children = ( - DBB525132611EBB1002F1F29 /* Segmented */, DBB525462611ED57002F1F29 /* Header */, + DBB525262611EBDA002F1F29 /* Paging */, DBB5253B2611ECF5002F1F29 /* Timeline */, DBE3CDF1261C6B3100430CC6 /* Favorite */, DB6B74F0272FB55400C70B6E /* Follower */, @@ -3106,15 +3108,6 @@ path = Video; sourceTree = ""; }; - DBB525132611EBB1002F1F29 /* Segmented */ = { - isa = PBXGroup; - children = ( - DBB525262611EBDA002F1F29 /* Paging */, - DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */, - ); - path = Segmented; - sourceTree = ""; - }; DBB525262611EBDA002F1F29 /* Paging */ = { isa = PBXGroup; children = ( @@ -3150,6 +3143,8 @@ isa = PBXGroup; children = ( DBB5254F2611ED6D002F1F29 /* ProfileHeaderView.swift */, + DB0F9D53283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift */, + DB0F9D55283EB46200379AF8 /* ProfileHeaderView+Configuration.swift */, DBF98149265E24F500E4BA07 /* ProfileFieldCollectionViewHeaderFooterView.swift */, ); path = View; @@ -4041,7 +4036,6 @@ DB0FCB7C2795821F006C02E2 /* StatusThreadRootTableViewCell.swift in Sources */, DB72601C25E36A2100235243 /* MastodonServerRulesViewController.swift in Sources */, DB5B54A32833BD1A00DEF8B2 /* UserListViewModel.swift in Sources */, - DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */, DBF156DF2701B17600EC00B7 /* SidebarAddAccountCollectionViewCell.swift in Sources */, DB0617F1278413D00030EE79 /* PickServerServerSectionTableHeaderView.swift in Sources */, DB0FCB7E27958957006C02E2 /* StatusThreadRootTableViewCell+ViewModel.swift in Sources */, @@ -4398,6 +4392,7 @@ DB67D08627312E67006A36CF /* WizardViewController.swift in Sources */, DB6746EB278ED8B0008A6B94 /* PollOptionView+Configuration.swift in Sources */, DB9A489026035963008B817C /* APIService+Media.swift in Sources */, + DB0F9D54283EB3C000379AF8 /* ProfileHeaderView+ViewModel.swift in Sources */, DBFEEC99279BDCDE004F81DD /* ProfileAboutViewModel.swift in Sources */, 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */, DB4F097B26A039FF00D62E92 /* SearchHistorySection.swift in Sources */, @@ -4406,6 +4401,7 @@ 2D5A3D6225CFD9CB002347D6 /* HomeTimelineViewController+DebugAction.swift in Sources */, DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */, + DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */, DB49A62525FF334C00B98345 /* EmojiService+CustomEmojiViewModel+LoadState.swift in Sources */, DB4924E226312AB200E9DB22 /* NotificationService.swift in Sources */, DB6D9F6F2635807F008423CD /* Setting.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index 3e2139aa3..6817fbe8e 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -24,7 +24,7 @@ Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 8 + 7 Mastodon - Release.xcscheme_^#shared#^_ @@ -114,7 +114,7 @@ MastodonIntent.xcscheme_^#shared#^_ orderHint - 28 + 23 MastodonIntents.xcscheme_^#shared#^_ @@ -129,12 +129,12 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 29 + 22 ShareActionExtension.xcscheme_^#shared#^_ orderHint - 27 + 24 SuppressBuildableAutocreation diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 9df3040c7..4a4b43407 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -342,6 +342,7 @@ extension SceneCoordinator { case .custom(let transitioningDelegate): viewController.modalPresentationStyle = .custom viewController.transitioningDelegate = transitioningDelegate + // viewController.modalPresentationCapturesStatusBarAppearance = true (splitViewController ?? presentingViewController)?.present(viewController, animated: true, completion: nil) case .customPush(let animated): diff --git a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift index ae55134c4..e1e367e37 100644 --- a/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift +++ b/Mastodon/Scene/MediaPreview/MediaPreviewViewController.swift @@ -135,6 +135,18 @@ extension MediaPreviewViewController { } } .store(in: &disposeBag) + +// viewModel.$isPoping +// .receive(on: DispatchQueue.main) +// .removeDuplicates() +// .sink { [weak self] _ in +// guard let self = self else { return } +// // statusBar style update with animation +// self.setNeedsStatusBarAppearanceUpdate() +// UIView.animate(withDuration: 0.3) { +// } +// } +// .store(in: &disposeBag) } } diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift index 259cad12d..0a11a71f6 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift @@ -25,7 +25,8 @@ extension ProfileAboutViewModel { profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate ) ) - + self.diffableDataSource = diffableDataSource + diffableDataSource.reorderingHandlers.canReorderItem = { item -> Bool in switch item { case .editField: return true @@ -42,22 +43,25 @@ extension ProfileAboutViewModel { guard case let .editField(field) = item else { continue } fields.append(field) } - self.editProfileInfo.fields = fields + self.profileInfoEditing.fields = fields } - self.diffableDataSource = diffableDataSource + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + diffableDataSource.apply(snapshot) Publishers.CombineLatest4( $isEditing.removeDuplicates(), - displayProfileInfo.$fields.removeDuplicates(), - editProfileInfo.$fields.removeDuplicates(), + profileInfo.$fields.removeDuplicates(), + profileInfoEditing.$fields.removeDuplicates(), $emojiMeta.removeDuplicates() ) .throttle(for: 0.3, scheduler: DispatchQueue.main, latest: true) .sink { [weak self] isEditing, displayFields, editingFields, emojiMeta in guard let self = self else { return } guard let diffableDataSource = self.diffableDataSource else { return } - + var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) @@ -69,17 +73,17 @@ extension ProfileAboutViewModel { return ProfileFieldItem.field(field: field) } } - + if isEditing, fields.count < ProfileHeaderViewModel.maxProfileFieldCount { items.append(.addEntry) } - + if !isEditing, items.isEmpty { items.append(.noResult) } - + snapshot.appendItems(items, toSection: .main) - + diffableDataSource.apply(snapshot, animatingDifferences: false, completion: nil) } .store(in: &disposeBag) diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift index c7ef895dd..8498c6866 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreDataStack import MastodonSDK import MastodonMeta import Kanna @@ -18,41 +19,69 @@ final class ProfileAboutViewModel { // input let context: AppContext + @Published var user: MastodonUser? @Published var isEditing = false @Published var accountForEdit: Mastodon.Entity.Account? - @Published var emojiMeta: MastodonContent.Emojis = [:] // output var diffableDataSource: UICollectionViewDiffableDataSource? + let profileInfo = ProfileInfo() + let profileInfoEditing = ProfileInfo() - let displayProfileInfo = ProfileInfo() - let editProfileInfo = ProfileInfo() - let editProfileInfoDidInitialized = CurrentValueSubject(Void()) // needs trigger initial event + @Published var fields: [MastodonField] = [] + @Published var emojiMeta: MastodonContent.Emojis = [:] init(context: AppContext) { self.context = context // end init + $user + .compactMap { $0 } + .flatMap { $0.publisher(for: \.emojis) } + .map { $0.asDictionary } + .assign(to: &$emojiMeta) + + $user + .compactMap { $0 } + .flatMap { $0.publisher(for: \.fields) } + .assign(to: &$fields) + Publishers.CombineLatest( - $isEditing.removeDuplicates(), // only trigger when value toggle - $accountForEdit + $fields, + $emojiMeta + ) + .map { fields, emojiMeta in + fields.map { ProfileFieldItem.FieldValue(name: $0.name, value: $0.value, emojiMeta: emojiMeta) } + } + .assign(to: &profileInfo.$fields) + + Publishers.CombineLatest( + $accountForEdit, + $emojiMeta ) .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, account in + .sink { [weak self] account, emojiMeta in guard let self = self else { return } - guard isEditing else { return } + guard let account = account else { return } - // setup editing value when toggle to editing - self.editProfileInfo.fields = account?.source?.fields?.compactMap { field in + self.profileInfo.fields = account.source?.fields?.compactMap { field in + ProfileFieldItem.FieldValue( + name: field.name, + value: field.value, + emojiMeta: emojiMeta + ) + } ?? [] + + self.profileInfoEditing.fields = account.source?.fields?.compactMap { field in ProfileFieldItem.FieldValue( name: field.name, value: field.value, emojiMeta: [:] // no use for editing ) } ?? [] - self.editProfileInfoDidInitialized.send() } .store(in: &disposeBag) + } } @@ -65,31 +94,31 @@ extension ProfileAboutViewModel { extension ProfileAboutViewModel { func appendFieldItem() { - var fields = editProfileInfo.fields + var fields = profileInfoEditing.fields guard fields.count < ProfileHeaderViewModel.maxProfileFieldCount else { return } fields.append(ProfileFieldItem.FieldValue(name: "", value: "", emojiMeta: [:])) - editProfileInfo.fields = fields + profileInfoEditing.fields = fields } func removeFieldItem(item: ProfileFieldItem) { - var fields = editProfileInfo.fields + var fields = profileInfoEditing.fields guard case let .editField(field) = item else { return } guard let removeIndex = fields.firstIndex(of: field) else { return } fields.remove(at: removeIndex) - editProfileInfo.fields = fields + profileInfoEditing.fields = fields } } // MARK: - ProfileViewModelEditable extension ProfileAboutViewModel: ProfileViewModelEditable { - func isEdited() -> Bool { + var isEdited: Bool { guard isEditing else { return false } let isFieldsEqual: Bool = { let originalFields = self.accountForEdit?.source?.fields?.compactMap { field in ProfileFieldItem.FieldValue(name: field.name, value: field.value, emojiMeta: [:]) } ?? [] - let editFields = editProfileInfo.fields + let editFields = profileInfoEditing.fields guard editFields.count == originalFields.count else { return false } for (editField, originalField) in zip(editFields, originalFields) { guard editField.name.value == originalField.name.value, diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index 984ba44fb..f35ac6aa4 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -8,6 +8,7 @@ import os.log import UIKit import Combine +import CoreDataStack import PhotosUI import AlamofireImage import CropViewController @@ -18,19 +19,27 @@ import MastodonLocalization import TabBarPager protocol ProfileHeaderViewControllerDelegate: AnyObject { - func profileHeaderViewController(_ viewController: ProfileHeaderViewController, viewLayoutDidUpdate view: UIView) + func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: ProfileRelationshipActionButton) + func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) } -final class ProfileHeaderViewController: UIViewController { +final class ProfileHeaderViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { + + let logger = Logger(subsystem: "ProfileHeaderViewController", category: "ViewController") static let segmentedControlHeight: CGFloat = 50 static let headerMinHeight: CGFloat = segmentedControlHeight + weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } + weak var coordinator: SceneCoordinator! { willSet { precondition(!isViewLoaded) } } + var disposeBag = Set() + var viewModel: ProfileHeaderViewModel! + weak var delegate: ProfileHeaderViewControllerDelegate? weak var headerDelegate: TabBarPagerHeaderDelegate? - var viewModel: ProfileHeaderViewModel! + let mediaPreviewTransitionController = MediaPreviewTransitionController() let titleView: DoubleTitleLabelNavigationBarTitleView = { let titleView = DoubleTitleLabelNavigationBarTitleView() @@ -44,39 +53,8 @@ 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 -// }() -// 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 +// private var isBannerPinned = false // private var isAdjustBannerImageViewForSafeAreaInset = false private var containerSafeAreaInset: UIEdgeInsets = .zero @@ -104,7 +82,7 @@ final class ProfileHeaderViewController: UIViewController { }() 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) } } @@ -114,7 +92,7 @@ extension ProfileHeaderViewController { override func viewDidLoad() { super.viewDidLoad() -// customizeButtonBarAppearance() + view.setContentHuggingPriority(.required - 1, for: .vertical) view.backgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor ThemeService.shared.currentTheme @@ -125,6 +103,7 @@ extension ProfileHeaderViewController { } .store(in: &disposeBag) +// profileHeaderView.preservesSuperviewLayoutMargins = true profileHeaderView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(profileHeaderView) NSLayoutConstraint.activate([ @@ -133,130 +112,64 @@ extension ProfileHeaderViewController { profileHeaderView.trailingAnchor.constraint(equalTo: view.trailingAnchor), view.bottomAnchor.constraint(equalTo: profileHeaderView.bottomAnchor), ]) - profileHeaderView.preservesSuperviewLayoutMargins = true - - Publishers.CombineLatest( - viewModel.viewDidAppear.eraseToAnyPublisher(), - viewModel.isTitleViewContentOffsetSet.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] viewDidAppear, isTitleViewContentOffsetDidSet in - guard let self = self else { return } - self.titleView.titleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0 - self.titleView.subtitleLabel.alpha = viewDidAppear && isTitleViewContentOffsetDidSet ? 1 : 0 - } - .store(in: &disposeBag) - - viewModel.needsSetupBottomShadow - .receive(on: DispatchQueue.main) - .sink { [weak self] needsSetupBottomShadow in - guard let self = self else { return } - self.setupBottomShadow() - } - .store(in: &disposeBag) - - Publishers.CombineLatest4( - viewModel.$isEditing.eraseToAnyPublisher(), - viewModel.displayProfileInfo.$avatarImageResource.eraseToAnyPublisher(), - viewModel.editProfileInfo.$avatarImageResource.eraseToAnyPublisher(), - viewModel.viewDidAppear.eraseToAnyPublisher() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, displayResource, editingResource, _ in - guard let self = self else { return } - - let url = displayResource.url - let image = editingResource.image - - self.profileHeaderView.avatarButton.avatarImageView.configure( - configuration: AvatarImageView.Configuration( - url: isEditing && image != nil ? nil : url, - placeholder: image ?? UIImage.placeholder(color: Asset.Theme.Mastodon.systemGroupedBackground.color) - ) - ) - } - .store(in: &disposeBag) - Publishers.CombineLatest4( - viewModel.$isEditing, - viewModel.displayProfileInfo.$name.removeDuplicates(), - viewModel.editProfileInfo.$name.removeDuplicates(), - viewModel.$emojiMeta - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, name, editingName, emojiMeta in - guard let self = self else { return } - do { - let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta) - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.profileHeaderView.nameMetaText.configure(content: metaContent) - } catch { - assertionFailure() - } - self.profileHeaderView.nameTextField.text = isEditing ? editingName : name - } - .store(in: &disposeBag) - - let profileNote = Publishers.CombineLatest3( - viewModel.$isEditing.removeDuplicates(), - viewModel.displayProfileInfo.$note.removeDuplicates(), - viewModel.editProfileInfoDidInitialized - ) - .map { isEditing, displayNote, _ -> String? in - if isEditing { - return self.viewModel.editProfileInfo.note - } else { - return displayNote - } - } - .eraseToAnyPublisher() - - Publishers.CombineLatest3( - viewModel.$isEditing.removeDuplicates(), - profileNote.removeDuplicates(), - viewModel.$emojiMeta.removeDuplicates() - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, note, emojiMeta in - guard let self = self else { return } - - self.profileHeaderView.bioMetaText.textView.isEditable = isEditing - - if isEditing { - let metaContent = PlaintextMetaContent(string: note ?? "") - self.profileHeaderView.bioMetaText.configure(content: metaContent) - } else { - let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta) - do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - self.profileHeaderView.bioMetaText.configure(content: metaContent) - } catch { - assertionFailure() - self.profileHeaderView.bioMetaText.reset() - } - } - } - .store(in: &disposeBag) - profileHeaderView.bioMetaText.delegate = self - + NotificationCenter.default.publisher(for: UITextField.textDidChangeNotification, object: profileHeaderView.nameTextField) .receive(on: DispatchQueue.main) .sink { [weak self] notification in guard let self = self else { return } guard let textField = notification.object as? UITextField else { return } - self.viewModel.editProfileInfo.name = textField.text + self.viewModel.profileInfoEditing.name = textField.text } .store(in: &disposeBag) - profileHeaderView.editAvatarButton.menu = createAvatarContextMenu() - profileHeaderView.editAvatarButton.showsMenuAsPrimaryAction = true + profileHeaderView.editAvatarButtonOverlayIndicatorView.menu = createAvatarContextMenu() + profileHeaderView.editAvatarButtonOverlayIndicatorView.showsMenuAsPrimaryAction = true + profileHeaderView.delegate = self + + // bind viewModel + viewModel.$isTitleViewContentOffsetSet + .receive(on: DispatchQueue.main) + .sink { [weak self] isTitleViewContentOffsetDidSet in + guard let self = self else { return } + self.titleView.titleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 + self.titleView.subtitleLabel.alpha = isTitleViewContentOffsetDidSet ? 1 : 0 + } + .store(in: &disposeBag) + viewModel.$user + .receive(on: DispatchQueue.main) + .sink { [weak self] user in + guard let self = self else { return } + guard let user = user else { return } + self.profileHeaderView.prepareForReuse() + self.profileHeaderView.configuration(user: user) + } + .store(in: &disposeBag) + viewModel.$relationshipActionOptionSet + .assign(to: \.relationshipActionOptionSet, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.$isEditing + .assign(to: \.isEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.$isUpdating + .assign(to: \.isUpdating, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.profileInfoEditing.$avatar + .assign(to: \.avatarImageEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.profileInfoEditing.$name + .assign(to: \.nameEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) + viewModel.profileInfoEditing.$note + .assign(to: \.noteEditing, on: profileHeaderView.viewModel) + .store(in: &disposeBag) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - viewModel.viewDidAppear.value = true - + profileHeaderView.viewModel.viewDidAppear.send() + // set display after view appear profileHeaderView.setupAvatarOverlayViews() } @@ -264,19 +177,7 @@ extension ProfileHeaderViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - switch UIApplication.shared.applicationState { - case .active: - headerDelegate?.viewLayoutDidUpdate(self) - setupBottomShadow() - default: - break - } - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - -// customizeButtonBarAppearance() + headerDelegate?.viewLayoutDidUpdate(self) } } @@ -328,80 +229,28 @@ extension ProfileHeaderViewController { containerSafeAreaInset = inset } - func setupBottomShadow() { - guard viewModel.needsSetupBottomShadow.value else { - view.layer.shadowColor = nil - view.layer.shadowRadius = 0 - return - } - view.layer.setupShadow(color: UIColor.black.withAlphaComponent(0.12), alpha: Float(bottomShadowAlpha), x: 0, y: 2, blur: 2, spread: 0, roundedRect: view.bounds, byRoundingCorners: .allCorners, cornerRadii: .zero) - } - - private func updateHeaderBottomShadow(progress: CGFloat) { - let alpha = min(max(0, 10 * progress - 9), 1) - if bottomShadowAlpha != alpha { - bottomShadowAlpha = alpha - view.setNeedsLayout() + 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) + + // 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 = transformY < titleView.containerView.frame.height + viewModel.isTitleViewContentOffsetSet = true + + if progress > 0, throttle > 0 { + // y = 1 - (x/t) + // give: x = 0, y = 1 + // x = t, y = 0 + let alpha = 1 - progress/throttle + 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 @@ -411,6 +260,103 @@ extension ProfileHeaderViewController { } +// MARK: - ProfileHeaderViewDelegate +extension ProfileHeaderViewController: 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 + ) { + delegate?.profileHeaderViewController( + self, + profileHeaderView: profileHeaderView, + relationshipButtonDidPressed: button + ) + } + + func profileHeaderView(_ profileHeaderView: ProfileHeaderView, metaTextView: MetaTextView, metaDidPressed meta: Meta) { + delegate?.profileHeaderViewController( + self, + profileHeaderView: profileHeaderView, + metaTextView: metaTextView, + metaDidPressed: 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.user?.domain, + let userID = viewModel.user?.id + 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.user?.domain, + let userID = viewModel.user?.id + else { return } + let followingListViewModel = FollowingListViewModel( + context: context, + domain: domain, + userID: userID + ) + coordinator.present( + scene: .following(viewModel: followingListViewModel), + from: self, + transition: .show + ) + } + } + +} + // MARK: - MetaTextDelegate extension ProfileHeaderViewController: MetaTextDelegate { func metaText(_ metaText: MetaText, processEditing textStorage: MetaTextStorage) -> MetaContent? { @@ -419,7 +365,9 @@ extension ProfileHeaderViewController: MetaTextDelegate { switch metaText { case profileHeaderView.bioMetaText: guard viewModel.isEditing else { break } - viewModel.editProfileInfo.note = metaText.backedString + defer { + viewModel.profileInfoEditing.note = metaText.backedString + } let metaContent = PlaintextMetaContent(string: metaText.backedString) return metaContent default: @@ -491,7 +439,7 @@ extension ProfileHeaderViewController: UIDocumentPickerDelegate { // MARK: - CropViewControllerDelegate extension ProfileHeaderViewController: CropViewControllerDelegate { public func cropViewController(_ cropViewController: CropViewController, didCropToImage image: UIImage, withRect cropRect: CGRect, angle: Int) { - viewModel.editProfileInfo.avatarImage = image + viewModel.profileInfoEditing.avatar = image cropViewController.dismiss(animated: true, completion: nil) } } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index 8bdce2a6d..e28b250cf 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -8,9 +8,11 @@ import os.log import UIKit import Combine +import CoreDataStack import Kanna import MastodonSDK import MastodonMeta +import MastodonUI final class ProfileHeaderViewModel { @@ -21,39 +23,44 @@ final class ProfileHeaderViewModel { // input let context: AppContext + @Published var user: MastodonUser? + @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none + @Published var isEditing = false - @Published var accountForEdit: Mastodon.Entity.Account? - @Published var emojiMeta: MastodonContent.Emojis = [:] + @Published var isUpdating = false - let viewDidAppear = CurrentValueSubject(false) - let needsSetupBottomShadow = CurrentValueSubject(true) - let needsFiledCollectionViewHidden = CurrentValueSubject(false) - let isTitleViewContentOffsetSet = CurrentValueSubject(false) + @Published var accountForEdit: Mastodon.Entity.Account? + +// let needsFiledCollectionViewHidden = CurrentValueSubject(false) // output - let isTitleViewDisplaying = CurrentValueSubject(false) - let displayProfileInfo = ProfileInfo() - let editProfileInfo = ProfileInfo() - let editProfileInfoDidInitialized = CurrentValueSubject(Void()) // needs trigger initial event + let profileInfo = ProfileInfo() + let profileInfoEditing = ProfileInfo() + + @Published var isTitleViewDisplaying = false + @Published var isTitleViewContentOffsetSet = false init(context: AppContext) { self.context = context - Publishers.CombineLatest( - $isEditing.removeDuplicates(), // only trigger when value toggle - $accountForEdit - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing, account in - guard let self = self else { return } - guard isEditing else { return } - // setup editing value when toggle to editing - self.editProfileInfo.name = self.displayProfileInfo.name // set to name - self.editProfileInfo.avatarImage = nil // set to empty - self.editProfileInfo.note = ProfileHeaderViewModel.normalize(note: self.displayProfileInfo.note) - self.editProfileInfoDidInitialized.send() - } - .store(in: &disposeBag) + $accountForEdit + .receive(on: DispatchQueue.main) + .sink { [weak self] account in + guard let self = self else { return } + guard let account = account else { return } + // avatar + self.profileInfo.avatar = nil + self.profileInfoEditing.avatar = nil + // name + let name = account.displayNameWithFallback + self.profileInfo.name = name + self.profileInfoEditing.name = name + // bio + let note = ProfileHeaderViewModel.normalize(note: account.note) + self.profileInfo.note = note + self.profileInfoEditing.note = note + } + .store(in: &disposeBag) } } @@ -61,29 +68,9 @@ final class ProfileHeaderViewModel { extension ProfileHeaderViewModel { class ProfileInfo { // input + @Published var avatar: UIImage? @Published var name: String? - @Published var avatarImageURL: URL? - @Published var avatarImage: UIImage? @Published var note: String? - - // output - @Published var avatarImageResource = ImageResource(url: nil, image: nil) - - struct ImageResource { - let url: URL? - let image: UIImage? - } - - init() { - Publishers.CombineLatest( - $avatarImageURL, - $avatarImage - ) - .map { url, image in - ImageResource(url: url, image: image) - } - .assign(to: &$avatarImageResource) - } } } @@ -103,15 +90,14 @@ extension ProfileHeaderViewModel { } - // MARK: - ProfileViewModelEditable extension ProfileHeaderViewModel: ProfileViewModelEditable { - func isEdited() -> Bool { + var isEdited: Bool { guard isEditing else { return false } - guard editProfileInfo.name == displayProfileInfo.name else { return true } - guard editProfileInfo.avatarImage == nil else { return true } - guard editProfileInfo.note == ProfileHeaderViewModel.normalize(note: displayProfileInfo.note) else { return true } + guard profileInfoEditing.avatar == nil else { return true } + guard profileInfo.name == profileInfoEditing.name else { return true } + guard profileInfo.note == profileInfoEditing.note else { return true } return false } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift new file mode 100644 index 000000000..aa5c32ab9 --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+Configuration.swift @@ -0,0 +1,56 @@ +// +// ProfileHeaderView+Configuration.swift +// Mastodon +// +// Created by MainasuK on 2022-5-26. +// + +import os.log +import UIKit +import Combine +import CoreDataStack + +extension ProfileHeaderView { + func configuration(user: MastodonUser) { + // header + user.publisher(for: \.header) + .map { _ in user.headerImageURL() } + .assign(to: \.headerImageURL, on: viewModel) + .store(in: &disposeBag) + // avatar + user.publisher(for: \.avatar) + .map { _ in user.avatarImageURL() } + .assign(to: \.avatarImageURL, on: viewModel) + .store(in: &disposeBag) + // emojiMeta + user.publisher(for: \.emojis) + .map { $0.asDictionary } + .assign(to: \.emojiMeta, on: viewModel) + .store(in: &disposeBag) + // name + user.publisher(for: \.displayName) + .map { _ in user.displayNameWithFallback } + .assign(to: \.name, on: viewModel) + .store(in: &disposeBag) + // username + viewModel.username = user.username + // bio + user.publisher(for: \.note) + .assign(to: \.note, on: viewModel) + .store(in: &disposeBag) + // dashboard + user.publisher(for: \.statusesCount) + .map { Int($0) } + .assign(to: \.statusesCount, on: viewModel) + .store(in: &disposeBag) + user.publisher(for: \.followingCount) + .map { Int($0) } + .assign(to: \.followingCount, on: viewModel) + .store(in: &disposeBag) + user.publisher(for: \.followersCount) + .map { Int($0) } + .assign(to: \.followersCount, on: viewModel) + .store(in: &disposeBag) + } +} + diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift new file mode 100644 index 000000000..f49abd8ec --- /dev/null +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView+ViewModel.swift @@ -0,0 +1,280 @@ +// +// ProfileHeaderView+ViewModel.swift +// Mastodon +// +// Created by MainasuK on 2022-5-26. +// + +import os.log +import UIKit +import Combine +import CoreDataStack +import MetaTextKit +import MastodonMeta +import MastodonUI +import MastodonAsset +import MastodonLocalization + +extension ProfileHeaderView { + class ViewModel: ObservableObject { + var disposeBag = Set() + + let viewDidAppear = PassthroughSubject() + + @Published var state: State? + @Published var isEditing = false + @Published var isUpdating = false + + @Published var emojiMeta: MastodonContent.Emojis = [:] + @Published var headerImageURL: URL? + @Published var avatarImageURL: URL? + @Published var avatarImageEditing: UIImage? + + @Published var name: String? + @Published var nameEditing: String? + + @Published var username: String? + + @Published var note: String? + @Published var noteEditing: String? + + @Published var statusesCount: Int? + @Published var followingCount: Int? + @Published var followersCount: Int? + + @Published var fields: [MastodonField] = [] + + @Published var relationshipActionOptionSet: RelationshipActionOptionSet = .none + @Published var isRelationshipActionButtonHidden = false + + init() { + $relationshipActionOptionSet + .compactMap { $0.highPriorityAction(except: []) } + .map { $0 == .none } + .assign(to: &$isRelationshipActionButtonHidden) + } + } +} + +extension ProfileHeaderView.ViewModel { + + func bind(view: ProfileHeaderView) { + // header + Publishers.CombineLatest( + $headerImageURL, + viewDidAppear + ) + .sink { headerImageURL, _ in + view.bannerImageView.af.cancelImageRequest() + let placeholder = UIImage.placeholder(color: ProfileHeaderView.bannerImageViewPlaceholderColor) + guard let bannerImageURL = headerImageURL else { + view.bannerImageView.image = placeholder + return + } + view.bannerImageView.af.setImage( + withURL: bannerImageURL, + placeholderImage: placeholder, + imageTransition: .crossDissolve(0.3), + runImageTransitionIfCached: false, + completion: { [weak view] response in + guard let view = view 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 + view.bannerImageView.image = placeholder + return + } + } + ) + } + .store(in: &disposeBag) + // avatar + Publishers.CombineLatest4( + $avatarImageURL, + $avatarImageEditing, + $isEditing, + viewDidAppear + ) + .sink { avatarImageURL, avatarImageEditing, isEditing, _ in + view.avatarButton.avatarImageView.configure(configuration: .init( + url: (!isEditing || avatarImageEditing == nil) ? avatarImageURL : nil, + placeholder: isEditing ? (avatarImageEditing ?? AvatarImageView.placeholder) : AvatarImageView.placeholder + )) + } + .store(in: &disposeBag) + // blur + $relationshipActionOptionSet + .map { $0.contains(.blocking) || $0.contains(.blockingBy) } + .sink { needsImageOverlayBlurred in + UIView.animate(withDuration: 0.33) { + let bannerEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.bannerImageViewOverlayBlurEffect : nil + view.bannerImageViewOverlayVisualEffectView.effect = bannerEffect + let avatarEffect: UIVisualEffect? = needsImageOverlayBlurred ? ProfileHeaderView.avatarImageViewOverlayBlurEffect : nil + view.avatarImageViewOverlayVisualEffectView.effect = avatarEffect + } + } + .store(in: &disposeBag) + // name + Publishers.CombineLatest4( + $isEditing.removeDuplicates(), + $name.removeDuplicates(), + $nameEditing.removeDuplicates(), + $emojiMeta.removeDuplicates() + ) + .sink { isEditing, name, nameEditing, emojiMeta in + do { + let mastodonContent = MastodonContent(content: name ?? " ", emojis: emojiMeta) + let metaContent = try MastodonMetaContent.convert(document: mastodonContent) + view.nameMetaText.configure(content: metaContent) + } catch { + assertionFailure() + } + view.nameTextField.text = isEditing ? nameEditing : name + } + .store(in: &disposeBag) + // username + $username + .map { username in username.flatMap { "@" + $0 } ?? " " } + .assign(to: \.text, on: view.usernameLabel) + .store(in: &disposeBag) + // bio + Publishers.CombineLatest4( + $isEditing.removeDuplicates(), + $emojiMeta.removeDuplicates(), + $note.removeDuplicates(), + $noteEditing.removeDuplicates() + ) + .sink { isEditing, emojiMeta, note, noteEditing in + view.bioMetaText.textView.isEditable = isEditing + + let metaContent: MetaContent = { + if isEditing { + return PlaintextMetaContent(string: noteEditing ?? "") + } else { + do { + let mastodonContent = MastodonContent(content: note ?? "", emojis: emojiMeta) + return try MastodonMetaContent.convert(document: mastodonContent) + } catch { + assertionFailure() + return PlaintextMetaContent(string: note ?? "") + } + } + }() + + guard metaContent.string != view.bioMetaText.textStorage.string else { return } + view.bioMetaText.configure(content: metaContent) + } + .store(in: &disposeBag) + $relationshipActionOptionSet + .sink { optionSet in + let isBlocking = optionSet.contains(.blocking) + let isBlockedBy = optionSet.contains(.blockingBy) + let isSuspended = optionSet.contains(.suspended) + let isNeedsHidden = isBlocking || isBlockedBy || isSuspended + view.bioMetaText.textView.isHidden = isNeedsHidden + } + .store(in: &disposeBag) + // dashboard + $statusesCount + .sink { count in + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + view.statusDashboardView.postDashboardMeterView.numberLabel.text = text + view.statusDashboardView.postDashboardMeterView.isAccessibilityElement = true + view.statusDashboardView.postDashboardMeterView.accessibilityLabel = L10n.Plural.Count.post(count ?? 0) + } + .store(in: &disposeBag) + $followingCount + .sink { count in + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + view.statusDashboardView.followingDashboardMeterView.numberLabel.text = text + view.statusDashboardView.followingDashboardMeterView.isAccessibilityElement = true + view.statusDashboardView.followingDashboardMeterView.accessibilityLabel = L10n.Plural.Count.following(count ?? 0) + } + .store(in: &disposeBag) + $followersCount + .sink { count in + let text = count.flatMap { MastodonMetricFormatter().string(from: $0) } ?? "-" + view.statusDashboardView.followersDashboardMeterView.numberLabel.text = text + view.statusDashboardView.followersDashboardMeterView.isAccessibilityElement = true + view.statusDashboardView.followersDashboardMeterView.accessibilityLabel = L10n.Plural.Count.follower(count ?? 0) + } + .store(in: &disposeBag) + $isEditing + .sink { isEditing in + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + animator.addAnimations { + view.statusDashboardView.alpha = isEditing ? 0.2 : 1.0 + } + animator.startAnimation() + } + .store(in: &disposeBag) + // relationship + $isRelationshipActionButtonHidden + .assign(to: \.isHidden, on: view.relationshipActionButtonShadowContainer) + .store(in: &disposeBag) + Publishers.CombineLatest3( + $relationshipActionOptionSet, + $isEditing, + $isUpdating + ) + .sink { relationshipActionOptionSet, isEditing, isUpdating in + if relationshipActionOptionSet.contains(.edit) { + // check .edit state and set .editing when isEditing + view.relationshipActionButton.configure(actionOptionSet: isUpdating ? .updating : (isEditing ? .editing : .edit)) + view.configure(state: isEditing ? .editing : .normal) + } else { + view.relationshipActionButton.configure(actionOptionSet: relationshipActionOptionSet) + } + } + .store(in: &disposeBag) + } + +} + + +extension ProfileHeaderView { + enum State { + case normal + case editing + } + + func configure(state: State) { + guard viewModel.state != state else { return } // avoid redundant animation + viewModel.state = state + + let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) + + switch state { + case .normal: + nameMetaText.textView.alpha = 1 + nameTextField.alpha = 0 + nameTextField.isEnabled = false + bioMetaText.textView.backgroundColor = .clear + + animator.addAnimations { + self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor + self.nameTextFieldBackgroundView.backgroundColor = .clear + self.editAvatarBackgroundView.alpha = 0 + } + animator.addCompletion { _ in + self.editAvatarBackgroundView.isHidden = true + } + case .editing: + nameMetaText.textView.alpha = 0 + nameTextField.isEnabled = true + nameTextField.alpha = 1 + + editAvatarBackgroundView.isHidden = false + editAvatarBackgroundView.alpha = 0 + bioMetaText.textView.backgroundColor = .clear + animator.addAnimations { + self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor + self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color + self.editAvatarBackgroundView.alpha = 1 + self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color + } + } + + animator.startAnimation() + } +} diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index 1a6e10537..7257333d0 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -38,8 +38,16 @@ final class ProfileHeaderView: UIView { weak var delegate: ProfileHeaderViewDelegate? var disposeBag = Set() - var state: State? + func prepareForReuse() { + disposeBag.removeAll() + } + private(set) lazy var viewModel: ViewModel = { + let viewModel = ViewModel() + viewModel.bind(view: self) + return viewModel + }() + let bannerContainerView = UIView() let bannerImageView: UIImageView = { let imageView = UIImageView() @@ -61,6 +69,8 @@ final class ProfileHeaderView: UIView { overlayView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor return overlayView }() + var bannerImageViewTopLayoutConstraint: NSLayoutConstraint! + var bannerImageViewBottomLayoutConstraint: NSLayoutConstraint! let avatarImageViewBackgroundView: UIView = { let view = UIView() @@ -81,7 +91,7 @@ final class ProfileHeaderView: UIView { func setupAvatarOverlayViews() { editAvatarBackgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6) - editAvatarButton.tintColor = .white + editAvatarButtonOverlayIndicatorView.tintColor = .white } static let avatarImageViewOverlayBlurEffect = UIBlurEffect(style: .systemUltraThinMaterialDark) @@ -101,7 +111,7 @@ final class ProfileHeaderView: UIView { return view }() - let editAvatarButton: HighlightDimmableButton = { + let editAvatarButtonOverlayIndicatorView: HighlightDimmableButton = { let button = HighlightDimmableButton() button.setImage(UIImage(systemName: "photo", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28)), for: .normal) button.tintColor = .clear @@ -136,7 +146,7 @@ final class ProfileHeaderView: UIView { let nameTextField: UITextField = { let textField = UITextField() textField.font = UIFontMetrics(forTextStyle: .title2).scaledFont(for: .systemFont(ofSize: 22, weight: .bold)) - textField.textColor = Asset.Colors.Label.secondary.color + textField.textColor = Asset.Colors.Label.primary.color textField.text = "Alice" textField.autocorrectionType = .no textField.autocapitalizationType = .none @@ -164,8 +174,8 @@ final class ProfileHeaderView: UIView { return button }() - let bioContainerView = UIView() - let fieldContainerStackView = UIStackView() + // let bioContainerView = UIView() + // let fieldContainerStackView = UIStackView() let bioMetaText: MetaText = { let metaText = MetaText() @@ -230,12 +240,19 @@ extension ProfileHeaderView { bannerContainerView.topAnchor.constraint(equalTo: topAnchor), bannerContainerView.leadingAnchor.constraint(equalTo: leadingAnchor), trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), - readableContentGuide.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // set height to 1/3 of readable frame width + bannerContainerView.widthAnchor.constraint(equalTo: bannerContainerView.heightAnchor, multiplier: 3), // aspectRatio 1 : 3 ]) - bannerImageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] - bannerImageView.frame = bannerContainerView.bounds + bannerImageView.translatesAutoresizingMaskIntoConstraints = false bannerContainerView.addSubview(bannerImageView) + bannerImageViewTopLayoutConstraint = bannerImageView.topAnchor.constraint(equalTo: bannerContainerView.topAnchor) + bannerImageViewBottomLayoutConstraint = bannerContainerView.bottomAnchor.constraint(equalTo: bannerImageView.bottomAnchor) + NSLayoutConstraint.activate([ + bannerImageViewTopLayoutConstraint, + bannerImageView.leadingAnchor.constraint(equalTo: bannerContainerView.leadingAnchor), + bannerImageView.trailingAnchor.constraint(equalTo: bannerContainerView.trailingAnchor), + bannerImageViewBottomLayoutConstraint, + ]) bannerImageViewOverlayVisualEffectView.translatesAutoresizingMaskIntoConstraints = false bannerImageView.addSubview(bannerImageViewOverlayVisualEffectView) @@ -283,13 +300,13 @@ extension ProfileHeaderView { editAvatarBackgroundView.bottomAnchor.constraint(equalTo: avatarButton.bottomAnchor), ]) - editAvatarButton.translatesAutoresizingMaskIntoConstraints = false - editAvatarBackgroundView.addSubview(editAvatarButton) + editAvatarButtonOverlayIndicatorView.translatesAutoresizingMaskIntoConstraints = false + editAvatarBackgroundView.addSubview(editAvatarButtonOverlayIndicatorView) NSLayoutConstraint.activate([ - editAvatarButton.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), - editAvatarButton.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), - editAvatarButton.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), - editAvatarButton.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), + editAvatarButtonOverlayIndicatorView.topAnchor.constraint(equalTo: editAvatarBackgroundView.topAnchor), + editAvatarButtonOverlayIndicatorView.leadingAnchor.constraint(equalTo: editAvatarBackgroundView.leadingAnchor), + editAvatarButtonOverlayIndicatorView.trailingAnchor.constraint(equalTo: editAvatarBackgroundView.trailingAnchor), + editAvatarButtonOverlayIndicatorView.bottomAnchor.constraint(equalTo: editAvatarBackgroundView.bottomAnchor), ]) editAvatarBackgroundView.isUserInteractionEnabled = true avatarButton.isUserInteractionEnabled = true @@ -297,6 +314,7 @@ extension ProfileHeaderView { // container: V - [ dashboard container | author container | bio ] let container = UIStackView() container.axis = .vertical + container.distribution = .fill container.spacing = 8 container.preservesSuperviewLayoutMargins = true container.isLayoutMarginsRelativeArrangement = true @@ -310,7 +328,7 @@ extension ProfileHeaderView { layoutMarginsGuide.trailingAnchor.constraint(equalTo: container.trailingAnchor), container.bottomAnchor.constraint(equalTo: bottomAnchor), ]) - + // dashboardContainer: H - [ padding | statusDashboardView ] let dashboardContainer = UIStackView() dashboardContainer.axis = .horizontal @@ -364,6 +382,7 @@ extension ProfileHeaderView { nameTextFieldBackgroundView.trailingAnchor.constraint(equalTo: nameMetaText.textView.trailingAnchor, constant: 5), nameMetaText.textView.bottomAnchor.constraint(equalTo: nameTextFieldBackgroundView.bottomAnchor), ]) + // nameMetaText.textView.setContentHuggingPriority(, for: <#T##NSLayoutConstraint.Axis#>) nameContainerStackView.addArrangedSubview(displayNameStackView) nameContainerStackView.addArrangedSubview(usernameLabel) @@ -438,53 +457,6 @@ extension ProfileHeaderView { } -extension ProfileHeaderView { - enum State { - case normal - case editing - } - - func configure(state: State) { - guard self.state != state else { return } // avoid redundant animation - self.state = state - - let animator = UIViewPropertyAnimator(duration: 0.33, curve: .easeInOut) - - switch state { - case .normal: - nameMetaText.textView.alpha = 1 - nameTextField.alpha = 0 - nameTextField.isEnabled = false - bioMetaText.textView.backgroundColor = .clear - - animator.addAnimations { - self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundNormalColor - self.nameTextFieldBackgroundView.backgroundColor = .clear - self.editAvatarBackgroundView.alpha = 0 - } - animator.addCompletion { _ in - self.editAvatarBackgroundView.isHidden = true - } - case .editing: - nameMetaText.textView.alpha = 0 - nameTextField.isEnabled = true - nameTextField.alpha = 1 - - editAvatarBackgroundView.isHidden = false - editAvatarBackgroundView.alpha = 0 - bioMetaText.textView.backgroundColor = .clear - animator.addAnimations { - self.bannerImageViewOverlayVisualEffectView.backgroundColor = ProfileHeaderView.bannerImageViewOverlayViewBackgroundEditingColor - self.nameTextFieldBackgroundView.backgroundColor = Asset.Scene.Profile.Banner.nameEditBackgroundGray.color - self.editAvatarBackgroundView.alpha = 1 - self.bioMetaText.textView.backgroundColor = Asset.Scene.Profile.Banner.bioEditBackgroundGray.color - } - } - - animator.startAnimation() - } -} - extension ProfileHeaderView { @objc private func relationshipActionButtonDidPressed(_ sender: UIButton) { os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) diff --git a/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift new file mode 100644 index 000000000..bfbe45471 --- /dev/null +++ b/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift @@ -0,0 +1,217 @@ +// +// ProfilePagingViewController.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-3-29. +// + +import os.log +import UIKit +import Combine +import XLPagerTabStrip +import TabBarPager +import MastodonAsset + +protocol ProfilePagingViewControllerDelegate: AnyObject { + func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) +} + +final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController { + + weak var tabBarPageViewDelegate: TabBarPageViewDelegate? + weak var pagingDelegate: ProfilePagingViewControllerDelegate? + + var disposeBag = Set() + var viewModel: ProfilePagingViewModel! + + let buttonBarShadowView = UIView() + private var buttonBarShadowAlpha: CGFloat = 0.0 + + // MARK: - TabBarPageViewController + + var currentPage: TabBarPage? { + return viewModel.viewControllers[currentIndex] + } + + 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) + + guard indexWasChanged else { return } + let page = viewModel.viewControllers[toIndex] + tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex) + } + + // make key commands works + override var canBecomeFirstResponder: Bool { + return true + } + + deinit { + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) + } + +} + +extension ProfilePagingViewController { + + override func viewDidLoad() { + // configure style before viewDidLoad + settings.style.buttonBarBackgroundColor = ThemeService.shared.currentTheme.value.systemBackgroundColor + settings.style.buttonBarItemBackgroundColor = .clear + settings.style.buttonBarItemsShouldFillAvailableWidth = false // alignment from leading to trailing + settings.style.selectedBarHeight = 3 + settings.style.selectedBarBackgroundColor = Asset.Colors.Label.primary.color + settings.style.buttonBarItemFont = UIFont.systemFont(ofSize: 17, weight: .semibold) + + changeCurrentIndexProgressive = { [weak self] (oldCell: ButtonBarViewCell?, newCell: ButtonBarViewCell?, progressPercentage: CGFloat, changeCurrentIndex: Bool, animated: Bool) -> Void in + guard let _ = self else { return } + guard changeCurrentIndex == true else { return } + oldCell?.label.textColor = Asset.Colors.Label.secondary.color + newCell?.label.textColor = Asset.Colors.Label.primary.color + } + + super.viewDidLoad() + + ThemeService.shared.currentTheme + .receive(on: DispatchQueue.main) + .sink { [weak self] theme in + guard let self = self else { return } + self.settings.style.buttonBarBackgroundColor = theme.systemBackgroundColor + self.barButtonLayout?.invalidateLayout() + } + .store(in: &disposeBag) + + updateBarButtonInsets() + + if let buttonBarView = self.buttonBarView { + buttonBarShadowView.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(buttonBarShadowView, belowSubview: buttonBarView) + NSLayoutConstraint.activate([ + buttonBarShadowView.topAnchor.constraint(equalTo: buttonBarView.topAnchor), + buttonBarShadowView.leadingAnchor.constraint(equalTo: buttonBarView.leadingAnchor), + buttonBarShadowView.trailingAnchor.constraint(equalTo: buttonBarView.trailingAnchor), + buttonBarShadowView.bottomAnchor.constraint(equalTo: buttonBarView.bottomAnchor), + ]) + + viewModel.$needsSetupBottomShadow + .receive(on: DispatchQueue.main) + .sink { [weak self] needsSetupBottomShadow in + guard let self = self else { return } + self.setupBottomShadow() + } + .store(in: &disposeBag) + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + becomeFirstResponder() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + setupBottomShadow() + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + updateBarButtonInsets() + } + +} + +extension ProfilePagingViewController { + + private func updateBarButtonInsets() { + let margin: CGFloat = { + switch traitCollection.userInterfaceIdiom { + case .phone: + return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + default: + return traitCollection.horizontalSizeClass == .regular ? + ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : + ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + } + }() + + settings.style.buttonBarLeftContentInset = margin + settings.style.buttonBarRightContentInset = margin + barButtonLayout?.sectionInset.left = margin + barButtonLayout?.sectionInset.right = margin + barButtonLayout?.invalidateLayout() + } + + private var barButtonLayout: UICollectionViewFlowLayout? { + let layout = buttonBarView.collectionViewLayout as? UICollectionViewFlowLayout + return layout + } + + func setupBottomShadow() { + guard viewModel.needsSetupBottomShadow else { + buttonBarShadowView.layer.shadowColor = nil + buttonBarShadowView.layer.shadowRadius = 0 + return + } + buttonBarShadowView.layer.setupShadow( + color: UIColor.black.withAlphaComponent(0.12), + alpha: Float(buttonBarShadowAlpha), + x: 0, + y: 2, + blur: 2, + spread: 0, + roundedRect: buttonBarShadowView.bounds, + byRoundingCorners: .allCorners, + cornerRadii: .zero + ) + } + + func updateButtonBarShadow(progress: CGFloat) { + let alpha = min(max(0, 10 * progress - 9), 1) + if buttonBarShadowAlpha != alpha { + buttonBarShadowAlpha = alpha + setupBottomShadow() + buttonBarShadowView.setNeedsLayout() + } + } +} + +extension ProfilePagingViewController { + + var currentViewController: (UIViewController & TabBarPage)? { + guard !viewModel.viewControllers.isEmpty, + currentIndex < viewModel.viewControllers.count + else { return nil } + return viewModel.viewControllers[currentIndex] + } + +} + +// 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) + } + +} diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift b/Mastodon/Scene/Profile/Paging/ProfilePagingViewModel.swift similarity index 56% rename from Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift rename to Mastodon/Scene/Profile/Paging/ProfilePagingViewModel.swift index f8174acde..9b9e78d98 100644 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewModel.swift +++ b/Mastodon/Scene/Profile/Paging/ProfilePagingViewModel.swift @@ -18,6 +18,9 @@ final class ProfilePagingViewModel: NSObject { let mediaUserTimelineViewController = UserTimelineViewController() let profileAboutViewController = ProfileAboutViewController() + // input + @Published var needsSetupBottomShadow = true + init( postsUserTimelineViewModel: UserTimelineViewModel, repliesUserTimelineViewModel: UserTimelineViewModel, @@ -40,42 +43,8 @@ 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 -// }() - deinit { 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] -// } -//} diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index ec43f4d4a..f572292ce 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,7 +18,7 @@ import TabBarPager import XLPagerTabStrip protocol ProfileViewModelEditable { - func isEdited() -> Bool + var isEdited: Bool { get } } final class ProfileViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { @@ -97,6 +97,8 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi private(set) lazy var profileHeaderViewController: ProfileHeaderViewController = { let viewController = ProfileHeaderViewController() + viewController.context = context + viewController.coordinator = coordinator viewController.viewModel = ProfileHeaderViewModel(context: context) return viewController }() @@ -121,30 +123,6 @@ final class ProfileViewController: UIViewController, NeedsDependency, MediaPrevi 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 @@ -156,43 +134,17 @@ 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 { -// 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 func viewDidLoad() { super.viewDidLoad() @@ -218,71 +170,71 @@ extension ProfileViewController { navigationItem.titleView = titleView -// 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) + let editingAndUpdatingPublisher = Publishers.CombineLatest( + viewModel.$isEditing, + viewModel.$isUpdating + ) + // note: not add .share() here -// 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) + let barButtonItemHiddenPublisher = Publishers.CombineLatest3( + viewModel.$isMeBarButtonItemsHidden, + viewModel.$isReplyBarButtonItemHidden, + viewModel.$isMoreMenuBarButtonItemHidden + ) + + 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.relationshipViewModel.$isSuspended, + profileHeaderViewController.viewModel.$isTitleViewDisplaying, + editingAndUpdatingPublisher.eraseToAnyPublisher(), + barButtonItemHiddenPublisher.eraseToAnyPublisher() + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] isSuspended, 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 !isSuspended 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 @@ -300,156 +252,24 @@ extension ProfileViewController { tabBarPagerController.relayScrollView.refreshControl = refreshControl refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) - -// 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), -// ]) -// }) -// ) + + // setup delegate + profileHeaderViewController.delegate = self + profilePagingViewController.viewModel.profileAboutViewController.delegate = self + + bindViewModel() + bindTitleView() + bindMoreBarButtonItem() + bindPager() // 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) { - super.viewWillAppear(animated) - - // set back button tint color in SceneCoordinator.present(scene:from:transition:) - - // force layout to make banner image tweak take effect -// 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) + setNeedsStatusBarAppearanceUpdate() } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - -// currentPostTimelineTableViewContentSizeObservation = nil - } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -479,256 +299,155 @@ extension ProfileViewController { } extension ProfileViewController { + + private func bindViewModel() { + // header + let headerViewModel = profileHeaderViewController.viewModel! + viewModel.$user + .assign(to: \.user, on: headerViewModel) + .store(in: &disposeBag) + viewModel.$isEditing + .assign(to: \.isEditing, on: headerViewModel) + .store(in: &disposeBag) + viewModel.$isUpdating + .assign(to: \.isUpdating, on: headerViewModel) + .store(in: &disposeBag) + viewModel.relationshipViewModel.$optionSet + .map { $0 ?? .none } + .assign(to: \.relationshipActionOptionSet, on: headerViewModel) + .store(in: &disposeBag) + viewModel.$accountForEdit + .assign(to: \.accountForEdit, on: headerViewModel) + .store(in: &disposeBag) + + // timeline + [ + viewModel.postsUserTimelineViewModel, + viewModel.repliesUserTimelineViewModel, + viewModel.mediaUserTimelineViewModel, + ].forEach { userTimelineViewModel in + viewModel.relationshipViewModel.$isBlocking.assign(to: \.isBlocking, on: userTimelineViewModel).store(in: &disposeBag) + viewModel.relationshipViewModel.$isBlockingBy.assign(to: \.isBlockedBy, on: userTimelineViewModel).store(in: &disposeBag) + viewModel.relationshipViewModel.$isSuspended.assign(to: \.isSuspended, on: userTimelineViewModel).store(in: &disposeBag) + } + + // about + let aboutViewModel = profilePagingViewController.viewModel.profileAboutViewController.viewModel! + viewModel.$isEditing + .assign(to: \.isEditing, on: aboutViewModel) + .store(in: &disposeBag) + viewModel.$accountForEdit + .assign(to: \.accountForEdit, on: aboutViewModel) + .store(in: &disposeBag) + } -// 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) -// } + private func bindTitleView() { + Publishers.CombineLatest3( + profileHeaderViewController.profileHeaderView.viewModel.$name, + profileHeaderViewController.profileHeaderView.viewModel.$emojiMeta, + profileHeaderViewController.profileHeaderView.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) + profileHeaderViewController.profileHeaderView.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 bindMoreBarButtonItem() { + Publishers.CombineLatest( + viewModel.$user, + viewModel.relationshipViewModel.$optionSet + ) + .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.relationshipViewModel.isMuting)), + .blockUser(.init(name: name, isBlocking: self.viewModel.relationshipViewModel.isBlocking)), + .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) + } + + private func bindPager() { + viewModel.$isPagingEnabled + .receive(on: DispatchQueue.main) + .sink { [weak self] isPagingEnabled in + guard let self = self else { return } + self.profilePagingViewController.containerView.isScrollEnabled = isPagingEnabled + self.profilePagingViewController.buttonBarView.isUserInteractionEnabled = isPagingEnabled + } + .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.profilePagingViewController.becomeFirstResponder() + } + } + + // dismiss keyboard if needs + if !isEditing { self.view.endEditing(true) } + + if isEditing, + let index = self.profilePagingViewController.viewControllers.firstIndex(where: { type(of: $0) is ProfileAboutViewController.Type }), + self.profilePagingViewController.canMoveTo(index: index) + { + self.profilePagingViewController.moveToViewController(at: index) + } + } + .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 { -// -// } -// } -// .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(), @@ -747,56 +466,23 @@ extension ProfileViewController { // } // .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 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 + } + } } @@ -804,67 +490,66 @@ 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 = 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 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() -// } + if let userTimelineViewController = profilePagingViewController.currentViewController as? UserTimelineViewController { + userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + sender.endRefreshing() + } } } @@ -883,14 +568,57 @@ extension ProfileViewController: TabBarPagerDelegate { } func tabBarPagerController(_ tabBarPagerController: TabBarPagerController, didScroll scrollView: UIScrollView) { + // try to find some patterns: + // print(""" + // ----- + // headerMinHeight: \(ProfileHeaderViewController.headerMinHeight) + // scrollView.contentOffset.y: \(scrollView.contentOffset.y) + // scrollView.contentSize.height: \(scrollView.contentSize.height) + // scrollView.frame: \(scrollView.frame) + // scrollView.adjustedContentInset.top: \(scrollView.adjustedContentInset.top) + // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) + // """ + // ) + + // elastically banner - if scrollView.contentOffset.y < -scrollView.safeAreaInsets.top { - let offset = scrollView.contentOffset.y - (-scrollView.safeAreaInsets.top) - profileHeaderViewController.profileHeaderView.bannerImageViewTopLayoutConstraint.constant = offset - } else { - profileHeaderViewController.profileHeaderView.bannerImageViewTopLayoutConstraint.constant = 0 + + // make banner top snap to window top + // do not rely on the view frame becase the header frame is .zero during the initial call + profileHeaderViewController.profileHeaderView.bannerImageViewTopLayoutConstraint.constant = min(0, scrollView.contentOffset.y) + + if profileHeaderViewController.profileHeaderView.frame != .zero { + // make banner bottom not higher than navigation bar bottom + let bannerContainerInWindow = profileHeaderViewController.profileHeaderView.convert( + profileHeaderViewController.profileHeaderView.bannerContainerView.frame, + to: nil + ) + let bannerContainerBottomOffset = bannerContainerInWindow.origin.y + bannerContainerInWindow.height + // print("bannerContainerBottomOffset: \(bannerContainerBottomOffset)") + + let height = profileHeaderViewController.view.frame.height - bannerContainerInWindow.height + // make avata hidden when scroll 0.5x avatar height + let throttle = height != .zero ? 0.5 * ProfileHeaderView.avatarImageViewSize.height / height : 0 + let progress: CGFloat + + if bannerContainerBottomOffset < tabBarPagerController.containerScrollView.safeAreaInsets.top { + let offset = bannerContainerBottomOffset - tabBarPagerController.containerScrollView.safeAreaInsets.top + profileHeaderViewController.profileHeaderView.bannerImageViewBottomLayoutConstraint.constant = offset + // the progress for header move from banner bottom to header bottom (from 0 to 1) + progress = height != .zero ? abs(offset) / height : 0 + } else { + profileHeaderViewController.profileHeaderView.bannerImageViewBottomLayoutConstraint.constant = 0 + progress = 0 + } + + // setup titleView offset and fade avatar + profileHeaderViewController.updateHeaderScrollProgress(progress, throttle: throttle) + + // setup buttonBar shadow + profilePagingViewController.updateButtonBarShadow(progress: progress) } } + } // MARK: - TabBarPagerDataSource @@ -928,311 +656,219 @@ extension ProfileViewController: TabBarPagerDataSource { // } // // } -// -// // 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 -// } -//} -// + +// MARK: - ProfileHeaderViewControllerDelegate +extension ProfileViewController: ProfileHeaderViewControllerDelegate { + func profileHeaderViewController( + _ profileHeaderViewController: ProfileHeaderViewController, + profileHeaderView: ProfileHeaderView, + relationshipButtonDidPressed button: ProfileRelationshipActionButton + ) { + let relationshipActionSet = viewModel.relationshipViewModel.optionSet ?? .none + + // handle edit logic for editable profile + // handle relationship logic for non-editable profile + if relationshipActionSet.contains(.edit) { + // do nothing when updating + guard !viewModel.isUpdating else { return } + + guard let profileHeaderViewModel = profileHeaderViewController.viewModel else { return } + guard let profileAboutViewModel = profilePagingViewController.viewModel.profileAboutViewController.viewModel else { return } + + let isEdited = profileHeaderViewModel.isEdited || profileAboutViewModel.isEdited + + if isEdited { + // update profile when edited + viewModel.isUpdating = true + Task { @MainActor in + do { + // TODO: handle error + _ = try await viewModel.updateProfileInfo( + headerProfileInfo: profileHeaderViewModel.profileInfoEditing, + aboutProfileInfo: profileAboutViewModel.profileInfoEditing + ) + self.logger.log(level: .debug, "\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): update profile info success") + self.viewModel.isEditing = 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)") + let alertController = UIAlertController( + for: error, + title: L10n.Common.Alerts.EditProfileFailure.title, + preferredStyle: .alert + ) + let okAction = UIAlertAction(title: L10n.Common.Controls.Actions.ok, style: .default) + alertController.addAction(okAction) + self.present(alertController, animated: true) + } + + // finish updating + self.viewModel.isUpdating = false + } // end Task + } else { + // set `updating` then toggle `edit` state + viewModel.isUpdating = true + viewModel.fetchEditProfileInfo() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + defer { + // finish updating + self.viewModel.isUpdating = 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.toggle() + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + self.viewModel.accountForEdit = 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 profileHeaderViewController( + _ profileHeaderViewController: ProfileHeaderViewController, + profileHeaderView: ProfileHeaderView, + metaTextView: MetaTextView, + metaDidPressed meta: Meta + ) { + handleMetaPress(meta) + } +} + +// 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 tabBarPagerController.containerScrollView + } +} + //extension ProfileViewController { // // override var keyCommands: [UIKeyCommand]? { diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index add87f312..91866b851 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -41,74 +41,38 @@ class ProfileViewModel: NSObject { @Published var isEditing = false @Published var isUpdating = false + @Published var accountForEdit: Mastodon.Entity.Account? // output + let relationshipViewModel = RelationshipViewModel() + @Published var userIdentifier: UserIdentifier? = nil + + @Published var isRelationshipActionButtonHidden: Bool = true + @Published var isReplyBarButtonItemHidden: Bool = true + @Published var isMoreMenuBarButtonItemHidden: Bool = true + @Published var isMeBarButtonItemsHidden: Bool = true + @Published var isPagingEnabled = true -// 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 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) + // @Published var protected: Bool? = nil + // let needsPagePinToTop = 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.postsUserTimelineViewModel = UserTimelineViewModel( context: context, + title: L10n.Scene.Profile.SegmentedControl.posts, queryFilter: .init(excludeReplies: true) ) self.repliesUserTimelineViewModel = UserTimelineViewModel( context: context, + title: L10n.Scene.Profile.SegmentedControl.postsAndReplies, queryFilter: .init(excludeReplies: true) ) self.mediaUserTimelineViewModel = UserTimelineViewModel( context: context, + title: L10n.Scene.Profile.SegmentedControl.media, queryFilter: .init(onlyMedia: true) ) self.profileAboutViewModel = ProfileAboutViewModel(context: context) @@ -122,6 +86,9 @@ class ProfileViewModel: NSObject { self.me = authenticationBox?.authenticationRecord.object(in: context.managedObjectContext)?.user } .store(in: &disposeBag) + $me + .assign(to: \.me, on: relationshipViewModel) + .store(in: &disposeBag) // bind user $user @@ -130,250 +97,88 @@ class ProfileViewModel: NSObject { return MastodonUserIdentifier(domain: user.domain, userID: user.id) } .assign(to: &$userIdentifier) + $user + .assign(to: \.user, on: relationshipViewModel) + .store(in: &disposeBag) + // bind 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) -// + // bind bar button items + relationshipViewModel.$optionSet + .sink { [weak self] optionSet in + guard let self = self else { return } + guard let optionSet = optionSet, !optionSet.contains(.none) else { + self.isReplyBarButtonItemHidden = true + self.isMoreMenuBarButtonItemHidden = true + self.isMeBarButtonItemsHidden = true + return + } + + let isMyself = optionSet.contains(.isMyself) + self.isReplyBarButtonItemHidden = isMyself + self.isMoreMenuBarButtonItemHidden = isMyself + self.isMeBarButtonItemsHidden = !isMyself + } + .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)") -// } -// } -// } -// .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() - } - -} + // query relationship + let userRecord = $user.map { user -> ManagedObjectRecord? in + user.flatMap { ManagedObjectRecord(objectID: $0.objectID) } + } + let pendingRetryPublisher = CurrentValueSubject(1) -extension ProfileViewModel { - private func setup() { - Publishers.CombineLatest( - $user, - $me + // observe friendship + Publishers.CombineLatest3( + userRecord, + context.authenticationService.activeMastodonAuthenticationBox, + pendingRetryPublisher ) - .receive(on: DispatchQueue.main) - .sink { [weak self] user, me in + .sink { [weak self] userRecord, authenticationBox, _ in guard let self = self else { return } - // Update view model attribute - self.update(mastodonUser: user) - self.update(mastodonUser: user, currentMastodonUser: me) - - // Setup observer for user - if let mastodonUser = user { - // setup observer - self.mastodonUserObserver = ManagedObjectObserver.observe(object: mastodonUser) - .sink { completion in - switch completion { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .finished: - assertionFailure() - } - } receiveValue: { [weak self] change in - guard let self = self else { return } - guard let changeType = change.changeType else { return } - switch changeType { - case .update: - self.update(mastodonUser: mastodonUser) - self.update(mastodonUser: mastodonUser, currentMastodonUser: me) - case .delete: - // TODO: - break + 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) } } - - } else { - self.mastodonUserObserver = nil - } - - // Setup observer for user - if let currentMastodonUser = me { - // setup observer - self.currentMastodonUserObserver = ManagedObjectObserver.observe(object: currentMastodonUser) - .sink { completion in - switch completion { - case .failure(let error): - assertionFailure(error.localizedDescription) - case .finished: - assertionFailure() - } - } receiveValue: { [weak self] change in - guard let self = self else { return } - guard let changeType = change.changeType else { return } - switch changeType { - case .update: - self.update(mastodonUser: user, currentMastodonUser: currentMastodonUser) - case .delete: - // TODO: - break - } - } - } else { - self.currentMastodonUserObserver = nil - } + } 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)") + } + } // end Task } .store(in: &disposeBag) +// + let isBlockingOrBlocked = Publishers.CombineLatest( + relationshipViewModel.$isBlocking, + relationshipViewModel.$isBlockingBy + ) + .map { $0 || $1 } + .share() + + Publishers.CombineLatest( + isBlockingOrBlocked, + $isEditing + ) + .map { !$0 && !$1 } + .assign(to: &$isPagingEnabled) } - 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 ?? [:] - } - - 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 -// } - } - } extension ProfileViewModel { @@ -418,7 +223,7 @@ extension ProfileViewModel { let authorization = authenticationBox.userAuthorization let _image: UIImage? = { - guard let image = headerProfileInfo.avatarImage else { return nil } + guard let image = headerProfileInfo.avatar else { return nil } guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) } diff --git a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift deleted file mode 100644 index 4eca4268e..000000000 --- a/Mastodon/Scene/Profile/Segmented/Paging/ProfilePagingViewController.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// ProfilePagingViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-29. -// - -import os.log -import UIKit -import XLPagerTabStrip -import TabBarPager - -protocol ProfilePagingViewControllerDelegate: AnyObject { - func profilePagingViewController(_ viewController: ProfilePagingViewController, didScrollToPostCustomScrollViewContainerController customScrollViewContainerController: ScrollViewContainer, atIndex index: Int) -} - -final class ProfilePagingViewController: ButtonBarPagerTabStripViewController, TabBarPageViewController { - - weak var tabBarPageViewDelegate: TabBarPageViewDelegate? - weak var pagingDelegate: ProfilePagingViewControllerDelegate? - - var viewModel: ProfilePagingViewModel! - - // MARK: - TabBarPageViewController - - var currentPage: TabBarPage? { - return viewModel.viewControllers[currentIndex] - } - - 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) - - guard indexWasChanged else { return } - let page = viewModel.viewControllers[toIndex] - tabBarPageViewDelegate?.pageViewController(self, didPresentingTabBarPage: page, at: toIndex) - } - - // make key commands works - override var canBecomeFirstResponder: Bool { - return true - } - - deinit { - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension ProfilePagingViewController { - - override func viewDidLoad() { - super.viewDidLoad() - -// view.backgroundColor = .clear -// dataSource = viewModel - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - becomeFirstResponder() - } - -} - -// workaround to fix tab man responder chain issue -extension ProfilePagingViewController { - -// 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/ProfileSegmentedViewController.swift b/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift deleted file mode 100644 index 5d5241c56..000000000 --- a/Mastodon/Scene/Profile/Segmented/ProfileSegmentedViewController.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// ProfileSegmentedViewController.swift -// Mastodon -// -// Created by MainasuK Cirno on 2021-3-29. -// - -import os.log -import UIKit - -final class ProfileSegmentedViewController: UIViewController { - let pagingViewController = ProfilePagingViewController() - - deinit { - os_log("%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function) - } - -} - -extension ProfileSegmentedViewController { - - override func viewDidLoad() { - super.viewDidLoad() - - view.backgroundColor = .clear - - addChild(pagingViewController) - pagingViewController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(pagingViewController.view) - pagingViewController.didMove(toParent: self) - NSLayoutConstraint.activate([ - pagingViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - pagingViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - view.trailingAnchor.constraint(equalTo: pagingViewController.view.trailingAnchor), - view.bottomAnchor.constraint(equalTo: pagingViewController.view.bottomAnchor), - ]) - } - -} diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift index 54f936f75..fb42b81b8 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController.swift @@ -178,6 +178,6 @@ extension UserTimelineViewController: StatusTableViewControllerNavigateable { // MARK: - IndicatorInfoProvider extension UserTimelineViewController: IndicatorInfoProvider { func indicatorInfo(for pagerTabStripController: PagerTabStripViewController) -> IndicatorInfo { - return IndicatorInfo(title: "Hello") + return IndicatorInfo(title: viewModel.title) } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index 0fc3368d1..7f7341aa6 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -40,9 +40,9 @@ extension UserTimelineViewModel { .store(in: &disposeBag) let needsTimelineHidden = Publishers.CombineLatest3( - isBlocking, - isBlockedBy, - isSuspended + $isBlocking, + $isBlockedBy, + $isSuspended ).map { $0 || $1 || $2 } Publishers.CombineLatest( diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift index 71213489f..ca798fa0b 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+State.swift @@ -194,7 +194,7 @@ extension UserTimelineViewModel.State { guard let viewModel = viewModel, let _ = stateMachine else { return } // trigger data source update. otherwise, spinner always display - viewModel.isSuspended.value = viewModel.isSuspended.value + viewModel.isSuspended = viewModel.isSuspended // remove bottom loader guard let diffableDataSource = viewModel.diffableDataSource else { return } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index 8882990ea..2d350fb0b 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -19,16 +19,18 @@ final class UserTimelineViewModel { // input let context: AppContext - @Published var userIdentifier: UserIdentifier? - @Published var queryFilter: QueryFilter + let title: String let statusFetchedResultsController: StatusFetchedResultsController let listBatchFetchViewModel = ListBatchFetchViewModel() + @Published var userIdentifier: UserIdentifier? + @Published var queryFilter: QueryFilter - let isBlocking = CurrentValueSubject(false) - let isBlockedBy = CurrentValueSubject(false) - let isSuspended = CurrentValueSubject(false) - let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label - var dataSourceDidUpdate = PassthroughSubject() + @Published var isBlocking = false + @Published var isBlockedBy = false + @Published var isSuspended = false + + // let userDisplayName = CurrentValueSubject(nil) // for suspended prompt label + // var dataSourceDidUpdate = PassthroughSubject() // output var diffableDataSource: UITableViewDiffableDataSource? @@ -47,9 +49,11 @@ final class UserTimelineViewModel { init( context: AppContext, + title: String, queryFilter: QueryFilter ) { self.context = context + self.title = title self.statusFetchedResultsController = StatusFetchedResultsController( managedObjectContext: context.managedObjectContext, domain: nil, diff --git a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift index ec145f86d..80df8938e 100644 --- a/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift +++ b/Mastodon/Scene/Share/NavigationController/AdaptiveStatusBarStyleNavigationController.swift @@ -7,12 +7,12 @@ import UIKit -// Make status bar style adaptive for child view controller -// SeeAlso: `modalPresentationCapturesStatusBarAppearance` class AdaptiveStatusBarStyleNavigationController: UINavigationController { private lazy var fullWidthBackGestureRecognizer = UIPanGestureRecognizer() + // Make status bar style adaptive for child view controller + // SeeAlso: `modalPresentationCapturesStatusBarAppearance` override var childForStatusBarStyle: UIViewController? { visibleViewController } diff --git a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift index cee31c14a..a19de5138 100644 --- a/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/ViewModel/RelationshipViewModel.swift @@ -84,7 +84,7 @@ public struct RelationshipActionOptionSet: OptionSet { case .pending: return L10n.Common.Controls.Friendship.pending case .following: return L10n.Common.Controls.Friendship.following case .muting: return L10n.Common.Controls.Friendship.muted - case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user + case .blocked: return L10n.Common.Controls.Friendship.follow // blocked by user (deprecated) case .blocking: return L10n.Common.Controls.Friendship.blocked case .suspended: return L10n.Common.Controls.Friendship.follow case .edit: return L10n.Common.Controls.Friendship.editInfo @@ -116,6 +116,7 @@ public final class RelationshipViewModel { @Published public var isMuting = false @Published public var isBlocking = false @Published public var isBlockingBy = false + @Published public var isSuspended = false public init() { Publishers.CombineLatest3( @@ -182,8 +183,8 @@ extension RelationshipViewModel { self.isMuting = optionSet.contains(.muting) self.isBlockingBy = optionSet.contains(.blockingBy) self.isBlocking = optionSet.contains(.blocking) + self.isSuspended = optionSet.contains(.suspended) - self.optionSet = optionSet } @@ -203,7 +204,7 @@ extension RelationshipViewModel { public static func optionSet(user: MastodonUser, me: MastodonUser) -> RelationshipActionOptionSet { let isMyself = user.id == me.id && user.domain == me.domain guard !isMyself else { - return [.isMyself] + return [.isMyself, .edit] } let isProtected = user.locked @@ -247,6 +248,10 @@ extension RelationshipViewModel { if isBlocking { optionSet.insert(.blocking) } + + if user.suspended { + optionSet.insert(.suspended) + } return optionSet }