// // ProfileHeaderView.swift // Tusker // // Created by Shadowfacts on 7/4/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider { func profileHeader(_ headerView: ProfileHeaderView, selectedPageChangedTo newPage: ProfileViewController.Page) } class ProfileHeaderView: UIView { static func create() -> ProfileHeaderView { let nib = UINib(nibName: "ProfileHeaderView", bundle: .main) return nib.instantiate(withOwner: nil, options: nil).first as! ProfileHeaderView } weak var delegate: ProfileHeaderViewDelegate? var mastodonController: MastodonController! { delegate?.apiController } @IBOutlet weak var headerImageView: UIImageView! @IBOutlet weak var avatarContainerView: UIView! @IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var moreButton: ProfileHeaderButton! @IBOutlet weak var followButton: ProfileHeaderButton! @IBOutlet weak var displayNameLabel: EmojiLabel! @IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var lockImageView: UIImageView! @IBOutlet weak var vStack: UIStackView! @IBOutlet weak var relationshipLabel: UILabel! @IBOutlet weak var noteTextView: StatusContentTextView! @IBOutlet weak var fieldsView: ProfileFieldsView! @IBOutlet weak var followCountButton: UIButton! private(set) var pagesSegmentedControl: ScrollingSegmentedControl! private var movedOverlayView: ProfileHeaderMovedOverlayView? var accountID: String! private var avatarRequest: ImageCache.Request? private var headerRequest: ImageCache.Request? private var isGrayscale = false private var followButtonMode = FollowButtonMode.follow { didSet { var config = followButton.configuration! followButtonMode.updateConfig(&config) config.showsActivityIndicator = false followButton.configuration = config } } deinit { avatarRequest?.cancel() headerRequest?.cancel() } override func awakeFromNib() { super.awakeFromNib() backgroundColor = .appBackground avatarContainerView.backgroundColor = .appBackground avatarContainerView.layer.masksToBounds = true avatarImageView.layer.masksToBounds = true avatarImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(avatarPressed))) avatarImageView.isUserInteractionEnabled = true headerImageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(headerPressed))) headerImageView.isUserInteractionEnabled = true var config = UIButton.Configuration.plain() config.image = UIImage(systemName: "ellipsis") moreButton.configuration = config moreButton.setNeedsUpdateConfiguration() moreButton.addInteraction(UIPointerInteraction(delegate: self)) moreButton.showsMenuAsPrimaryAction = true moreButton.isContextMenuInteractionEnabled = true followButton.setNeedsUpdateConfiguration() followButton.addInteraction(UIPointerInteraction(delegate: self)) displayNameLabel.font = UIFontMetrics(forTextStyle: .title1).scaledFont(for: .systemFont(ofSize: 24, weight: .semibold)) displayNameLabel.adjustsFontForContentSizeCategory = true usernameLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 15, weight: .light)) usernameLabel.adjustsFontForContentSizeCategory = true relationshipLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14)) relationshipLabel.adjustsFontForContentSizeCategory = true noteTextView.defaultFont = .preferredFont(forTextStyle: .body) noteTextView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)) noteTextView.adjustsFontForContentSizeCategory = true pagesSegmentedControl = ScrollingSegmentedControl(frame: .zero) pagesSegmentedControl.options = [ .init(value: .posts, name: "Posts"), .init(value: .postsAndReplies, name: "Posts and Replies"), .init(value: .media, name: "Media"), ] pagesSegmentedControl.setSelectedOption(.posts, animated: false) pagesSegmentedControl.didSelectOption = { [unowned self] newPage in if let newPage { self.delegate?.profileHeader(self, selectedPageChangedTo: newPage) } } vStack.addArrangedSubview(pagesSegmentedControl) // equal inset on both sides, the leading inset is applied to the vStack pagesSegmentedControl.widthAnchor.constraint(equalTo: vStack.widthAnchor, constant: -16).isActive = true // the segemented control itself is only focusable when VoiceOver is in Group navigation mode, // so make it clear that to switch tabs the user needs to enter the group pagesSegmentedControl.accessibilityHint = "Enter group to select scope" NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) } func updateUI(for accountID: String) { self.accountID = accountID guard let mastodonController = mastodonController else { return } guard let account = mastodonController.persistentContainer.account(for: accountID) else { fatalError("Missing cached account \(accountID)") } updateUIForPreferences() usernameLabel.text = "@\(account.acct)" lockImageView.isHidden = !account.locked updateImages(account: account) moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: delegate?.actionsForProfile(accountID: accountID, source: .view(moreButton), fetchRelationship: false) ?? []) noteTextView.navigationDelegate = delegate noteTextView.setTextFromHtml(account.note) noteTextView.setEmojis(account.emojis, identifier: account.id) if accountID == mastodonController.account?.id { followButton.isHidden = true } else { followButton.isHidden = false // while fetching the most up-to-date, show the current data (if any) updateRelationship() } fieldsView.delegate = delegate fieldsView.updateUI(account: account) let (followingAbbr, followingSpelledOut) = formatBigNumber(account.followingCount) let (followersAbbr, followersSpelledOut) = formatBigNumber(account.followersCount) let followCountTitle = NSMutableAttributedString() followCountTitle.append(NSAttributedString(string: followingAbbr, attributes: [ .font: UIFont.preferredFont(forTextStyle: .body).withTraits(.traitBold)!, ])) followCountTitle.append(NSAttributedString(string: " Following, ", attributes: [ .foregroundColor: UIColor.secondaryLabel, ])) followCountTitle.append(NSAttributedString(string: followersAbbr, attributes: [ .font: UIFont.preferredFont(forTextStyle: .body).withTraits(.traitBold)!, ])) followCountTitle.append(NSAttributedString(string: " Follower\(account.followersCount == 1 ? "" : "s")", attributes: [ .foregroundColor: UIColor.secondaryLabel, ])) followCountButton.setAttributedTitle(followCountTitle, for: .normal) followCountButton.accessibilityLabel = "\(followingSpelledOut) following, \(followersSpelledOut) followers" if let movedTo = account.movedTo { if let movedOverlayView { movedOverlayView.updateUI(movedTo: movedTo) } else { let overlay = createMovedOverlayView(movedTo: movedTo) movedOverlayView = overlay accessibilityElements = [ overlay, ] } } else { movedOverlayView?.removeFromSuperview() movedOverlayView = nil accessibilityElements = [ displayNameLabel!, usernameLabel!, relationshipLabel!, noteTextView!, fieldsView!, moreButton!, pagesSegmentedControl!, ] } } private func createMovedOverlayView(movedTo: AccountMO) -> ProfileHeaderMovedOverlayView { let overlay = ProfileHeaderMovedOverlayView() overlay.delegate = delegate overlay.updateUI(movedTo: movedTo) overlay.translatesAutoresizingMaskIntoConstraints = false addSubview(overlay) let bottomConstraint = overlay.bottomAnchor.constraint(equalTo: bottomAnchor) NSLayoutConstraint.activate([ overlay.leadingAnchor.constraint(equalTo: leadingAnchor), overlay.trailingAnchor.constraint(equalTo: trailingAnchor), overlay.topAnchor.constraint(equalTo: topAnchor), bottomConstraint, ]) overlay.collapse = { [weak self, weak overlay] in guard let self, let overlay else { return } bottomConstraint.isActive = false overlay.bottomAnchor.constraint(equalTo: self.avatarContainerView.topAnchor, constant: -2).isActive = true let animator = UIViewPropertyAnimator(duration: 0.35, dampingRatio: 0.8) animator.addAnimations { self.layoutIfNeeded() overlay.collapseButton.layer.opacity = 0 } animator.addCompletion { _ in overlay.collapseButton.layer.opacity = 1 overlay.collapseButton?.removeFromSuperview() } animator.startAnimation() } return overlay } private func updateRelationship() { guard let mastodonController = mastodonController, let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else { relationshipLabel.isHidden = true followButton.isEnabled = false followButtonMode = .follow return } var relationshipStr: String? if relationship.following && relationship.followedBy { relationshipStr = "You follow each other" } else if relationship.following { relationshipStr = "You follow" } else if relationship.followedBy { relationshipStr = "Follows you" } else if relationship.blocking { relationshipStr = "You block" } if let relationshipStr { relationshipLabel.text = relationshipStr relationshipLabel.isHidden = false } else { relationshipLabel.isHidden = true } if relationship.blocking || relationship.domainBlocking { followButton.isEnabled = false followButtonMode = .blocked } else { followButton.isEnabled = true if relationship.following { followButtonMode = .unfollow } else if relationship.requested { followButtonMode = .cancelRequest } else { followButtonMode = .follow } } } @objc private func updateUIForPreferences() { guard let mastodonController = mastodonController, // nil if prefs changed before own account is loaded let accountID = accountID, let account = mastodonController.persistentContainer.account(for: accountID) else { return } avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) displayNameLabel.updateForAccountDisplayName(account: account) if isGrayscale != Preferences.shared.grayscaleImages { updateImages(account: account) } } private func updateImages(account: AccountMO) { isGrayscale = Preferences.shared.grayscaleImages let accountID = account.id if let avatarURL = account.avatar { // always load original for avatars, because ImageCache.avatars stores them scaled-down in memory avatarRequest = ImageCache.avatars.get(avatarURL, loadOriginal: true) { [weak self] (_, image) in guard let self = self, let image = image, self.accountID == accountID, let transformedImage = ImageGrayscalifier.convertIfNecessary(url: avatarURL, image: image) else { DispatchQueue.main.async { self?.avatarRequest = nil } return } DispatchQueue.main.async { self.avatarRequest = nil self.avatarImageView.image = transformedImage } } } if let header = account.header { headerRequest = ImageCache.headers.get(header) { [weak self] (_, image) in guard let self = self, let image = image, self.accountID == accountID, let transformedImage = ImageGrayscalifier.convertIfNecessary(url: header, image: image) else { DispatchQueue.main.async { self?.headerRequest = nil } return } DispatchQueue.main.async { self.headerRequest = nil self.headerImageView.image = transformedImage } } } } private func formatBigNumber(_ value: Int) -> (String, String) { let formatter = NumberFormatter() formatter.maximumFractionDigits = 1 for (threshold, abbr, spelledOut) in [(1_000_000, "m", "million"), (1_000, "k", "thousand")] { if value >= threshold { let frac = Double(value) / Double(threshold) let s = formatter.string(from: frac as NSNumber)! return ("\(s)\(abbr)", "\(s) \(spelledOut)") } } let s = formatter.string(from: value as NSNumber)! return (s, s) } // MARK: Interaction @objc func avatarPressed() { guard let account = mastodonController.persistentContainer.account(for: accountID), let avatar = account.avatar else { return } delegate?.showLoadingLargeImage(url: avatar, cache: .avatars, description: nil, animatingFrom: avatarImageView) } @objc func headerPressed() { guard let account = mastodonController.persistentContainer.account(for: accountID), let header = account.header else { return } delegate?.showLoadingLargeImage(url: header, cache: .headers, description: nil, animatingFrom: headerImageView) } @IBAction func followPressed() { let req: Request let action: String switch followButtonMode { case .follow: req = Account.follow(accountID) action = "Following" case .unfollow, .cancelRequest: req = Account.unfollow(accountID) action = followButtonMode == .unfollow ? "Unfollowing" : "Cancelling Request" case .blocked: return } followButton.configuration!.showsActivityIndicator = true followButton.isEnabled = false UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { do { let (relationship, _) = try await mastodonController.run(req) mastodonController.persistentContainer.addOrUpdate(relationship: relationship) // don't need to update the button, since the relationship observer will do so anyways } catch { followButton.isEnabled = true followButton.configuration!.showsActivityIndicator = false if let toastable = delegate?.toastableViewController { let config = ToastConfiguration(from: error, with: "Error \(action)", in: toastable) { toast in toast.dismissToast(animated: true) self.followPressed() } toastable.showToast(configuration: config, animated: true) } } } } @IBAction func followCountButtonPressed(_ sender: Any) { guard let accountID else { return } delegate?.show(AccountFollowsViewController(accountID: accountID, mastodonController: mastodonController)) } } extension ProfileHeaderView { enum FollowButtonMode { case follow, unfollow, cancelRequest, blocked func updateConfig(_ config: inout UIButton.Configuration) { switch self { case .follow: config.title = "Follow" config.image = UIImage(systemName: "person.badge.plus") case .unfollow: config.title = "Unfollow" config.image = UIImage(systemName: "person.badge.minus") case .cancelRequest: config.title = "Cancel Request" config.image = UIImage(systemName: "person.badge.clock") case .blocked: config.title = "Blocked" config.image = UIImage(systemName: "circle.slash") } } } } extension ProfileHeaderView: UIPointerInteractionDelegate { func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? { let preview = UITargetedPreview(view: interaction.view!) return UIPointerStyle(effect: .lift(preview), shape: .none) } }