372 lines
15 KiB
Swift
372 lines
15 KiB
Swift
//
|
|
// ProfileViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 10/10/22.
|
|
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Pachyderm
|
|
import Combine
|
|
|
|
class ProfileViewController: UIViewController, StateRestorableViewController {
|
|
|
|
let 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 {
|
|
precondition(newValue != nil, "Do not set ProfileViewController.accountID to nil")
|
|
}
|
|
didSet {
|
|
pageControllers.forEach { $0.setAccountID(accountID!) }
|
|
Task {
|
|
await loadAccount()
|
|
}
|
|
}
|
|
}
|
|
|
|
private(set) var currentIndex: Int!
|
|
private let pages = [Page.posts, .postsAndReplies, .media]
|
|
private var pageControllers: [ProfileStatusesViewController]!
|
|
var currentPage: Page {
|
|
pages[currentIndex]
|
|
}
|
|
var currentViewController: ProfileStatusesViewController {
|
|
pageControllers[currentIndex]
|
|
}
|
|
|
|
private var state: State = .idle
|
|
|
|
private var cancellables = Set<AnyCancellable>()
|
|
|
|
init(accountID: String?, mastodonController: MastodonController) {
|
|
self.accountID = accountID
|
|
self.mastodonController = mastodonController
|
|
|
|
super.init(nibName: nil, bundle: nil)
|
|
|
|
self.pageControllers = [
|
|
.init(accountID: accountID, kind: .statuses, owner: self),
|
|
.init(accountID: accountID, kind: .withReplies, owner: self),
|
|
.init(accountID: accountID, kind: .onlyMedia, owner: self),
|
|
]
|
|
|
|
// try to update the account UI immediately if possible, to avoid the navigation title popping in later
|
|
if let accountID,
|
|
let account = mastodonController.persistentContainer.account(for: accountID) {
|
|
updateAccountUI(account: account)
|
|
}
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
view.backgroundColor = .appBackground
|
|
|
|
for pageController in pageControllers {
|
|
pageController.profileHeaderDelegate = self
|
|
}
|
|
|
|
selectPage(at: 0, animated: false)
|
|
|
|
let composeButton = UIBarButtonItem(barButtonSystemItem: .compose, target: self, action: #selector(composeMentioning))
|
|
composeButton.menu = UIMenu(children: [
|
|
UIAction(title: "Direct Message", image: UIImage(systemName: Visibility.direct.unfilledImageName), handler: { [unowned self] _ in
|
|
self.composeDirectMentioning()
|
|
})
|
|
])
|
|
composeButton.isEnabled = mastodonController.loggedIn
|
|
navigationItem.rightBarButtonItem = composeButton
|
|
|
|
addKeyCommand(MenuController.prevSubTabCommand)
|
|
addKeyCommand(MenuController.nextSubTabCommand)
|
|
|
|
mastodonController.persistentContainer.accountSubject
|
|
.receive(on: DispatchQueue.main)
|
|
.filter { [unowned self] in $0 == self.accountID }
|
|
.sink { [unowned self] id in
|
|
let account = self.mastodonController.persistentContainer.account(for: id)!
|
|
self.updateAccountUI(account: account)
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
Task {
|
|
await loadAccount()
|
|
}
|
|
|
|
// disable the transparent nav bar because it gets messy with multiple pages at different scroll positions
|
|
if let nav = navigationController {
|
|
let appearance = UINavigationBarAppearance()
|
|
appearance.configureWithDefaultBackground()
|
|
nav.navigationBar.scrollEdgeAppearance = appearance
|
|
}
|
|
}
|
|
|
|
func updateUserActivity() {
|
|
if let accountID,
|
|
let currentAccountID = mastodonController.accountInfo?.id {
|
|
userActivity = UserActivityManager.showProfileActivity(id: accountID, accountID: currentAccountID)
|
|
}
|
|
}
|
|
|
|
private func loadAccount() async {
|
|
guard let accountID else {
|
|
return
|
|
}
|
|
if let account = mastodonController.persistentContainer.account(for: accountID) {
|
|
updateAccountUI(account: account)
|
|
} else {
|
|
do {
|
|
let req = Client.getAccount(id: accountID)
|
|
let (account, _) = try await mastodonController.run(req)
|
|
let mo = await withCheckedContinuation { continuation in
|
|
mastodonController.persistentContainer.addOrUpdate(account: account, in: mastodonController.persistentContainer.viewContext) { (mo) in
|
|
continuation.resume(returning: mo)
|
|
}
|
|
}
|
|
self.updateAccountUI(account: mo)
|
|
} catch {
|
|
let config = ToastConfiguration(from: error, with: "Loading Account", in: self) { [unowned self] toast in
|
|
toast.dismissToast(animated: true)
|
|
await self.loadAccount()
|
|
}
|
|
self.showToast(configuration: config, animated: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func updateAccountUI(account: AccountMO) {
|
|
updateUserActivity()
|
|
navigationItem.title = account.displayNameWithoutCustomEmoji
|
|
}
|
|
|
|
private func selectPage(at index: Int, animated: Bool, completion: ((Bool) -> Void)? = nil) {
|
|
guard case .idle = state else {
|
|
return
|
|
}
|
|
|
|
state = .animating
|
|
|
|
let new = pageControllers[index]
|
|
|
|
guard let currentIndex else {
|
|
assert(!animated)
|
|
// if old doesn't exist, we're selecting the initial view controller, so moving the header around isn't necessary
|
|
new.initialHeaderMode = .createView
|
|
new.view.translatesAutoresizingMaskIntoConstraints = false
|
|
addChild(new)
|
|
view.addSubview(new.view)
|
|
NSLayoutConstraint.activate([
|
|
new.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
new.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
new.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
new.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
new.didMove(toParent: self)
|
|
self.currentIndex = index
|
|
state = .idle
|
|
completion?(true)
|
|
return
|
|
}
|
|
|
|
let direction: CGFloat
|
|
if index - currentIndex > 0 {
|
|
direction = 1 // forward
|
|
} else {
|
|
direction = -1 // reverse
|
|
}
|
|
|
|
let old = pageControllers[currentIndex]
|
|
|
|
new.loadViewIfNeeded()
|
|
|
|
self.currentIndex = index
|
|
|
|
// TODO: old.headerCell could be nil if scrolled down and key command used
|
|
let oldHeaderCell = old.headerCell!
|
|
|
|
// old header cell must have the header view
|
|
let headerView = oldHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)!
|
|
|
|
if let newHeaderCell = new.headerCell {
|
|
_ = newHeaderCell.addConstraint(height: oldHeaderCell.bounds.height)
|
|
} else {
|
|
new.initialHeaderMode = .placeholder(height: oldHeaderCell.bounds.height)
|
|
}
|
|
|
|
// disable user interaction during animation, to avoid any potential weird race conditions
|
|
headerView.isUserInteractionEnabled = false
|
|
headerView.layer.zPosition = 100
|
|
view.addSubview(headerView)
|
|
let oldHeaderCellTop = oldHeaderCell.convert(CGPoint.zero, to: view).y
|
|
let headerTopOffset = oldHeaderCellTop - view.safeAreaInsets.top
|
|
let headerBottomOffset = oldHeaderCell.convert(CGPoint(x: 0, y: oldHeaderCell.bounds.maxY), to: view).y// - view.safeAreaInsets.top
|
|
NSLayoutConstraint.activate([
|
|
headerView.topAnchor.constraint(equalTo: view.topAnchor, constant: headerTopOffset),
|
|
headerView.bottomAnchor.constraint(equalTo: view.topAnchor, constant: headerBottomOffset),
|
|
headerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
headerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
|
|
])
|
|
|
|
// hide scroll indicators during the transition because otherwise the show through the
|
|
// profile header, even though it has an opaque background
|
|
old.collectionView.showsVerticalScrollIndicator = false
|
|
new.collectionView.showsVerticalScrollIndicator = false
|
|
|
|
let origOldContentOffset = old.collectionView.contentOffset
|
|
// we can't just change the content offset during the animation, otherwise the new collection view doesn't size the cells at the top
|
|
// and new's offset doesn't physically match old's, even though they're numerically the same
|
|
let needsMatchContentOffsetWithTransform = new.state != .loaded
|
|
let yTranslationToMatchOldContentOffset: CGFloat
|
|
if needsMatchContentOffsetWithTransform {
|
|
yTranslationToMatchOldContentOffset = -origOldContentOffset.y - view.safeAreaInsets.top
|
|
} else {
|
|
new.collectionView.contentOffset = origOldContentOffset
|
|
yTranslationToMatchOldContentOffset = 0
|
|
}
|
|
|
|
if animated {
|
|
// if the new view isn't tall enough to match content offsets
|
|
if new.collectionView.contentSize.height - new.collectionView.bounds.height < old.collectionView.contentOffset.y {
|
|
let additionalHeightNeededToMatchContentOffset = old.collectionView.contentOffset.y + old.collectionView.bounds.height - new.collectionView.contentSize.height
|
|
new.collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: additionalHeightNeededToMatchContentOffset, right: 0)
|
|
}
|
|
|
|
new.view.translatesAutoresizingMaskIntoConstraints = false
|
|
embedChild(new)
|
|
new.view.transform = CGAffineTransform(translationX: direction * view.bounds.width, y: yTranslationToMatchOldContentOffset)
|
|
|
|
let animator = UIViewPropertyAnimator(duration: 0.5, timingParameters: UISpringTimingParameters(dampingRatio: 1, initialVelocity: .zero))
|
|
animator.addAnimations {
|
|
new.view.transform = CGAffineTransform(translationX: 0, y: yTranslationToMatchOldContentOffset)
|
|
old.view.transform = CGAffineTransform(translationX: -direction * self.view.bounds.width, y: 0)
|
|
}
|
|
animator.addCompletion { _ in
|
|
old.removeViewAndController()
|
|
old.collectionView.transform = .identity
|
|
|
|
new.collectionView.transform = .identity
|
|
new.collectionView.contentOffset = origOldContentOffset
|
|
|
|
// reenable scroll indicators after the switching animation is done
|
|
old.collectionView.showsVerticalScrollIndicator = true
|
|
new.collectionView.showsVerticalScrollIndicator = true
|
|
|
|
headerView.isUserInteractionEnabled = true
|
|
headerView.transform = .identity
|
|
headerView.layer.zPosition = 0
|
|
// move the header view into the new page controller's cell
|
|
if let newHeaderCell = new.headerCell {
|
|
newHeaderCell.addHeader(headerView)
|
|
} else {
|
|
new.initialHeaderMode = .useExistingView(headerView)
|
|
}
|
|
|
|
self.state = .idle
|
|
completion?(true)
|
|
}
|
|
animator.startAnimation()
|
|
} else {
|
|
old.removeViewAndController()
|
|
new.view.translatesAutoresizingMaskIntoConstraints = false
|
|
embedChild(new)
|
|
completion?(true)
|
|
}
|
|
}
|
|
|
|
// MARK: Interaction
|
|
|
|
@objc private func composeMentioning() {
|
|
if let accountID,
|
|
let account = mastodonController.persistentContainer.account(for: accountID) {
|
|
compose(mentioningAcct: account.acct)
|
|
}
|
|
}
|
|
|
|
private func composeDirectMentioning() {
|
|
if let accountID,
|
|
let account = mastodonController.persistentContainer.account(for: accountID) {
|
|
let draft = mastodonController.createDraft(mentioningAcct: account.acct)
|
|
draft.visibility = .direct
|
|
compose(editing: draft)
|
|
}
|
|
}
|
|
|
|
// MARK: StateRestorableViewController
|
|
func stateRestorationActivity() -> NSUserActivity? {
|
|
if let accountID,
|
|
let accountInfo = mastodonController.accountInfo {
|
|
return UserActivityManager.showProfileActivity(id: accountID, accountID: accountInfo.id)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ProfileViewController {
|
|
enum Page: Hashable {
|
|
case posts
|
|
case postsAndReplies
|
|
case media
|
|
}
|
|
}
|
|
|
|
extension ProfileViewController {
|
|
enum State {
|
|
case idle
|
|
case animating
|
|
}
|
|
}
|
|
|
|
extension ProfileViewController: TuskerNavigationDelegate {
|
|
var apiController: MastodonController! { mastodonController }
|
|
}
|
|
|
|
extension ProfileViewController: ToastableViewController {
|
|
}
|
|
|
|
extension ProfileViewController: ProfileHeaderViewDelegate {
|
|
func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: Page) {
|
|
guard case .idle = state else {
|
|
headerView.pagesSegmentedControl.setSelectedOption(currentPage, animated: false)
|
|
return
|
|
}
|
|
selectPage(at: pages.firstIndex(of: newPage)!, animated: true)
|
|
}
|
|
}
|
|
|
|
extension ProfileViewController: TabbedPageViewController {
|
|
func selectNextPage() {
|
|
guard currentIndex < pageControllers.count - 1 else { return }
|
|
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex + 1], animated: true)
|
|
selectPage(at: currentIndex + 1, animated: true)
|
|
}
|
|
|
|
func selectPrevPage() {
|
|
guard currentIndex > 0 else { return }
|
|
currentViewController.headerCell?.view?.pagesSegmentedControl.setSelectedOption(pages[currentIndex - 1], animated: true)
|
|
selectPage(at: currentIndex - 1, animated: true)
|
|
}
|
|
}
|
|
|
|
extension ProfileViewController: TabBarScrollableViewController {
|
|
func tabBarScrollToTop() {
|
|
guard isViewLoaded else { return }
|
|
currentViewController.tabBarScrollToTop()
|
|
}
|
|
}
|
|
|
|
extension ProfileViewController: StatusBarTappableViewController {
|
|
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
|
|
guard isViewLoaded else { return .stop }
|
|
return currentViewController.handleStatusBarTapped(xPosition: xPosition)
|
|
}
|
|
}
|