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

260 lines
10 KiB
Swift

//
// ProfileHeaderView.swift
// Tusker
//
// Created by Shadowfacts on 7/4/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
protocol ProfileHeaderViewDelegate: TuskerNavigationDelegate, MenuActionProvider {
func profileHeader(_ headerView: ProfileHeaderView, selectedPostsIndexChangedTo newIndex: Int)
}
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? {
didSet {
createObservers()
}
}
var mastodonController: MastodonController! { delegate?.apiController }
@IBOutlet weak var headerImageView: UIImageView!
@IBOutlet weak var avatarContainerView: UIView!
@IBOutlet weak var avatarImageView: UIImageView!
@IBOutlet weak var moreButton: VisualEffectImageButton!
@IBOutlet weak var displayNameLabel: EmojiLabel!
@IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var lockImageView: UIImageView!
@IBOutlet weak var followsYouLabel: UILabel!
@IBOutlet weak var noteTextView: StatusContentTextView!
@IBOutlet weak var fieldsView: ProfileFieldsView!
@IBOutlet weak var pagesSegmentedControl: UISegmentedControl!
var accountID: String!
private var avatarRequest: ImageCache.Request?
private var headerRequest: ImageCache.Request?
private var isGrayscale = false
private var cancellables = [AnyCancellable]()
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
moreButton.layer.cornerRadius = 16
moreButton.layer.masksToBounds = true
moreButton.addInteraction(UIPointerInteraction(delegate: self))
moreButton.showsMenuAsPrimaryAction = true
moreButton.isContextMenuInteractionEnabled = true
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
followsYouLabel.font = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 14))
followsYouLabel.adjustsFontForContentSizeCategory = true
noteTextView.defaultFont = .preferredFont(forTextStyle: .body)
noteTextView.adjustsFontForContentSizeCategory = true
NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil)
}
private func createObservers() {
// mastodonController may be nil if the ProfileViewController is deinit'd before the header is even created
guard let mastodonController else {
return
}
cancellables = []
mastodonController.persistentContainer.accountSubject
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] in self?.updateUI(for: $0) }
.store(in: &cancellables)
mastodonController.persistentContainer.relationshipSubject
.receive(on: DispatchQueue.main)
.filter { [weak self] in $0 == self?.accountID }
.sink { [weak self] (_) in self?.updateRelationship() }
.store(in: &cancellables)
}
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)) ?? [])
noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis, identifier: account.id)
// don't show relationship label for the user's own account
if accountID != mastodonController.account?.id {
// while fetching the most up-to-date, show the current data (if any)
updateRelationship()
let request = Client.getRelationships(accounts: [accountID])
mastodonController.run(request) { [weak self] (response) in
guard let mastodonController = self?.mastodonController,
case let .success(results, _) = response,
let relationship = results.first else {
return
}
mastodonController.persistentContainer.addOrUpdate(relationship: relationship)
}
}
fieldsView.delegate = delegate
fieldsView.updateUI(account: account)
accessibilityElements = [
displayNameLabel!,
usernameLabel!,
noteTextView!,
fieldsView!,
moreButton!,
pagesSegmentedControl!,
]
}
private func updateRelationship() {
guard let mastodonController = mastodonController,
let relationship = mastodonController.persistentContainer.relationship(forAccount: accountID) else {
return
}
followsYouLabel.isHidden = !relationship.followedBy
}
@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 postsSegmentedControlChanged(_ sender: UISegmentedControl) {
delegate?.profileHeader(self, selectedPostsIndexChangedTo: sender.selectedSegmentIndex)
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
extension ProfileHeaderView: UIPointerInteractionDelegate {
func pointerInteraction(_ interaction: UIPointerInteraction, styleFor region: UIPointerRegion) -> UIPointerStyle? {
let preview = UITargetedPreview(view: moreButton)
return UIPointerStyle(effect: .lift(preview), shape: .none)
}
}