347 lines
14 KiB
Swift
347 lines
14 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!
|
|
private(set) var pagesSegmentedControl: ScrollingSegmentedControl<ProfileViewController.Page>!
|
|
|
|
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()
|
|
|
|
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)
|
|
|
|
accessibilityElements = [
|
|
displayNameLabel!,
|
|
usernameLabel!,
|
|
relationshipLabel!,
|
|
noteTextView!,
|
|
fieldsView!,
|
|
moreButton!,
|
|
pagesSegmentedControl!,
|
|
]
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|