// // ProfileViewController.swift // Tusker // // Created by Shadowfacts on 7/3/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine class ProfileViewController: UIPageViewController { weak var mastodonController: MastodonController! // This property is optional because MyProfileViewController may not have the user's account ID // when first constructed. It should never be set to nil. var accountID: String? { willSet { if newValue == nil { fatalError("Do not set ProfileViewController.accountID to nil") } } didSet { pageControllers.forEach { $0.accountID = accountID } loadAccount() } } private var accountUpdater: Cancellable? private(set) var currentIndex: Int! let pageControllers: [ProfileStatusesViewController] var currentViewController: ProfileStatusesViewController { pageControllers[currentIndex] } private var headerView: ProfileHeaderView! private var hasAppeared = false init(accountID: String?, mastodonController: MastodonController) { self.accountID = accountID self.mastodonController = mastodonController self.pageControllers = [ ProfileStatusesViewController(accountID: accountID, kind: .statuses, mastodonController: mastodonController), ProfileStatusesViewController(accountID: accountID, kind: .withReplies, mastodonController: mastodonController), ProfileStatusesViewController(accountID: accountID, kind: .onlyMedia, mastodonController: mastodonController) ] super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } deinit { if let accountID = accountID { mastodonController.persistentContainer.account(for: accountID)?.decrementReferenceCount() } } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .systemBackground let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning)) composeButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: [ UIAction(title: "Direct Message", image: UIImage(systemName: Status.Visibility.direct.unfilledImageName), identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in self.composeDirectMentioning() }) ]) navigationItem.rightBarButtonItem = composeButton headerView = ProfileHeaderView.create() headerView.delegate = self selectPage(at: 0, animated: false) currentViewController.tableView.tableHeaderView = headerView NSLayoutConstraint.activate([ headerView.widthAnchor.constraint(equalTo: view.widthAnchor), ]) addKeyCommand(MenuController.prevSubTabCommand) addKeyCommand(MenuController.nextSubTabCommand) accountUpdater = mastodonController.persistentContainer.accountSubject .filter { [weak self] in $0 == self?.accountID } .receive(on: DispatchQueue.main) .sink { [weak self] (_) in self?.updateAccountUI() } loadAccount() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) hasAppeared = true } private func loadAccount() { guard let accountID = accountID else { return } if mastodonController.persistentContainer.account(for: accountID) != nil { updateAccountUI() } else { let req = Client.getAccount(id: accountID) mastodonController.run(req) { [weak self] (response) in guard let self = self else { return } guard case let .success(account, _) = response else { fatalError() } self.mastodonController.persistentContainer.addOrUpdate(account: account, incrementReferenceCount: true) { (account) in DispatchQueue.main.async { self.updateAccountUI() } } } } } private func updateAccountUI() { guard let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) else { return } if let currentAccountID = mastodonController.accountInfo?.id { userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID) } // Optionally invoke updateUI on headerView because viewDidLoad may not have been called yet headerView?.updateUI(for: accountID) navigationItem.title = account.displayNameWithoutCustomEmoji // Only call updateUI on the individual page controllers if the account is loaded after the profile VC has appeared on screen. // Otherwise, fi the page view controllers do something with the table view before they appear, the table view doesn't load // its cells until the user begins to scroll. if hasAppeared { pageControllers.forEach { $0.updateUI(account: account) } } } private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) { let direction: UIPageViewController.NavigationDirection = currentIndex == nil || index - currentIndex > 0 ? .forward : .reverse currentIndex = index headerView.pagesSegmentedControl.selectedSegmentIndex = index guard let old = viewControllers?.first as? ProfileStatusesViewController else { // if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary // since it will be added in viewDidLoad setViewControllers([pageControllers[index]], direction: direction, animated: animated, completion: completion) return } let new = pageControllers[index] let headerHeight = self.headerView.bounds.height // Store old's content offset so it can be transferred to new let prevOldContentOffset = old.tableView.contentOffset // Remove the header, inset the table content by the same amount, and adjust the offset so the cells don't move old.tableView.tableHeaderView = nil old.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0) old.tableView.contentOffset.y -= headerHeight // Add the header to ourself temporarily, and constrain it to the same position it was in self.view.addSubview(self.headerView) let tempTopConstraint = self.headerView.topAnchor.constraint(equalTo: self.view.topAnchor, constant: -(prevOldContentOffset.y + old.tableView.safeAreaInsets.top)) NSLayoutConstraint.activate([ self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor), tempTopConstraint ]) // Setup the inset in new, in case it hasn't been already new.tableView.contentInset = UIEdgeInsets(top: headerHeight, left: 0, bottom: 0, right: 0) // Match the scroll positions new.tableView.contentOffset = old.tableView.contentOffset // Actually switch pages setViewControllers([pageControllers[index]], direction: direction, animated: animated) { (finished) in // Defer everything one run-loop iteration, otherwise altering the tableView's contentInset/Offset causes it to jump around during the animation DispatchQueue.main.async { // Move the header to the new table view new.tableView.tableHeaderView = self.headerView // Remove the inset, and set the offset back to old's original one, prior to removing the header new.tableView.contentInset = .zero new.tableView.contentOffset = prevOldContentOffset // Deactivate the top constraint, otherwise it sticks around tempTopConstraint.isActive = false // Re-add the width constraint since it was removed by re-parenting the view // Why was the width constraint removed, but the top one not? Good question, I have no idea. NSLayoutConstraint.activate([ self.headerView.widthAnchor.constraint(equalTo: self.view.widthAnchor) ]) // Layout and update the table view, otherwise the content jumps around when first scrolling it, // if old was not scrolled all the way to the top new.tableView.layoutIfNeeded() UIView.performWithoutAnimation { new.tableView.performBatchUpdates(nil, completion: nil) } completion?(finished) } } } // MARK: Interaction @objc private func composeMentioning() { if let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) { compose(mentioningAcct: account.acct) } } private func composeDirectMentioning() { if let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) { let draft = mastodonController.createDraft(mentioningAcct: account.acct) draft.visibility = .direct compose(editing: draft) } } } extension ProfileViewController: TuskerNavigationDelegate { var apiController: MastodonController { mastodonController } } extension ProfileViewController: ProfileHeaderViewDelegate { func profileHeader(_ view: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int) { // disable user interaction on segmented control while switching pages to prevent // race condition from trying to switch to multiple pages simultaneously view.pagesSegmentedControl.isUserInteractionEnabled = false selectPage(at: newIndex, animated: true) { (finished) in view.pagesSegmentedControl.isUserInteractionEnabled = true } } func profileHeader(_ view: ProfileHeaderView, showMoreOptionsFor accountID: String, sourceView: UIView) { let account = mastodonController.persistentContainer.account(for: accountID)! func showActivityController(activities: [UIActivity]) { let activityController = UIActivityViewController(activityItems: [account.url, AccountActivityItemSource(account)], applicationActivities: activities) activityController.completionWithItemsHandler = OpenInSafariActivity.completionHandler(navigator: self, url: account.url) activityController.popoverPresentationController?.sourceView = sourceView self.present(activityController, animated: true) } if account.id == mastodonController.account.id { showActivityController(activities: [OpenInSafariActivity()]) } else { let request = Client.getRelationships(accounts: [account.id]) mastodonController.run(request) { (response) in var customActivities: [UIActivity] = [OpenInSafariActivity()] if case let .success(results, _) = response, let relationship = results.first { let toggleFollowActivity = relationship.following ? UnfollowAccountActivity() : FollowAccountActivity() customActivities.insert(toggleFollowActivity, at: 0) } DispatchQueue.main.async { showActivityController(activities: customActivities) } } } } } extension ProfileViewController: TabBarScrollableViewController { func tabBarScrollToTop() { pageControllers[currentIndex].tabBarScrollToTop() } } extension ProfileViewController: TabbedPageViewController { func selectNextPage() { guard currentIndex < pageControllers.count - 1 else { return } selectPage(at: currentIndex + 1, animated: true) } func selectPrevPage() { guard currentIndex > 0 else { return } selectPage(at: currentIndex - 1, animated: true) } }