From af6272014a3fec1f2a09c51dc9a39726ee9493e6 Mon Sep 17 00:00:00 2001 From: shannon Date: Thu, 5 Dec 2024 10:32:49 -0500 Subject: [PATCH] Refactor: Immutable view model for ProfileViewController Goal: Begin to centralize the locus of view updates for easier bug fixing and future design flexibility. Remaining: The supplementary views are still heavily using Combine. Fixes #1372 [BUG] Cannot save new profile fields Fixes iOS-340 --- Mastodon/Coordinator/SceneCoordinator.swift | 17 +- .../Profile/ProfileFieldSection.swift | 1 - .../Provider/DataSourceFacade+Profile.swift | 11 +- .../About/ProfileAboutViewController.swift | 4 +- .../ProfileAboutViewModel+Diffable.swift | 1 - .../Profile/About/ProfileAboutViewModel.swift | 12 +- .../Header/ProfileHeaderViewController.swift | 9 +- .../Header/ProfileHeaderViewModel.swift | 6 +- .../Header/View/ProfileHeaderView.swift | 6 +- .../Paging/ProfilePagingViewController.swift | 6 +- .../Scene/Profile/ProfileViewController.swift | 1648 +++++++++-------- Mastodon/Scene/Profile/ProfileViewModel.swift | 322 ++-- .../Timeline/UserTimelineViewModel.swift | 3 - .../Root/MainTab/MainTabBarController.swift | 53 +- Mastodon/Supporting Files/SceneDelegate.swift | 21 +- 15 files changed, 1016 insertions(+), 1104 deletions(-) diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index cbb897298..568d68f08 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -103,15 +103,9 @@ final public class SceneCoordinator { let relationship = try await APIService.shared.relationship(forAccounts: [account], authenticationBox: authenticationBox).value.first - let profileViewModel = ProfileViewModel( - context: appContext, - authenticationBox: authenticationBox, - account: account, - relationship: relationship, - me: me - ) + let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship) _ = self.present( - scene: .profile(viewModel: profileViewModel), + scene: .profile(profileType), from: from, transition: .show ) @@ -190,7 +184,7 @@ extension SceneCoordinator { // profile case accountList(viewModel: AccountListViewModel) - case profile(viewModel: ProfileViewModel) + case profile(ProfileViewController.ProfileType) case favorite(viewModel: FavoriteViewModel) case follower(viewModel: FollowerListViewModel) case following(viewModel: FollowingListViewModel) @@ -449,9 +443,8 @@ private extension SceneCoordinator { let accountListViewController = AccountListViewController() accountListViewController.viewModel = viewModel viewController = accountListViewController - case .profile(let viewModel): - let _viewController = ProfileViewController() - _viewController.viewModel = viewModel + case .profile(let profileType): + let _viewController = ProfileViewController(profileType, authenticationBox: AuthenticationServiceProvider.shared.currentActiveUser.value!) viewController = _viewController case .bookmark(let viewModel): let _viewController = BookmarkViewController() diff --git a/Mastodon/Diffable/Profile/ProfileFieldSection.swift b/Mastodon/Diffable/Profile/ProfileFieldSection.swift index 0303cf89d..4309932d8 100644 --- a/Mastodon/Diffable/Profile/ProfileFieldSection.swift +++ b/Mastodon/Diffable/Profile/ProfileFieldSection.swift @@ -26,7 +26,6 @@ extension ProfileFieldSection { static func diffableDataSource( collectionView: UICollectionView, - context: AppContext, configuration: Configuration ) -> UICollectionViewDiffableDataSource { collectionView.register(ProfileFieldCollectionViewHeaderFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: ProfileFieldCollectionViewHeaderFooterView.headerReuseIdentifer) diff --git a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift index 2060d6893..d4138b2ee 100644 --- a/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift +++ b/Mastodon/Protocol/Provider/DataSourceFacade+Profile.swift @@ -120,16 +120,9 @@ extension DataSourceFacade { coordinator.hideLoading() - let profileViewModel = ProfileViewModel( - context: AppContext.shared, - authenticationBox: provider.authenticationBox, - account: account, - relationship: relationship, - me: me - ) - + let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship) _ = coordinator.present( - scene: .profile(viewModel: profileViewModel), + scene: .profile(profileType), from: provider, transition: .show ) diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift index f6c1d5aa7..c56d9215d 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewController.swift @@ -33,7 +33,9 @@ final class ProfileAboutViewController: UIViewController { return collectionView }() - + public var currentEditableFields: [ (String, String) ] { + return viewModel.profileInfoEditing.editedFields + } } extension ProfileAboutViewController { diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift index 43e4a301b..1b86d86e3 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel+Diffable.swift @@ -18,7 +18,6 @@ extension ProfileAboutViewModel { ) { let diffableDataSource = ProfileFieldSection.diffableDataSource( collectionView: collectionView, - context: context, configuration: ProfileFieldSection.Configuration( profileFieldCollectionViewCellDelegate: profileFieldCollectionViewCellDelegate, profileFieldEditCollectionViewCellDelegate: profileFieldEditCollectionViewCellDelegate diff --git a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift index 5b5488b6d..dd9f405f3 100644 --- a/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift +++ b/Mastodon/Scene/Profile/About/ProfileAboutViewModel.swift @@ -18,7 +18,6 @@ final class ProfileAboutViewModel { var disposeBag = Set() // input - let context: AppContext @Published var account: Mastodon.Entity.Account @Published var isEditing = false @Published var accountForEdit: Mastodon.Entity.Account? @@ -32,9 +31,8 @@ final class ProfileAboutViewModel { @Published var emojiMeta: MastodonContent.Emojis = [:] @Published var createdAt: Date = Date() - init(context: AppContext, account: Mastodon.Entity.Account) { + init(account: Mastodon.Entity.Account) { self.account = account - self.context = context emojiMeta = account.emojiMeta fields = account.mastodonFields @@ -79,6 +77,12 @@ final class ProfileAboutViewModel { extension ProfileAboutViewModel { class ProfileInfo { @Published var fields: [ProfileFieldItem.FieldValue] = [] + + var editedFields: [ (String, String) ] { + let edited = fields.map { return ($0.name.value, $0.value.value) + } + return edited + } } } @@ -100,7 +104,7 @@ extension ProfileAboutViewModel { } // MARK: - ProfileViewModelEditable -extension ProfileAboutViewModel: ProfileViewModelEditable { +extension ProfileAboutViewModel { var isEdited: Bool { guard isEditing else { return false } diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift index a93f8f838..b7154e12b 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewController.swift @@ -32,6 +32,9 @@ final class ProfileHeaderViewController: UIViewController, MediaPreviewableViewC var disposeBag = Set() let viewModel: ProfileHeaderViewModel + var editedDetails: ProfileHeaderDetails { + return viewModel.editedDetails + } weak var delegate: ProfileHeaderViewControllerDelegate? weak var headerDelegate: TabBarPagerHeaderDelegate? @@ -79,9 +82,9 @@ final class ProfileHeaderViewController: UIViewController, MediaPreviewableViewC return documentPickerController }() - init(authenticationBox: MastodonAuthenticationBox, profileViewModel: ProfileViewModel) { - self.viewModel = ProfileHeaderViewModel(authenticationBox: authenticationBox, account: profileViewModel.account, me: profileViewModel.me, relationship: profileViewModel.relationship) - self.profileHeaderView = ProfileHeaderView(account: profileViewModel.account, me: profileViewModel.me, relationship: profileViewModel.relationship) + init(authenticationBox: MastodonAuthenticationBox, account: Mastodon.Entity.Account, me: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) { + self.viewModel = ProfileHeaderViewModel(authenticationBox: authenticationBox, account: account, me: me, relationship: relationship) + self.profileHeaderView = ProfileHeaderView(account: account, me: me, relationship: relationship) super.init(nibName: nil, bundle: nil) diff --git a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift index f52963a40..874cf07cd 100644 --- a/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift +++ b/Mastodon/Scene/Profile/Header/ProfileHeaderViewModel.swift @@ -77,6 +77,10 @@ extension ProfileHeaderViewModel { @Published var name: String? @Published var note: String? } + + var editedDetails: ProfileHeaderDetails { + return ProfileHeaderDetails(bannerImage: profileInfoEditing.header, avatarImage: profileInfoEditing.avatar, displayName: profileInfoEditing.name, bioText: profileInfoEditing.note) + } } extension ProfileHeaderViewModel { @@ -96,7 +100,7 @@ extension ProfileHeaderViewModel { } // MARK: - ProfileViewModelEditable -extension ProfileHeaderViewModel: ProfileViewModelEditable { +extension ProfileHeaderViewModel { var isEdited: Bool { guard isEditing else { return false } diff --git a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift index a3af8379c..19bbce432 100644 --- a/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift +++ b/Mastodon/Scene/Profile/Header/View/ProfileHeaderView.swift @@ -475,11 +475,9 @@ extension ProfileHeaderView { let margin: CGFloat = { switch traitCollection.userInterfaceIdiom { case .phone: - return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + return ProfileViewController.containerViewMargin(forHorizontalSizeClass: .compact) default: - return traitCollection.horizontalSizeClass == .regular ? - ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : - ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + return ProfileViewController.containerViewMargin(forHorizontalSizeClass: traitCollection.horizontalSizeClass) } }() diff --git a/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift b/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift index eddfeec2c..23784e6ae 100644 --- a/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift +++ b/Mastodon/Scene/Profile/Paging/ProfilePagingViewController.swift @@ -136,11 +136,9 @@ extension ProfilePagingViewController { let margin: CGFloat = { switch traitCollection.userInterfaceIdiom { case .phone: - return ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + return ProfileViewController.containerViewMargin(forHorizontalSizeClass: .compact) default: - return traitCollection.horizontalSizeClass == .regular ? - ProfileViewController.containerViewMarginForRegularHorizontalSizeClass : - ProfileViewController.containerViewMarginForCompactHorizontalSizeClass + return ProfileViewController.containerViewMargin(forHorizontalSizeClass: traitCollection.horizontalSizeClass) } }() diff --git a/Mastodon/Scene/Profile/ProfileViewController.swift b/Mastodon/Scene/Profile/ProfileViewController.swift index fddca6508..7f950477c 100644 --- a/Mastodon/Scene/Profile/ProfileViewController.swift +++ b/Mastodon/Scene/Profile/ProfileViewController.swift @@ -18,44 +18,363 @@ import TabBarPager import XLPagerTabStrip import MastodonSDK -protocol ProfileViewModelEditable { - var isEdited: Bool { get } +fileprivate enum ActionableRelationship { + case blocked + case domainBlocked + case muted + case followed(Bool) + + init(_ relationship: Mastodon.Entity.Relationship) { + if relationship.blocking { + self = .blocked + } else if relationship.domainBlocking { + self = .domainBlocked + } else if relationship.muting { + self = .muted + } else { + self = .followed(relationship.following) + } + } } -final class ProfileViewController: UIViewController, MediaPreviewableViewController { - - public static let containerViewMarginForRegularHorizontalSizeClass: CGFloat = 64 - public static let containerViewMarginForCompactHorizontalSizeClass: CGFloat = 16 - - var disposeBag = Set() +enum ProfileViewError: Error { + case invalidStateTransition + case invalidDomain + case accountNotFound + case attemptToPushInvalidProfileChanges + case profileChangeServerError(String) +} - var viewModel: ProfileViewModel? { - didSet { - if isViewLoaded { - guard let viewModel = viewModel else { return } - viewModel.isEditing = false - - if profileHeaderViewController == nil { - createSupplementaryViews(withViewModel: viewModel) - } - bindToViewModel(viewModel) - - guard let profileHeaderViewController = profileHeaderViewController else { return } - profileHeaderViewController.viewModel.isEditing = false - profilePagingViewController?.viewModel?.profileAboutViewController.viewModel?.isEditing = false - viewModel.profileAboutViewModel.isEditing = false +extension ProfileViewController.ProfileType: UserIdentifier { + public var domain: String { + return accountToDisplay.domain ?? "" + } + + public var userID: MastodonSDK.Mastodon.Entity.Account.ID { + return accountToDisplay.id + } + + +} + +extension ProfileViewController { + public enum ProfileType { + case me(Mastodon.Entity.Account) + case notMe(me: Mastodon.Entity.Account, displayAccount: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?) + + var isMe: Bool { + switch self { + case .me: + return true + case .notMe: + return false + } + } + + var accountToDisplay: Mastodon.Entity.Account { + switch self { + case .me(let account): + return account + case .notMe(_, let account, _): + return account + } + } + + var myAccount: Mastodon.Entity.Account { + switch self { + case .me(let account): + return account + case .notMe(let myAccount, _, _): + return myAccount + } + } + + var myRelationshipToDisplayedAccount: Mastodon.Entity.Relationship? { + switch self { + case .me: + return nil + case .notMe(_, _, let relationship): + return relationship + } + } + + var canEditProfile: Bool { + switch self { + case .me: return true + case .notMe: return false } } } +} +@MainActor +class ProfileViewController: UIViewController, MediaPreviewableViewController, AuthContextProvider { + + var subscriptions = Set() + let mediaPreviewTransitionController = MediaPreviewTransitionController() + private var profilePagingViewController: ProfilePagingViewController? + + nonisolated let authenticationBox: MastodonAuthenticationBox + private var viewModel: ProfileViewModelImmutable { + didSet { + updateDisplay(viewModel) + } + } + + required init(_ profileType: ProfileType, authenticationBox: MastodonAuthenticationBox) { + self.authenticationBox = authenticationBox + self.viewModel = ProfileViewModelImmutable(profileType: profileType, state: .idle) + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + createSupplementaryViews() + setUpSupplementaryViews() + setAppearanceDetails() + + tabBarPagerController.delegate = self + tabBarPagerController.dataSource = self + + navigationItem.titleView = titleView + + addChild(tabBarPagerController) + tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(tabBarPagerController.view) + tabBarPagerController.didMove(toParent: self) + tabBarPagerController.view.pinToParent() + + tabBarPagerController.relayScrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) + + updateDisplay(viewModel) + + reloadCurrentTimeline() + + PublisherService.shared.statusPublishResult.sink { [weak self] result in + if case .success(.edit(let status)) = result { + self?.updateViewModelsWithDataControllers(status: .fromEntity(status.value), intent: .edit) + } + }.store(in: &subscriptions) + } + + private func updateDisplay(_ viewModel: ProfileViewModelImmutable) { + guard isViewLoaded else { return } + // TODO: careful about resetting things if we failed to push edits + + // Bridge to the old way of doing things until we replace the UI sometime in the future + updateHeader(viewModel) + updateAboutView(viewModel) + updateTabBarPager(viewModel) + updatePagingViewController(viewModel) + updateBarButtonItems(viewModel) + updateMoreButton(viewModel) + } + + private func updateHeader(_ viewModel: ProfileViewModelImmutable) { + guard let headerViewControllerViewModel = profileHeaderViewController?.viewModel else { + return + } + guard let headerViewViewModel = profileHeaderViewController?.profileHeaderView.viewModel else { return } + + let relationship = viewModel.profileType.myRelationshipToDisplayedAccount + headerViewControllerViewModel.relationship = relationship + headerViewViewModel.relationship = relationship + + headerViewControllerViewModel.account = viewModel.profileType.accountToDisplay + headerViewControllerViewModel.isEditing = viewModel.state.isEditing + headerViewControllerViewModel.isUpdating = viewModel.state.isUpdating + headerViewControllerViewModel.accountForEdit = viewModel.state.isEditing ? viewModel.profileType.accountToDisplay : nil + + if viewModel.state.isEditing { + headerViewControllerViewModel.setProfileInfo(accountForEdit: viewModel.profileType.accountToDisplay) + } + + guard let relationship else { return } + + for userTimeLineViewModel in [ + profilePagingViewController?.viewModel?.postUserTimelineViewController.viewModel, + profilePagingViewController?.viewModel?.repliesUserTimelineViewController.viewModel, + profilePagingViewController?.viewModel?.mediaUserTimelineViewController.viewModel, + ] { + userTimeLineViewModel?.isBlocking = relationship.blocking + userTimeLineViewModel?.isBlockedBy = relationship.blockedBy + userTimeLineViewModel?.isSuspended = viewModel.profileType.accountToDisplay.suspended ?? false + } + } + + private func updateAboutView(_ viewModel: ProfileViewModelImmutable) { + guard let aboutViewModel = profilePagingViewController?.viewModel?.profileAboutViewController.viewModel else { return } + aboutViewModel.fields = viewModel.profileType.accountToDisplay.mastodonFields + aboutViewModel.account = viewModel.profileType.accountToDisplay + aboutViewModel.isEditing = viewModel.state.isEditing + aboutViewModel.accountForEdit = viewModel.state.isEditing ? viewModel.profileType.accountToDisplay : nil + } + + private func updateBarButtonItems(_ viewModel: ProfileViewModelImmutable) { + self.cancelEditingBarButtonItem.isEnabled = !viewModel.state.isUpdating + + var items: [UIBarButtonItem] = [] + defer { + if items.isNotEmpty { + self.navigationItem.rightBarButtonItems = items + } else { + self.navigationItem.rightBarButtonItems = nil + } + } + + let suspended = viewModel.profileType.accountToDisplay.suspended ?? false + + guard !suspended else { return } + + guard !viewModel.state.isEditing else { + items.append(self.cancelEditingBarButtonItem) + return + } + + let isTitleViewDisplaying = profileHeaderViewController?.viewModel.isTitleViewDisplaying ?? false + guard !isTitleViewDisplaying else { + return + } + + guard viewModel.hideIsMeBarButtonItems else { + items.append(self.settingBarButtonItem) + items.append(self.shareBarButtonItem) + items.append(self.favoriteBarButtonItem) + items.append(self.bookmarkBarButtonItem) + + if self.currentInstance?.canFollowTags == true { + items.append(self.followedTagsBarButtonItem) + } + + return + } + + if !viewModel.hideMoreMenuBarButtonItem { + items.append(self.moreMenuBarButtonItem) + } + if !viewModel.hideReplyBarButtonItem { + items.append(self.replyBarButtonItem) + } + } + + private func updateMoreButton(_ viewModel: ProfileViewModelImmutable) { + switch viewModel.profileType { + case .me: + moreMenuBarButtonItem.menu = nil + case .notMe(let me, let displayAccount, let relationship): + guard let relationship, let domain = displayAccount.domainFromAcct, let myDomain = me.domainFromAcct else { + moreMenuBarButtonItem.menu = nil + return + } + + let name = displayAccount.displayNameWithFallback + + var items: [MastodonMenu.Submenu] = [] + + items.append(MastodonMenu.Submenu(actions: [ + .shareUser(.init(name: name)), + .openUserInBrowser(URL(string: displayAccount.url)), + .copyProfileLink(URL(string: displayAccount.url)) + ])) + + + var relationshipActions: [MastodonMenu.Action] = [ + .followUser(.init(name: name, isFollowing: relationship.following)), + .muteUser(.init(name: name, isMuting: relationship.muting)) + ] + + if relationship.following { + relationshipActions.append(.hideReblogs(.init(showReblogs: relationship.showingReblogs))) + } + + items.append(MastodonMenu.Submenu(actions: relationshipActions)) + + var destructiveActions: [MastodonMenu.Action] = [ + .blockUser(.init(name: name, isBlocking: relationship.blocking)), + .reportUser(.init(name: name)), + ] + + if myDomain != domain { + destructiveActions.append( + .blockDomain(.init(domain: domain, isBlocking: relationship.domainBlocking)) + ) + } + + items.append(MastodonMenu.Submenu(actions: destructiveActions)) + + let menu = MastodonMenu.setupMenu( + submenus: items, + delegate: self + ) + + moreMenuBarButtonItem.menu = menu + } + } + + private func updateTabBarPager(_ viewModel: ProfileViewModelImmutable) { + tabBarPagerController.relayScrollView.refreshControl = viewModel.state.isEditing ? nil : refreshControl + } + + private func updatePagingViewController(_ viewModel: ProfileViewModelImmutable) { + guard let pagingViewController = profilePagingViewController else { return } + pagingViewController.containerView.isScrollEnabled = viewModel.isPagingEnabled + pagingViewController.buttonBarView.isUserInteractionEnabled = viewModel.isPagingEnabled + + // set first responder for key command + if !viewModel.state.isEditing { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + pagingViewController.becomeFirstResponder() + } + // dismiss keyboard if needs + self.view.endEditing(true) + } + + if viewModel.state.isEditing, + let index = pagingViewController.viewControllers.firstIndex(where: { type(of: $0) is ProfileAboutViewController.Type }), + pagingViewController.canMoveTo(index: index) + { + pagingViewController.moveToViewController(at: index) + } + } + + + private func createSupplementaryViews() { + profileHeaderViewController = createProfileHeaderViewController() + profilePagingViewController = createProfilePagingViewController() + } + + private func setUpSupplementaryViews() { + profileHeaderViewController?.delegate = self + profilePagingViewController?.viewModel?.profileAboutViewController.delegate = self + } + + private func setAppearanceDetails() { + view.backgroundColor = .secondarySystemBackground + let barAppearance = UINavigationBarAppearance() + if isModal { + barAppearance.configureWithDefaultBackground() + } else { + barAppearance.configureWithTransparentBackground() + } + navigationItem.standardAppearance = barAppearance + navigationItem.compactAppearance = barAppearance + navigationItem.scrollEdgeAppearance = barAppearance + } + + // MARK: From original ProfileViewController private(set) lazy var cancelEditingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(title: L10n.Common.Controls.Actions.cancel, style: .plain, target: self, action: #selector(ProfileViewController.cancelEditingBarButtonItemPressed(_:))) barButtonItem.tintColor = .white return barButtonItem }() - + private(set) lazy var settingBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: UIImage(systemName: "gear")?.withRenderingMode(.alwaysTemplate), @@ -67,7 +386,7 @@ final class ProfileViewController: UIViewController, MediaPreviewableViewControl barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.settings return barButtonItem }() - + private(set) lazy var shareBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.Arrow.squareAndArrowUp.image.withRenderingMode(.alwaysTemplate), @@ -79,7 +398,7 @@ final class ProfileViewController: UIViewController, MediaPreviewableViewControl barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.share return barButtonItem }() - + private(set) lazy var favoriteBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem( image: Asset.ObjectsAndTools.star.image.withRenderingMode(.alwaysTemplate), @@ -103,14 +422,14 @@ final class ProfileViewController: UIViewController, MediaPreviewableViewControl barButtonItem.accessibilityLabel = L10n.Scene.Bookmark.title return barButtonItem }() - + private(set) lazy var replyBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "arrowshape.turn.up.left"), style: .plain, target: self, action: #selector(ProfileViewController.replyBarButtonItemPressed(_:))) barButtonItem.tintColor = .white barButtonItem.accessibilityLabel = L10n.Common.Controls.Actions.reply return barButtonItem }() - + let moreMenuBarButtonItem: UIBarButtonItem = { let barButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), style: .plain, target: nil, action: nil) barButtonItem.tintColor = .white @@ -124,7 +443,7 @@ final class ProfileViewController: UIViewController, MediaPreviewableViewControl barButtonItem.accessibilityLabel = L10n.Scene.FollowedTags.title return barButtonItem }() - + let refreshControl: RefreshControl = { let refreshControl = RefreshControl() refreshControl.tintColor = .white @@ -132,414 +451,407 @@ final class ProfileViewController: UIViewController, MediaPreviewableViewControl }() private(set) lazy var tabBarPagerController = TabBarPagerController() - + private(set) var profileHeaderViewController: ProfileHeaderViewController? - private func createSupplementaryViews(withViewModel viewModel: ProfileViewModel) { - profileHeaderViewController = createProfileHeaderViewController(viewModel: viewModel) - profilePagingViewController = createProfilePagingViewController(viewModel: viewModel) - } - - private func createProfileHeaderViewController(viewModel: ProfileViewModel) -> ProfileHeaderViewController { - let viewController = ProfileHeaderViewController(authenticationBox: viewModel.authenticationBox, profileViewModel: viewModel) + private func createProfileHeaderViewController() -> ProfileHeaderViewController { + let viewController = ProfileHeaderViewController(authenticationBox: authenticationBox, account: viewModel.profileType.accountToDisplay, me: viewModel.profileType.myAccount, relationship: viewModel.profileType.myRelationshipToDisplayedAccount) return viewController } - private(set) var profilePagingViewController: ProfilePagingViewController? - - private func createProfilePagingViewController(viewModel: ProfileViewModel) -> ProfilePagingViewController { + private func createProfilePagingViewController() -> ProfilePagingViewController { let profilePagingViewController = ProfilePagingViewController() + let timelineUserIdentifier = viewModel.profileType profilePagingViewController.viewModel = { let profilePagingViewModel = ProfilePagingViewModel( - postsUserTimelineViewModel: viewModel.postsUserTimelineViewModel, - repliesUserTimelineViewModel: viewModel.repliesUserTimelineViewModel, - mediaUserTimelineViewModel: viewModel.mediaUserTimelineViewModel, - profileAboutViewModel: viewModel.profileAboutViewModel + postsUserTimelineViewModel: userTimelineViewModel(.posts), + repliesUserTimelineViewModel: userTimelineViewModel(.postsAndReplies), + mediaUserTimelineViewModel: userTimelineViewModel(.media), + profileAboutViewModel: profileAboutViewModel ) + profilePagingViewModel.postUserTimelineViewController.viewModel.userIdentifier = timelineUserIdentifier + profilePagingViewModel.repliesUserTimelineViewController.viewModel.userIdentifier = timelineUserIdentifier + profilePagingViewModel.mediaUserTimelineViewController.viewModel.userIdentifier = timelineUserIdentifier return profilePagingViewModel }() return profilePagingViewController } +} +extension ProfileViewController: ProfileHeaderViewControllerDelegate { + // TODO: replace delegate with async streams + + func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, relationshipButtonDidPressed button: MastodonUI.ProfileRelationshipActionButton) { + relationshipActionButtonTapped() + } + + func profileHeaderViewController(_ profileHeaderViewController: ProfileHeaderViewController, profileHeaderView: ProfileHeaderView, metaTextView: MetaTextKit.MetaTextView, metaDidPressed meta: Meta) { + handleMetaPress(meta) + } + + private func userTimelineViewModel(_ timelineType: TimelineType) -> UserTimelineViewModel { + return UserTimelineViewModel( + authenticationBox: authenticationBox, + title: timelineType.title, + queryFilter: timelineType.queryFilter + ) + } + + private var profileAboutViewModel: ProfileAboutViewModel { ProfileAboutViewModel(account: viewModel.profileType.accountToDisplay) + } + + enum TimelineType { + case posts + case postsAndReplies + case media + + var title: String { + switch self { + case .posts: + return L10n.Scene.Profile.SegmentedControl.posts + case .postsAndReplies: + return L10n.Scene.Profile.SegmentedControl.postsAndReplies + case .media: + return L10n.Scene.Profile.SegmentedControl.media + } + } + + var queryFilter: UserTimelineViewModel.QueryFilter { + switch self { + case .posts: + return UserTimelineViewModel.QueryFilter(excludeReplies: true) + case .postsAndReplies: + return UserTimelineViewModel.QueryFilter(excludeReplies: false) + case .media: + return UserTimelineViewModel.QueryFilter(onlyMedia: true) + } + } + + } +} + +// MARK: API Calls +extension ProfileViewController { + + private func reloadCurrentTimeline() { + if let userTimelineViewController = profilePagingViewController?.currentViewController as? UserTimelineViewController { + userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) + } + } + + private func refetchAllData() async { + guard viewModel.state == .idle else { return } + + let reset = viewModel + + viewModel = ProfileViewModelImmutable(profileType: viewModel.profileType, state: .updating) + + do { + let account = viewModel.profileType.accountToDisplay + if let domain = account.domain { + let updatedAccount = try await refetchDisplayedAccount() + switch viewModel.profileType { + case .me: + viewModel = ProfileViewModelImmutable(profileType: .me(updatedAccount), state: .idle) + case .notMe: + // also update me and my relationship + let updatedMe = try await APIService.shared.accountInfo(authenticationBox) + let updatedRelationship = try await APIService.shared.relationship(forAccounts: [updatedAccount], authenticationBox: authenticationBox).value.first + viewModel = ProfileViewModelImmutable(profileType: .notMe(me: updatedMe, displayAccount: updatedAccount, relationship: updatedRelationship ?? viewModel.profileType.myRelationshipToDisplayedAccount), state: .idle) + } + } + } catch let error { + displayError(error, andResetView: reset) + } + } + + private func refetchDisplayedAccount() async throws -> Mastodon.Entity.Account { + switch viewModel.profileType { + case .me(let account): + let (account, authBox) = try await APIService.shared.verifyAndActivateUser(domain: authenticationBox.domain, clientID: authenticationBox.authentication.clientID, clientSecret: authenticationBox.authentication.clientSecret, authorization: authenticationBox.userAuthorization) + return account + case .notMe(let me, let displayAccount, let relationship): + guard let domain = displayAccount.domain else { throw ProfileViewError.invalidDomain } + guard let refreshedAccount = try await APIService.shared.fetchUser(username: displayAccount.acct, domain: domain, authenticationBox: authenticationBox) else { throw ProfileViewError.accountNotFound } + return refreshedAccount + } + } + + func pushProfileChanges( + headerDetails: ProfileHeaderDetails, + profileFields: [ (String, String) ] + ) async throws -> Mastodon.Entity.Account { + let domain = authenticationBox.domain + let authorization = authenticationBox.userAuthorization + + let newBannerImage: UIImage? + let newAvatarImage: UIImage? + + if case let .local(image) = headerDetails.bannerImage { + if image.size.width <= ProfileHeaderViewModel.bannerImageMaxSizeInPixel.width { + newBannerImage = image + } else { + newBannerImage = image.af.imageScaled(to: ProfileHeaderViewModel.bannerImageMaxSizeInPixel) + } + } else { + newBannerImage = nil + } + + if case let .local(image) = headerDetails.avatarImage { + if image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width { + newAvatarImage = image + } else { + newAvatarImage = image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) + } + } else { newAvatarImage = nil } + + let fieldsAttributes = profileFields.map { Mastodon.Entity.Field(name: $0.0, value: $0.1) } + + let query = Mastodon.API.Account.UpdateCredentialQuery( + discoverable: nil, + bot: nil, + displayName: headerDetails.displayName, + note: headerDetails.bioText, + avatar: newAvatarImage.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, + header: newBannerImage.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, + locked: nil, + source: nil, + fieldsAttributes: fieldsAttributes + ) + let response = try await APIService.shared.accountUpdateCredentials( + domain: domain, + query: query, + authorization: authorization + ) + // TODO: Publish the details, rather than using notification center to broadcast. This may actually already be handled in some other way. + NotificationCenter.default.post(name: .userFetched, object: nil) + + return response.value + } +} + +// MARK: Older code +extension ProfileViewController { // title view nested in header var titleView: DoubleTitleLabelNavigationBarTitleView? { profileHeaderViewController?.titleView } - -} - -extension ProfileViewController { - override var preferredStatusBarStyle: UIStatusBarStyle { return .lightContent } - + override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() - + profileHeaderViewController?.updateHeaderContainerSafeAreaInset(view.safeAreaInsets) } - override func viewDidLoad() { - super.viewDidLoad() - - NotificationCenter.default.addObserver(self, selector: #selector(ProfileViewController.relationshipChanged(_:)), name: .relationshipChanged, object: nil) - - view.backgroundColor = .secondarySystemBackground - let barAppearance = UINavigationBarAppearance() - if isModal { - barAppearance.configureWithDefaultBackground() - } else { - barAppearance.configureWithTransparentBackground() - } - navigationItem.standardAppearance = barAppearance - navigationItem.compactAppearance = barAppearance - navigationItem.scrollEdgeAppearance = barAppearance - - navigationItem.titleView = titleView - - addChild(tabBarPagerController) - tabBarPagerController.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(tabBarPagerController.view) - tabBarPagerController.didMove(toParent: self) - tabBarPagerController.view.pinToParent() - - tabBarPagerController.relayScrollView.refreshControl = refreshControl - refreshControl.addTarget(self, action: #selector(ProfileViewController.refreshControlValueChanged(_:)), for: .valueChanged) - - if let viewModel = viewModel { - setUpSupplementaryViews(viewModel: viewModel) - } - } - - private func setUpSupplementaryViews(viewModel: ProfileViewModel) { - // setup delegate - if profileHeaderViewController == nil { - createSupplementaryViews(withViewModel: viewModel) - } - profileHeaderViewController?.delegate = self - profilePagingViewController?.viewModel?.profileAboutViewController.delegate = self - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - navigationController?.navigationBar.prefersLargeTitles = false - - if let viewModel = viewModel { - bindToViewModel(viewModel) - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - viewModel?.viewDidAppear.send() - - setNeedsStatusBarAppearanceUpdate() - } - -} - -extension ProfileViewController { - - private func bindToViewModel(_ viewModel: ProfileViewModel) { - guard let profileHeaderViewController = profileHeaderViewController, let profilePagingViewController = profilePagingViewController else { return } - bindViewModel(viewModel, toHeaderViewController: profileHeaderViewController) - bindTitleView(profileHeaderViewController.titleView, headerView: profileHeaderViewController.profileHeaderView) - bindMoreBarButtonItem(viewModel: viewModel) - bindPager(pagingViewController: profilePagingViewController) - tabBarPagerController.delegate = self - tabBarPagerController.dataSource = self - } - - private func bindViewModel(_ viewModel: ProfileViewModel, toHeaderViewController headerViewController: ProfileHeaderViewController) { - // header - let headerViewModel = headerViewController.viewModel - viewModel.$account - .assign(to: \.account, 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.$relationship - .assign(to: \.relationship, on: headerViewModel) - .store(in: &disposeBag) - viewModel.$accountForEdit - .assign(to: \.accountForEdit, on: headerViewModel) - .store(in: &disposeBag) - - [ - viewModel.postsUserTimelineViewModel, - viewModel.repliesUserTimelineViewModel, - viewModel.mediaUserTimelineViewModel, - ].forEach { userTimelineViewModel in - - viewModel.relationship.publisher - .map { $0.blocking } - .assign(to: \UserTimelineViewModel.isBlocking, on: userTimelineViewModel) - .store(in: &disposeBag) - - viewModel.relationship.publisher - .compactMap { $0.blockedBy } - .assign(to: \UserTimelineViewModel.isBlockedBy, on: userTimelineViewModel) - .store(in: &disposeBag) - - viewModel.$account - .compactMap { $0.suspended } - .assign(to: \UserTimelineViewModel.isSuspended, on: userTimelineViewModel) - .store(in: &disposeBag) - } - - // about - let aboutViewModel = viewModel.profileAboutViewModel - viewModel.$account - .assign(to: \.account, on: aboutViewModel) - .store(in: &disposeBag) - viewModel.$isEditing - .assign(to: \.isEditing, on: aboutViewModel) - .store(in: &disposeBag) - viewModel.$accountForEdit - .assign(to: \.accountForEdit, on: aboutViewModel) - .store(in: &disposeBag) - - let editingAndUpdatingPublisher = Publishers.CombineLatest( - viewModel.$isEditing, - viewModel.$isUpdating - ) - // note: not add .share() here - - 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) - - // build items - Publishers.CombineLatest4( - viewModel.$relationship, - headerViewController.viewModel.$isTitleViewDisplaying, - editingAndUpdatingPublisher, - barButtonItemHiddenPublisher - ) - .receive(on: DispatchQueue.main) - .sink { [weak self] account, isTitleViewDisplaying, tuple1, tuple2 in - guard let self, let viewModel = self.viewModel else { return } - let (isEditing, _) = tuple1 - let (isMeBarButtonItemsHidden, isReplyBarButtonItemHidden, isMoreMenuBarButtonItemHidden) = tuple2 - - var items: [UIBarButtonItem] = [] - defer { - if items.isNotEmpty { - self.navigationItem.rightBarButtonItems = items - } else { - self.navigationItem.rightBarButtonItems = nil - } - } - - if let suspended = viewModel.account.suspended, suspended == true { - return - } - - guard isEditing == false else { - items.append(self.cancelEditingBarButtonItem) - return - } - - guard isTitleViewDisplaying == false else { - return - } - - guard isMeBarButtonItemsHidden else { - items.append(self.settingBarButtonItem) - items.append(self.shareBarButtonItem) - items.append(self.favoriteBarButtonItem) - items.append(self.bookmarkBarButtonItem) - - if self.currentInstance?.canFollowTags == true { - items.append(self.followedTagsBarButtonItem) - } - - return - } - - if !isMoreMenuBarButtonItemHidden { - items.append(self.moreMenuBarButtonItem) - } - if !isReplyBarButtonItemHidden { - items.append(self.replyBarButtonItem) - } - } - .store(in: &disposeBag) - - viewModel.$isEditing - .receive(on: DispatchQueue.main) - .sink { [weak self] isEditing in - guard let self else { return } - - if isEditing { - tabBarPagerController.relayScrollView.refreshControl = nil - } else { - tabBarPagerController.relayScrollView.refreshControl = refreshControl - } - } - .store(in: &disposeBag) - - PublisherService.shared.statusPublishResult.sink { [weak self] result in - if case .success(.edit(let status)) = result { - self?.updateViewModelsWithDataControllers(status: .fromEntity(status.value), intent: .edit) - } - }.store(in: &disposeBag) - - } - - private func bindTitleView(_ titleView: DoubleTitleLabelNavigationBarTitleView, headerView: ProfileHeaderView) { - Publishers.CombineLatest3( - headerView.viewModel.$name, - headerView.viewModel.$emojiMeta, - headerView.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 { - titleView.isHidden = true - return - } - titleView.isHidden = false - let subtitle = L10n.Plural.Count.MetricFormatted.post(formattedStatusCount, statusesCount) - let mastodonContent = MastodonContent(content: title, emojis: emojiMeta) + @objc private func refreshControlValueChanged(_ sender: RefreshControl) { + let reset = viewModel + Task { [weak self] in + guard let s = self else { return } do { - let metaContent = try MastodonMetaContent.convert(document: mastodonContent) - titleView.update(titleMetaContent: metaContent, subtitle: subtitle) - } catch { - + await s.refetchAllData() + } catch let error { + s.displayError(error, andResetView: reset) } } - .store(in: &disposeBag) - headerView.viewModel.$name - .receive(on: DispatchQueue.main) - .sink { [weak self] name in - guard let self = self, self.isModal == false else { return } - self.navigationItem.title = name - } - .store(in: &disposeBag) - } - - // This More-button is only visible for other users, but not myself - private func bindMoreBarButtonItem(viewModel: ProfileViewModel) { - Publishers.CombineLatest3( - viewModel.$account, - viewModel.$me, - viewModel.$relationship - ) - .asyncMap { [weak self] user, me, relationship -> UIMenu? in - guard let self, let relationship, let domain = user.domainFromAcct, let myDomain = me.domainFromAcct else { return nil } - - let name = user.displayNameWithFallback - - var items: [MastodonMenu.Submenu] = [] - - items.append(MastodonMenu.Submenu(actions: [ - .shareUser(.init(name: name)), - .openUserInBrowser(URL(string: user.url)), - .copyProfileLink(URL(string: user.url)) - ])) - - - var relationshipActions: [MastodonMenu.Action] = [ - .followUser(.init(name: name, isFollowing: relationship.following)), - .muteUser(.init(name: name, isMuting: relationship.muting)) - ] - - if relationship.following { - relationshipActions.append(.hideReblogs(.init(showReblogs: relationship.showingReblogs))) - } - - items.append(MastodonMenu.Submenu(actions: relationshipActions)) - - var destructiveActions: [MastodonMenu.Action] = [ - .blockUser(.init(name: name, isBlocking: relationship.blocking)), - .reportUser(.init(name: name)), - ] - - if myDomain != domain { - destructiveActions.append( - .blockDomain(.init(domain: domain, isBlocking: relationship.domainBlocking)) - ) - } - - items.append(MastodonMenu.Submenu(actions: destructiveActions)) - - let menu = MastodonMenu.setupMenu( - submenus: items, - 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 } - DispatchQueue.main.async { - self.moreMenuBarButtonItem.menu = menu - } - } - .store(in: &disposeBag) } - private func bindPager(pagingViewController: ProfilePagingViewController) { - guard let viewModel = viewModel else { return } - viewModel.$isPagingEnabled - .receive(on: DispatchQueue.main) - .sink { [weak self] isPagingEnabled in - guard let self else { return } - pagingViewController.containerView.isScrollEnabled = isPagingEnabled - pagingViewController.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) { - pagingViewController.becomeFirstResponder() - } - // dismiss keyboard if needs - self.view.endEditing(true) - } + private func relationshipActionButtonTapped() { + // if viewing your own profile, this means edit or save + // if viewing another account, this is unblock if blocked, or unmute if muted, or follow/unfollow + guard viewModel.state.actionButtonEnabled else { return } - if isEditing, - let index = pagingViewController.viewControllers.firstIndex(where: { type(of: $0) is ProfileAboutViewController.Type }), - pagingViewController.canMoveTo(index: index) - { - pagingViewController.moveToViewController(at: index) - } - } - .store(in: &disposeBag) + switch viewModel.profileType { + case .me: + toggleEditing() + case .notMe: + toggleRelationship() + } } + + private func toggleEditing() { + assert(viewModel.profileType.canEditProfile) + switch viewModel.state { + case .idle: + let reset = viewModel + Task { [weak self] in + guard let s = self else { return } + do { + try await s.refetchAllData() + s.viewModel = ProfileViewModelImmutable(profileType: s.viewModel.profileType, state: .editing) + } catch let error { + s.displayError(error, andResetView: reset) + } + } + case .editing: + let reset = viewModel + Task { [weak self] in + guard let s = self else { return } + do { + let updatedAccount = try await s.pushProfileEdits() + s.viewModel = ProfileViewModelImmutable(profileType: .me(updatedAccount), state: .idle) + } catch let error { + s.displayError(error, andResetView: reset) + } + } + case .updating, .pushingEdits: + break + } + } + + private func displayError(_ error: Error, andResetView reset: ProfileViewModelImmutable?) { + 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) + present(alertController, animated: true) + if let reset { + viewModel = reset + } + } + + private func pushProfileEdits() async throws -> Mastodon.Entity.Account { + guard viewModel.state == .editing else { throw ProfileViewError.invalidStateTransition } + guard let editedHeaderDetails = profileHeaderViewController?.editedDetails, let editedAboutFields = profilePagingViewController?.viewModel?.profileAboutViewController.currentEditableFields else { throw ProfileViewError.attemptToPushInvalidProfileChanges } + + viewModel = ProfileViewModelImmutable(profileType: viewModel.profileType, state: .pushingEdits) + + // TODO: also check that there are actual changes? + // cancelEditing() <- if no actual changes + let updatedAccount = try await pushProfileChanges(headerDetails: editedHeaderDetails, profileFields: editedAboutFields) + return updatedAccount + } + + private func cancelEditing() { + viewModel = ProfileViewModelImmutable(profileType: viewModel.profileType, state: .idle) + } + + private func toggleRelationship() { + guard let relationship = viewModel.profileType.myRelationshipToDisplayedAccount else { return } + let actionableRelationship = ActionableRelationship(relationship) + let account = viewModel.profileType.accountToDisplay + + if let confirmationAlert = confirmationAlertForRelationshipToggle(onOtherAccount: viewModel.profileType.accountToDisplay, myCurrentRelationship: actionableRelationship) { + self.sceneCoordinator?.present(scene: .alertController(alertController: confirmationAlert), transition: .alertController(animated: true)) + } else { + doToggleRelationship(actionableRelationship, on: account) + } + } + + private func confirmationAlertForRelationshipToggle(onOtherAccount account: Mastodon.Entity.Account, myCurrentRelationship relationship: ActionableRelationship) -> UIAlertController? { + + let confirmationAlert = UIAlertController(title: nil, message: nil, preferredStyle: .alert) + confirmationAlert.title = confirmationTitle(relationship) + + let entityName: String + switch relationship { + case .followed: + return nil + case .blocked: + entityName = viewModel.profileType.accountToDisplay.displayNameWithFallback + + case .domainBlocked: + guard let domain = account.domain else { return nil } + entityName = domain + + case .muted: + entityName = account.displayNameWithFallback + } + confirmationAlert.message = confirmationMessage(relationship, entityName: entityName) + let toggleAction = UIAlertAction(title: confirmationActionTitle(relationship, entityName: entityName), style: .default) { [weak self] _ in + guard let s = self else { return } + s.doToggleRelationship(relationship, on: account) + } + confirmationAlert.addAction(toggleAction) + + let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) + confirmationAlert.addAction(cancelAction) + return confirmationAlert + } + + private func confirmationTitle(_ actionableRelationship: ActionableRelationship) -> String { + switch actionableRelationship { + case .followed: + return "" + case .blocked: + return L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title + case .domainBlocked: + return L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.title + case .muted: + return L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title + } + } + + private func confirmationMessage(_ relationship: ActionableRelationship, entityName: String) -> String { + switch relationship { + case .followed: + return "" + case .blocked: + return L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(entityName) + case .domainBlocked: + return L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.message(entityName) + case .muted: + return L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(entityName) + } + } + + private func confirmationActionTitle(_ relationship: ActionableRelationship, entityName: String) -> String { + switch relationship { + case .followed: + return "" + case .blocked: + return L10n.Common.Controls.Friendship.unblock + case .domainBlocked: + return L10n.Common.Controls.Actions.unblockDomain(entityName) + case .muted: + return L10n.Common.Controls.Friendship.unmute + } + } + + private func doToggleRelationship(_ actionableRelationship: ActionableRelationship, on account: Mastodon.Entity.Account) { + + let reset = viewModel + viewModel = ProfileViewModelImmutable(profileType: viewModel.profileType, state: .updating) + Task { [weak self] in + guard let s = self else { return } + do { + let updatedRelationship: Mastodon.Entity.Relationship + + switch actionableRelationship { + case .followed: + updatedRelationship = try await DataSourceFacade.responseToUserFollowAction( + dependency: s, + account: s.viewModel.profileType.accountToDisplay + ) + case .blocked: + updatedRelationship = try await DataSourceFacade.responseToUserBlockAction( + dependency: s, + account: s.viewModel.profileType.accountToDisplay + ) + case .domainBlocked: + _ = try await DataSourceFacade.responseToDomainBlockAction(dependency: s, account: account) + + guard let s1 = self, let fetchedRelationship = try await APIService.shared.relationship(forAccounts: [account], authenticationBox: s1.authenticationBox).value.first else { return } + updatedRelationship = fetchedRelationship + case .muted: + updatedRelationship = try await DataSourceFacade.responseToUserMuteAction(dependency: s, account: s.viewModel.profileType.accountToDisplay) + } + guard let s2 = self else { return } + let newType = ProfileType.notMe(me: s2.viewModel.profileType.myAccount, displayAccount: s2.viewModel.profileType.accountToDisplay, relationship: updatedRelationship) + s2.viewModel = ProfileViewModelImmutable(profileType: newType, state: .idle) + } catch let error { + self?.displayError(error, andResetView: reset) + } + } + } + private func handleMetaPress(_ meta: Meta) { switch meta { case .url(_, _, let url, _): @@ -550,34 +862,27 @@ extension ProfileViewController { let url = URL(string: href) else { return } _ = self.sceneCoordinator?.present(scene: .safari(url: url), from: nil, transition: .safariPresent(animated: true, completion: nil)) case .hashtag(_, let hashtag, _): - guard let viewModel = viewModel else { break } - let hashtagTimelineViewModel = HashtagTimelineViewModel(authenticationBox: viewModel.authenticationBox, hashtag: hashtag) + let hashtagTimelineViewModel = HashtagTimelineViewModel(authenticationBox: authenticationBox, hashtag: hashtag) _ = self.sceneCoordinator?.present(scene: .hashtagTimeline(viewModel: hashtagTimelineViewModel), from: nil, transition: .show) case .email, .emoji: break } } - -} - -extension ProfileViewController { - + @objc private func cancelEditingBarButtonItemPressed(_ sender: UIBarButtonItem) { cancelEditing() } - + @objc private func settingBarButtonItemPressed(_ sender: UIBarButtonItem) { guard let setting = SettingService.shared.currentSetting.value else { return } - + _ = self.sceneCoordinator?.present(scene: .settings(setting: setting), from: self, transition: .none) } - + @objc private func shareBarButtonItemPressed(_ sender: UIBarButtonItem) { - guard let viewModel = viewModel else { return } - let activityViewController = DataSourceFacade.createActivityViewController( dependency: self, - account: viewModel.account + account: viewModel.profileType.accountToDisplay ) _ = self.sceneCoordinator?.present( scene: .activityViewController( @@ -589,28 +894,22 @@ extension ProfileViewController { transition: .activityViewControllerPresent(animated: true, completion: nil) ) } - + @objc private func favoriteBarButtonItemPressed(_ sender: UIBarButtonItem) { - guard let viewModel = viewModel else { return } - - let favoriteViewModel = FavoriteViewModel(authenticationBox: viewModel.authenticationBox) + let favoriteViewModel = FavoriteViewModel(authenticationBox: authenticationBox) _ = self.sceneCoordinator?.present(scene: .favorite(viewModel: favoriteViewModel), from: self, transition: .show) } @objc private func bookmarkBarButtonItemPressed(_ sender: UIBarButtonItem) { - guard let viewModel = viewModel else { return } - - let bookmarkViewModel = BookmarkViewModel(authenticationBox: viewModel.authenticationBox) + let bookmarkViewModel = BookmarkViewModel(authenticationBox: authenticationBox) _ = self.sceneCoordinator?.present(scene: .bookmark(viewModel: bookmarkViewModel), from: self, transition: .show) } - + @objc private func replyBarButtonItemPressed(_ sender: UIBarButtonItem) { - guard let viewModel = viewModel else { return } - - let mention = "@" + viewModel.account.acct + let mention = "@" + viewModel.profileType.accountToDisplay.acct UITextChecker.learnWord(mention) let composeViewModel = ComposeViewModel( - authenticationBox: viewModel.authenticationBox, + authenticationBox: authenticationBox, composeContext: .composeStatus, destination: .topLevel, initialContent: mention @@ -619,45 +918,117 @@ extension ProfileViewController { } @objc private func followedTagsItemPressed(_ sender: UIBarButtonItem) { - guard let viewModel = viewModel else { return } - - let followedTagsViewModel = FollowedTagsViewModel(authenticationBox: viewModel.authenticationBox) + let followedTagsViewModel = FollowedTagsViewModel(authenticationBox: authenticationBox) _ = self.sceneCoordinator?.present(scene: .followedTags(viewModel: followedTagsViewModel), from: self, transition: .show) } +} - @objc private func refreshControlValueChanged(_ sender: RefreshControl) { - if let userTimelineViewController = profilePagingViewController?.currentViewController as? UserTimelineViewController { - userTimelineViewController.viewModel.stateMachine.enter(UserTimelineViewModel.State.Reloading.self) - } +// MARK: - ProfileAboutViewControllerDelegate +extension ProfileViewController: ProfileAboutViewControllerDelegate { + // TODO: replace delegate with async stream + func profileAboutViewController( + _ viewController: ProfileAboutViewController, + profileFieldCollectionViewCell: ProfileFieldCollectionViewCell, + metaLabel: MetaLabel, + didSelectMeta meta: Meta + ) { + handleMetaPress(meta) + } +} - Task { - guard let viewModel = viewModel else { return } - - let account = viewModel.account - if let domain = account.domain, - let updatedAccount = try? await APIService.shared.fetchUser(username: account.acct, domain: domain, authenticationBox: viewModel.authenticationBox), - let updatedRelationship = try? await APIService.shared.relationship(forAccounts: [updatedAccount], authenticationBox: viewModel.authenticationBox).value.first - { - viewModel.account = updatedAccount - viewModel.relationship = updatedRelationship - viewModel.profileAboutViewModel.fields = updatedAccount.mastodonFields - } - - if let updatedMe = try? await APIService.shared.accountInfo(viewModel.authenticationBox) { - viewModel.me = updatedMe - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - sender.endRefreshing() +// MARK: - MastodonMenuDelegate +extension ProfileViewController: MastodonMenuDelegate { + func menuAction(_ action: MastodonMenu.Action) { + switch action { + case .muteUser(_), .blockUser(_), .blockDomain(_), .hideReblogs(_), .reportUser(_), .shareUser(_), .openUserInBrowser(_), .copyProfileLink(_), .followUser(_): + Task { + try await DataSourceFacade.responseToMenuAction( + dependency: self, + action: action, + menuContext: DataSourceFacade.MenuContext( + author: viewModel.profileType.accountToDisplay, + statusViewModel: nil, + button: nil, + barButtonItem: self.moreMenuBarButtonItem + )) } + case .translateStatus(_), .showOriginal, .bookmarkStatus(_), .shareStatus, .deleteStatus, .editStatus, .boostStatus(_), .favoriteStatus(_), .copyStatusLink, .openStatusInBrowser: + break } } +} +// MARK: - ScrollViewContainer +extension ProfileViewController: ScrollViewContainer { + var scrollView: UIScrollView { + return tabBarPagerController.relayScrollView + } +} + +extension ProfileViewController { + + override var keyCommands: [UIKeyCommand]? { + switch viewModel.state { + case .idle: + return pagerTabStripNavigateKeyCommands + case .editing, .pushingEdits, .updating: + return nil + } + } + +} + +// MARK: - PagerTabStripNavigateable +extension ProfileViewController: PagerTabStripNavigateable { + + var navigateablePageViewController: PagerTabStripViewController? { + return profilePagingViewController + } + + @objc func pagerTabStripNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { + pagerTabStripNavigateKeyCommandHandler(sender) + } + +} + +private extension ProfileViewController { + var currentInstance: MastodonAuthentication.InstanceConfiguration? { + authenticationBox.authentication.instanceConfiguration + } +} + +extension ProfileViewController: DataSourceProvider { + var filterContext: MastodonSDK.Mastodon.Entity.FilterContext? { + .none + } + + func didToggleContentWarningDisplayStatus(status: MastodonSDK.MastodonStatus) { + reloadTables() + } + + func item(from source: DataSourceItem.Source) async -> DataSourceItem? { + assertionFailure("Not required") + return nil + } + + func reloadTables() { + profilePagingViewController?.reloadTables() + } + + func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + updateViewModelsWithDataControllers(status: status, intent: intent) + } + + func updateViewModelsWithDataControllers(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { + + profilePagingViewController?.viewModel?.postUserTimelineViewController.update(status: status, intent: intent) + profilePagingViewController?.viewModel?.repliesUserTimelineViewController.update(status: status, intent: intent) + profilePagingViewController?.viewModel?.mediaUserTimelineViewController.update(status: status, intent: intent) + } } // MARK: - TabBarPagerDelegate extension ProfileViewController: TabBarPagerDelegate { - func tabBarMinimalHeight() -> CGFloat { return ProfileHeaderViewController.headerMinHeight } @@ -680,11 +1051,11 @@ extension ProfileViewController: TabBarPagerDelegate { // scrollView.adjustedContentInset.bottom: \(scrollView.adjustedContentInset.bottom) // """ // ) - + guard let profileHeaderViewController = profileHeaderViewController else { return } // elastically banner - + // 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) @@ -699,7 +1070,7 @@ extension ProfileViewController: TabBarPagerDelegate { // print("bannerContainerBottomOffset: \(bannerContainerBottomOffset)") let height = profileHeaderViewController.view.frame.height - bannerContainerInWindow.height - // make avata hidden when scroll 0.5x avatar height + // make avatar hidden when scroll 0.5x avatar height let throttle = height != .zero ? 0.5 * ProfileHeaderView.avatarImageViewSize.height / height : 0 let progress: CGFloat @@ -750,361 +1121,16 @@ extension ProfileViewController: TabBarPagerDataSource { } } -// MARK: - AuthContextProvider -extension ProfileViewController: AuthContextProvider { - var authenticationBox: MastodonAuthenticationBox { viewModel!.authenticationBox } -} - -// MARK: - ProfileHeaderViewControllerDelegate -extension ProfileViewController: ProfileHeaderViewControllerDelegate { - func profileHeaderViewController( - _ profileHeaderViewController: ProfileHeaderViewController, - profileHeaderView: ProfileHeaderView, - relationshipButtonDidPressed button: ProfileRelationshipActionButton - ) { - guard let viewModel = viewModel else { return } - if viewModel.me == viewModel.account { - editProfile() - } else { - editRelationship() - } - } - - - private func editProfile() { - // do nothing when updating - guard let viewModel = viewModel, let profileHeaderViewModel = profileHeaderViewController?.viewModel else { return } - guard viewModel.isUpdating == false 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 - let updatedAccount = try await viewModel.updateProfileInfo( - headerProfileInfo: profileHeaderViewModel.profileInfoEditing, - aboutProfileInfo: profileAboutViewModel.profileInfoEditing - ).value - viewModel.isEditing = false - self.profileHeaderViewController?.viewModel.isEditing = false - profileAboutViewModel.isEditing = false - viewModel.account = updatedAccount - viewModel.profileAboutViewModel.fields = updatedAccount.mastodonFields - - } catch { - 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 - viewModel.isUpdating = false - } - } else if viewModel.isEditing == false { - // set `updating` then toggle `edit` state - viewModel.isUpdating = true - profileHeaderViewController?.viewModel.isUpdating = true - viewModel.fetchEditProfileInfo() - .receive(on: DispatchQueue.main) - .sink { [weak self] completion in - guard let self else { return } - defer { - // finish updating - viewModel.isUpdating = false - self.profileHeaderViewController?.viewModel.isUpdating = false - } - switch completion { - case .failure(let error): - 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.sceneCoordinator?.present( - scene: .alertController(alertController: alertController), - from: nil, - transition: .alertController(animated: true, completion: nil) - ) - case .finished: - // enter editing mode - viewModel.isEditing = true - self.profileHeaderViewController?.viewModel.isEditing = true - profileAboutViewModel.isEditing = true - } - } receiveValue: { [weak self] account in - guard let self else { return } - - self.profileHeaderViewController?.viewModel.setProfileInfo(accountForEdit: account) - viewModel.accountForEdit = account - } - .store(in: &disposeBag) - } else if isEdited == false { - cancelEditing() - } - } - - private func cancelEditing() { - guard let viewModel = viewModel else { return } - viewModel.isEditing = false - profileHeaderViewController?.viewModel.isEditing = false - profilePagingViewController?.viewModel?.profileAboutViewController.viewModel.isEditing = false - viewModel.profileAboutViewModel.isEditing = false - } - - private func editRelationship() { - guard let viewModel = viewModel, let relationship = viewModel.relationship, viewModel.isUpdating == false else { - return - } - - let account = viewModel.account - - viewModel.isUpdating = true - - if relationship.blocking { - let name = account.displayNameWithFallback - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockUser.message(name), - preferredStyle: .alert - ) - let unblockAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unblock, style: .default) { [weak self] _ in - guard let self else { return } - Task { - _ = try await DataSourceFacade.responseToUserBlockAction( - dependency: self, - account: account - ) - } - } - alertController.addAction(unblockAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) - alertController.addAction(cancelAction) - self.sceneCoordinator?.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) - } else if relationship.domainBlocking { - guard let domain = account.domain else { return } - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnblockDomain.message(domain), - preferredStyle: .alert - ) - - let unblockAction = UIAlertAction(title: L10n.Common.Controls.Actions.unblockDomain(domain), style: .default) { [weak self] _ in - guard let self, let viewModel = self.viewModel else { return } - Task { - _ = try await DataSourceFacade.responseToDomainBlockAction(dependency: self, account: account) - - guard let newRelationship = try await APIService.shared.relationship(forAccounts: [account], authenticationBox: viewModel.authenticationBox).value.first else { return } - - viewModel.isUpdating = false - - // we need to trigger this here as domain block doesn't return a relationship - let userInfo = [ - UserInfoKey.relationship: newRelationship, - ] - - NotificationCenter.default.post(name: .relationshipChanged, object: self, userInfo: userInfo) - } - } - alertController.addAction(unblockAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) - alertController.addAction(cancelAction) - self.sceneCoordinator?.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) - - } else if relationship.muting { - let name = account.displayNameWithFallback - - let alertController = UIAlertController( - title: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.title, - message: L10n.Scene.Profile.RelationshipActionAlert.ConfirmUnmuteUser.message(name), - preferredStyle: .alert - ) - - let unmuteAction = UIAlertAction(title: L10n.Common.Controls.Friendship.unmute, style: .default) { [weak self] _ in - guard let self else { return } - Task { - _ = try await DataSourceFacade.responseToUserMuteAction(dependency: self, account: account) - } - } - alertController.addAction(unmuteAction) - let cancelAction = UIAlertAction(title: L10n.Common.Controls.Actions.cancel, style: .cancel) - alertController.addAction(cancelAction) - self.sceneCoordinator?.present(scene: .alertController(alertController: alertController), transition: .alertController(animated: true)) - } else { - Task { [weak self] in - guard let self else { return } - - _ = try await DataSourceFacade.responseToUserFollowAction( - dependency: self, - account: viewModel.account - ) - } - } - } - - 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 viewModel = viewModel else { return } - switch action { - case .muteUser(_), .blockUser(_), .blockDomain(_), .hideReblogs(_), .reportUser(_), .shareUser(_), .openUserInBrowser(_), .copyProfileLink(_), .followUser(_): - Task { - try await DataSourceFacade.responseToMenuAction( - dependency: self, - action: action, - menuContext: DataSourceFacade.MenuContext( - author: viewModel.account, - statusViewModel: nil, - button: nil, - barButtonItem: self.moreMenuBarButtonItem - )) - } - case .translateStatus(_), .showOriginal, .bookmarkStatus(_), .shareStatus, .deleteStatus, .editStatus, .boostStatus(_), .favoriteStatus(_), .copyStatusLink, .openStatusInBrowser: - break - } - } -} - -// MARK: - ScrollViewContainer -extension ProfileViewController: ScrollViewContainer { - var scrollView: UIScrollView { - return tabBarPagerController.relayScrollView - } -} - extension ProfileViewController { - - override var keyCommands: [UIKeyCommand]? { - guard let viewModel = viewModel else { return nil } - if !viewModel.isEditing { - return pagerTabStripNavigateKeyCommands - } - - return nil - } - -} - -// MARK: - PagerTabStripNavigateable -extension ProfileViewController: PagerTabStripNavigateable { - - var navigateablePageViewController: PagerTabStripViewController? { - return profilePagingViewController - } - - @objc func pagerTabStripNavigateKeyCommandHandlerRelay(_ sender: UIKeyCommand) { - pagerTabStripNavigateKeyCommandHandler(sender) - } - -} - -private extension ProfileViewController { - var currentInstance: MastodonAuthentication.InstanceConfiguration? { - authenticationBox.authentication.instanceConfiguration - } -} - -extension ProfileViewController: DataSourceProvider { - var filterContext: MastodonSDK.Mastodon.Entity.FilterContext? { - .none - } - - func didToggleContentWarningDisplayStatus(status: MastodonSDK.MastodonStatus) { - reloadTables() - } - - func item(from source: DataSourceItem.Source) async -> DataSourceItem? { - assertionFailure("Not required") - return nil - } - - func reloadTables() { - profilePagingViewController?.reloadTables() - } - - func update(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { - updateViewModelsWithDataControllers(status: status, intent: intent) - } - - func updateViewModelsWithDataControllers(status: MastodonStatus, intent: MastodonStatus.UpdateIntent) { - guard let viewModel = viewModel else { return } - viewModel.postsUserTimelineViewModel.dataController.update(status: status, intent: intent) - viewModel.repliesUserTimelineViewModel.dataController.update(status: status, intent: intent) - viewModel.mediaUserTimelineViewModel.dataController.update(status: status, intent: intent) - } -} - -//MARK: - Notifications - -extension ProfileViewController { - @objc - func relationshipChanged(_ notification: Notification) { - - guard let userInfo = notification.userInfo, let relationship = userInfo[UserInfoKey.relationship] as? Mastodon.Entity.Relationship else { - return - } - - guard let viewModel = viewModel else { return } - - viewModel.isUpdating = true - if viewModel.account.id == relationship.id { - // if relationship belongs to an other account - Task { - let account = viewModel.account - if let domain = account.domain, - let updatedAccount = try? await APIService.shared.fetchUser(username: account.acct, domain: domain, authenticationBox: viewModel.authenticationBox) { - viewModel.account = updatedAccount - - viewModel.relationship = relationship - self.profileHeaderViewController?.viewModel.relationship = relationship - self.profileHeaderViewController?.profileHeaderView.viewModel.relationship = relationship - } - - viewModel.isUpdating = false - } - } else if viewModel.account == viewModel.me { - // update my profile - Task { - if let updatedMe = try? await APIService.shared.accountInfo(viewModel.authenticationBox) { - viewModel.me = updatedMe - viewModel.account = updatedMe - } - - viewModel.isUpdating = false - } + static func containerViewMargin(forHorizontalSizeClass sizeClass: UIUserInterfaceSizeClass) -> CGFloat { + // TODO: this might be better gated on actual size than on sizeClass (we had previously treated the phone as always compact, for instance) + switch sizeClass { + case .compact: + return 16 + case .regular, .unspecified: + return 64 + @unknown default: + return 16 } } } diff --git a/Mastodon/Scene/Profile/ProfileViewModel.swift b/Mastodon/Scene/Profile/ProfileViewModel.swift index e4506703a..8c3210a3f 100644 --- a/Mastodon/Scene/Profile/ProfileViewModel.swift +++ b/Mastodon/Scene/Profile/ProfileViewModel.swift @@ -15,228 +15,124 @@ import MastodonCore import MastodonLocalization import MastodonUI -// please override this base class -@MainActor -class ProfileViewModel: NSObject { - - typealias UserID = String - - var disposeBag = Set() - var observations = Set() - private var mastodonUserObserver: AnyCancellable? - private var currentMastodonUserObserver: AnyCancellable? - - let postsUserTimelineViewModel: UserTimelineViewModel - let repliesUserTimelineViewModel: UserTimelineViewModel - let mediaUserTimelineViewModel: UserTimelineViewModel - let profileAboutViewModel: ProfileAboutViewModel - - // input - let context: AppContext - let authenticationBox: MastodonAuthenticationBox +enum ServerHostedImage: Equatable { + case fetchable(URL) + // case fetching(URL) + // case fetched(URL, UIImage) + // case fetchError(URL, Error) + case local(UIImage) +} - @Published var me: Mastodon.Entity.Account - @Published var account: Mastodon.Entity.Account - @Published var relationship: Mastodon.Entity.Relationship? - - let viewDidAppear = PassthroughSubject() +struct ProfileHeaderDetails: Equatable { + let bannerImage: ServerHostedImage? + let avatarImage: ServerHostedImage? + let displayName: String? + let bioText: String? - @Published var isEditing = false - @Published var isUpdating = false - @Published var accountForEdit: Mastodon.Entity.Account? - - @Published var userIdentifier: UserIdentifier? = nil - - @Published var isReplyBarButtonItemHidden: Bool = true - @Published var isMoreMenuBarButtonItemHidden: Bool = true - @Published var isMeBarButtonItemsHidden: Bool = true - @Published var isPagingEnabled = true - - // @Published var protected: Bool? = nil - // let needsPagePinToTop = CurrentValueSubject(false) - - @MainActor - init(context: AppContext, authenticationBox: MastodonAuthenticationBox, account: Mastodon.Entity.Account, relationship: Mastodon.Entity.Relationship?, me: Mastodon.Entity.Account) { - self.context = context - self.authenticationBox = authenticationBox - self.account = account - self.relationship = relationship - self.me = me - - self.postsUserTimelineViewModel = UserTimelineViewModel( - context: context, - authenticationBox: authenticationBox, - title: L10n.Scene.Profile.SegmentedControl.posts, - queryFilter: .init(excludeReplies: true) - ) - self.repliesUserTimelineViewModel = UserTimelineViewModel( - context: context, - authenticationBox: authenticationBox, - title: L10n.Scene.Profile.SegmentedControl.postsAndReplies, - queryFilter: .init(excludeReplies: false) - ) - self.mediaUserTimelineViewModel = UserTimelineViewModel( - context: context, - authenticationBox: authenticationBox, - title: L10n.Scene.Profile.SegmentedControl.media, - queryFilter: .init(onlyMedia: true) - ) - self.profileAboutViewModel = ProfileAboutViewModel(context: context, account: account) - super.init() - + init(_ account: Mastodon.Entity.Account) { + bannerImage = account.headerImageURL().flatMap { .fetchable($0) } if let domain = account.domain { - userIdentifier = MastodonUserIdentifier(domain: domain, userID: account.id) + avatarImage = .fetchable(account.avatarImageURLWithFallback(domain: domain)) } else { - userIdentifier = nil + avatarImage = account.avatarImageURL().flatMap { .fetchable($0) } // TODO: there is a fallback option here. what is it for? } - - // bind userIdentifier - $userIdentifier.assign(to: &postsUserTimelineViewModel.$userIdentifier) - $userIdentifier.assign(to: &repliesUserTimelineViewModel.$userIdentifier) - $userIdentifier.assign(to: &mediaUserTimelineViewModel.$userIdentifier) - - // bind bar button items - Publishers.CombineLatest3($account, $me, $relationship) - .sink(receiveValue: { [weak self] account, me, relationship in - guard let self else { - self?.isReplyBarButtonItemHidden = true - self?.isMoreMenuBarButtonItemHidden = true - self?.isMeBarButtonItemsHidden = true - return - } - - let isMyself = (account == me) - self.isReplyBarButtonItemHidden = isMyself - self.isMoreMenuBarButtonItemHidden = isMyself - self.isMeBarButtonItemsHidden = (isMyself == false) - }) - .store(in: &disposeBag) - - viewDidAppear - .sink { [weak self] _ in - guard let self else { return } - - self.isReplyBarButtonItemHidden = self.isReplyBarButtonItemHidden - self.isMoreMenuBarButtonItemHidden = self.isMoreMenuBarButtonItemHidden - self.isMeBarButtonItemsHidden = self.isMeBarButtonItemsHidden - } - .store(in: &disposeBag) - // query relationship - - let pendingRetryPublisher = CurrentValueSubject(1) - - // observe friendship - Publishers.CombineLatest( - $account, - pendingRetryPublisher - ) - .sink { [weak self] account, _ in - guard let self else { return } - - Task { - do { - let response = try await APIService.shared.relationship( - forAccounts: [account], - authenticationBox: self.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) - } - } - } catch { - } - } // end Task - } - .store(in: &disposeBag) - - let isBlockingOrBlocked = Publishers.CombineLatest3( - (relationship?.blocking ?? false).publisher, - (relationship?.blockedBy ?? false).publisher, - (relationship?.domainBlocking ?? false).publisher - ) - .map { $0 || $1 || $2 } - .share() - - Publishers.CombineLatest( - isBlockingOrBlocked, - $isEditing - ) - .map { !$0 && !$1 } - .assign(to: &$isPagingEnabled) + displayName = account.displayNameWithFallback + bioText = account.note } - // fetch profile info before edit - func fetchEditProfileInfo() -> AnyPublisher { - guard let domain = me.domain else { - return Fail(error: APIService.APIError.implicit(.authenticationMissing)).eraseToAnyPublisher() - } - - let mastodonAuthentication = authenticationBox.authentication - let authorization = Mastodon.API.OAuth.Authorization(accessToken: mastodonAuthentication.userAccessToken) - return APIService.shared.verifyAndActivateUser(domain: domain, - clientID: mastodonAuthentication.clientID, - clientSecret: mastodonAuthentication.clientSecret, - authorization: authorization) - .tryMap { (account, _) in - return account - }.eraseToAnyPublisher() + init(bannerImage: UIImage?, avatarImage: UIImage?, displayName: String?, bioText: String?) { + self.bannerImage = bannerImage.flatMap{ .local($0) } + self.avatarImage = avatarImage.flatMap{ .local($0) } + self.displayName = displayName + self.bioText = bioText } } -extension ProfileViewModel { - func updateProfileInfo( - headerProfileInfo: ProfileHeaderViewModel.ProfileInfo, - aboutProfileInfo: ProfileAboutViewModel.ProfileInfo - ) async throws -> Mastodon.Response.Content { - let authenticationBox = authenticationBox - let domain = authenticationBox.domain - let authorization = authenticationBox.userAuthorization - - // TODO: constrain size? - let _header: UIImage? = { - guard let image = headerProfileInfo.header else { return nil } - guard image.size.width <= ProfileHeaderViewModel.bannerImageMaxSizeInPixel.width else { - return image.af.imageScaled(to: ProfileHeaderViewModel.bannerImageMaxSizeInPixel) - } - return image - }() - - let _avatar: UIImage? = { - guard let image = headerProfileInfo.avatar else { return nil } - guard image.size.width <= ProfileHeaderViewModel.avatarImageMaxSizeInPixel.width else { - return image.af.imageScaled(to: ProfileHeaderViewModel.avatarImageMaxSizeInPixel) - } - return image - }() - - let fieldsAttributes = aboutProfileInfo.fields.map { field in - Mastodon.Entity.Field(name: field.name.value, value: field.value.value) - } - - let query = Mastodon.API.Account.UpdateCredentialQuery( - discoverable: nil, - bot: nil, - displayName: headerProfileInfo.name, - note: headerProfileInfo.note, - avatar: _avatar.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, - header: _header.flatMap { Mastodon.Query.MediaAttachment.png($0.pngData()) }, - locked: nil, - source: nil, - fieldsAttributes: fieldsAttributes - ) - let response = try await APIService.shared.accountUpdateCredentials( - domain: domain, - query: query, - authorization: authorization - ) - NotificationCenter.default.post(name: .userFetched, object: nil) - - return response +struct ProfileAboutDetails { + let createdAt: Date + let fields: [ String : String ] + + init(_ account: Mastodon.Entity.Account) { + createdAt = account.createdAt + fields = profileFields(account) + } +} + +fileprivate func profileFields(_ account: Mastodon.Entity.Account) -> [ String : String ] { + var result = [ String : String ]() + for field in account.fields ?? [] { + result[field.name] = field.value + } + return result +} + +public struct ProfileViewModelImmutable { + + let profileType: ProfileViewController.ProfileType + let state: ProfileInteractionState + + var headerDetails: ProfileHeaderDetails { + return ProfileHeaderDetails(profileType.accountToDisplay) + } + var aboutDetails: ProfileAboutDetails { + return ProfileAboutDetails(profileType.accountToDisplay) + } + + public enum ProfileInteractionState { + case idle + case updating + case editing + case pushingEdits + + var actionButtonEnabled: Bool { + switch self { + case .updating, .pushingEdits: + return false + case .idle, .editing: + return true + } + } + + var isEditing: Bool { + switch self { + case .editing, .pushingEdits: + return true + case .idle, .updating: + return false + } + } + + var isUpdating: Bool { + switch self { + case .editing, .idle: + return false + case .pushingEdits, .updating: + return true + } + } + } + + var hideReplyBarButtonItem: Bool { + return profileType.isMe + } + + var hideMoreMenuBarButtonItem: Bool { + return profileType.isMe + } + + var hideIsMeBarButtonItems: Bool { + return !profileType.isMe + } + + var isPagingEnabled: Bool { + guard !state.isEditing else { return false } + guard let relationship = profileType.myRelationshipToDisplayedAccount else { return true } + return !relationship.isBlockingOrBlocked + } +} + +fileprivate extension Mastodon.Entity.Relationship { + var isBlockingOrBlocked: Bool { + return blocking || blockedBy || domainBlocking } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift index a24b38d3b..28126a1d6 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel.swift @@ -18,7 +18,6 @@ final class UserTimelineViewModel { var disposeBag = Set() // input - let context: AppContext let authenticationBox: MastodonAuthenticationBox let title: String let dataController: StatusDataController @@ -50,12 +49,10 @@ final class UserTimelineViewModel { @MainActor init( - context: AppContext, authenticationBox: MastodonAuthenticationBox, title: String, queryFilter: QueryFilter ) { - self.context = context self.authenticationBox = authenticationBox self.title = title self.dataController = StatusDataController() diff --git a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift index 12c9a7ba3..282692c46 100644 --- a/Mastodon/Scene/Root/MainTab/MainTabBarController.swift +++ b/Mastodon/Scene/Root/MainTab/MainTabBarController.swift @@ -36,7 +36,7 @@ class MainTabBarController: UITabBarController { let searchViewController: SearchViewController let composeViewController: UIViewController // placeholder let notificationViewController: NotificationViewController - let meProfileViewController: ProfileViewController + var meProfileViewController: UIViewController // placeholder private(set) var isReadyForWizardAvatarButton = false @@ -63,17 +63,13 @@ class MainTabBarController: UITabBarController { notificationViewController = NotificationViewController() notificationViewController.configureTabBarItem(with: .notifications) - meProfileViewController = ProfileViewController() + meProfileViewController = UIViewController() meProfileViewController.configureTabBarItem(with: .me) if let authenticationBox { notificationViewController.viewModel = NotificationViewModel(context: AppContext.shared, authenticationBox: authenticationBox) homeTimelineViewController.viewModel = HomeTimelineViewModel(authenticationBox: authenticationBox) searchViewController.viewModel = SearchViewModel(authenticationBox: authenticationBox) - - if let account = authenticationBox.cachedAccount { - meProfileViewController.viewModel = ProfileViewModel(context: AppContext.shared, authenticationBox: authenticationBox, account: account, relationship: nil, me: account) - } } super.init(nibName: nil, bundle: nil) @@ -84,6 +80,12 @@ class MainTabBarController: UITabBarController { layoutAvatarButton() } + private func replace(_ oldVC: UIViewController, with newVC: UIViewController) { + guard let navControllers = viewControllers as? [UINavigationController] else { return } + guard let toReplace = navControllers.first(where: { $0.viewControllers[0] == oldVC }) else { return } + toReplace.viewControllers = [newVC] + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } @@ -93,16 +95,6 @@ extension MainTabBarController { return selectedViewController } - override var selectedViewController: UIViewController? { - willSet { - if let profileView = (newValue as? UINavigationController)?.topViewController as? ProfileViewController{ - guard let authenticationBox, - let account = authenticationBox.cachedAccount else { return } - profileView.viewModel = ProfileViewModel(context: AppContext.shared, authenticationBox: authenticationBox, account: account, relationship: nil, me: account) - } - } - } - override func viewDidLoad() { super.viewDidLoad() @@ -171,6 +163,30 @@ extension MainTabBarController { notificationViewController.navigationController?.tabBarItem.image = image.imageWithoutBaseline() } .store(in: &disposeBag) + + $currentTab + .receive(on: DispatchQueue.main) + .sink { [weak self] currentTab in + guard let self else { return } + + if currentTab == .me { + guard let authBox = authenticationBox, let myAccount = authBox.cachedAccount else { return } + let oldMe = meProfileViewController + let updatedProfile = ProfileViewController(.me(myAccount), authenticationBox: authBox) + meProfileViewController = updatedProfile + updatedProfile.configureTabBarItem(with: .me) + self.replace(oldMe, with: updatedProfile) + if let domain = myAccount.domain ?? myAccount.domainFromAcct { + self.avatarURL = myAccount.avatarImageURLWithFallback(domain: domain) + } else { + self.avatarURL = myAccount.avatarImageURL() + } + + self.avatarButton.removeFromSuperview() + self.layoutAvatarButton() + } + } + .store(in: &disposeBag) $avatarURL .receive(on: DispatchQueue.main) @@ -203,10 +219,6 @@ extension MainTabBarController { self?.updateUserAccount() } .store(in: &self.disposeBag) - - if let currentViewModel = self.meProfileViewController.viewModel, currentViewModel.account.id == account.id, !currentViewModel.isEditing { - self.meProfileViewController.viewModel = ProfileViewModel(context: AppContext.shared, authenticationBox: authenticationBox, account: account, relationship: nil, me: account) - } } .store(in: &disposeBag) @@ -330,6 +342,7 @@ extension MainTabBarController { } anchorImageView.alpha = 0 + accountSwitcherChevron.removeFromSuperview() accountSwitcherChevron.translatesAutoresizingMaskIntoConstraints = false view.addSubview(accountSwitcherChevron) diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 8d48f63f8..c2314f8d2 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -167,15 +167,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { authenticationBox: authenticationBox ).value.first else { return } - let profileViewModel = ProfileViewModel( - context: AppContext.shared, - authenticationBox: authenticationBox, - account: account, - relationship: relationship, - me: me - ) + let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship) _ = self.coordinator?.present( - scene: .profile(viewModel: profileViewModel), + scene: .profile(profileType), from: nil, transition: .show ) @@ -308,16 +302,9 @@ extension SceneDelegate { authenticationBox: authenticationBox ).value.first else { return } - let profileViewModel = ProfileViewModel( - context: AppContext.shared, - authenticationBox: authenticationBox, - account: account, - relationship: relationship, - me: me - ) - + let profileType: ProfileViewController.ProfileType = me == account ? .me(me) : .notMe(me: me, displayAccount: account, relationship: relationship) self.coordinator?.present( - scene: .profile(viewModel: profileViewModel), + scene: .profile(profileType), from: nil, transition: .show )