Tusker/Tusker/Views/Profile Header/ProfileHeaderView.swift

439 lines
18 KiB
Swift

//
// 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<ProfileViewController.Page>!
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<Relationship>
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)
}
}