Tusker/Tusker/Views/Profile Header/ProfileFieldsView.swift

326 lines
12 KiB
Swift

//
// ProfileFieldsView.swift
// Tusker
//
// Created by Shadowfacts on 11/4/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import SwiftUI
class ProfileFieldsView: UIView {
weak var delegate: ProfileHeaderViewDelegate?
private var fields = [Account.Field]()
private let stack = UIStackView()
private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = []
private var fieldConstraints: [NSLayoutConstraint] = []
private var isUsingSingleColumn: Bool = false
private var needsSingleColumn: Bool {
traitCollection.horizontalSizeClass == .compact && traitCollection.preferredContentSizeCategory > .extraLarge
}
override var accessibilityElements: [Any]? {
get {
fieldViews.flatMap { [$0.0, $0.1] }
}
set {}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
stack.axis = .vertical
stack.alignment = .fill
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if isUsingSingleColumn != needsSingleColumn {
configureFields()
}
}
func updateUI(account: AccountMO) {
isHidden = account.fields.isEmpty
guard !account.fields.isEmpty,
fields != account.fields else {
return
}
fields = account.fields
for (name, value) in fieldViews {
name.removeFromSuperview()
value.removeFromSuperview()
}
fieldViews = []
for field in account.fields {
let nameLabel = EmojiLabel()
nameLabel.text = field.name
nameLabel.font = .preferredFont(forTextStyle: .body).withTraits(.traitBold)!
nameLabel.adjustsFontForContentSizeCategory = true
nameLabel.numberOfLines = 0
nameLabel.lineBreakMode = .byWordWrapping
nameLabel.setEmojis(account.emojis, identifier: account.id)
nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let valueView = ProfileFieldValueView(field: field, account: account)
valueView.navigationDelegate = delegate
valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
fieldViews.append((nameLabel, valueView))
}
configureFields()
}
@objc private func configureFields() {
guard !isHidden else {
return
}
isUsingSingleColumn = needsSingleColumn
NSLayoutConstraint.deactivate(fieldConstraints)
fieldConstraints = []
stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
if needsSingleColumn {
stack.spacing = 4
var isFirst = true
for (name, value) in fieldViews {
if isFirst {
isFirst = false
} else {
let spacer = UIView()
// don't need any height, since there's 4pts of padding on either side
spacer.heightAnchor.constraint(equalToConstant: 0).isActive = true
stack.addArrangedSubview(spacer)
}
name.textAlignment = .natural
stack.addArrangedSubview(name)
value.setTextAlignment(.natural)
stack.addArrangedSubview(value)
}
} else {
stack.spacing = 8
let dividerLayoutGuide = UILayoutGuide()
addLayoutGuide(dividerLayoutGuide)
fieldConstraints.append(contentsOf: [
dividerLayoutGuide.widthAnchor.constraint(equalToConstant: 8),
])
for (name, value) in fieldViews {
name.textAlignment = .right
name.translatesAutoresizingMaskIntoConstraints = false
value.setTextAlignment(.left)
value.translatesAutoresizingMaskIntoConstraints = false
let fieldContainer = UIView()
fieldContainer.addSubview(name)
fieldContainer.addSubview(value)
stack.addArrangedSubview(fieldContainer)
fieldConstraints.append(contentsOf: [
name.leadingAnchor.constraint(equalTo: fieldContainer.leadingAnchor),
name.trailingAnchor.constraint(equalTo: dividerLayoutGuide.leadingAnchor),
name.topAnchor.constraint(equalTo: fieldContainer.topAnchor),
name.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor),
value.leadingAnchor.constraint(equalTo: dividerLayoutGuide.trailingAnchor),
value.trailingAnchor.constraint(equalTo: fieldContainer.trailingAnchor),
value.topAnchor.constraint(equalTo: fieldContainer.topAnchor),
value.bottomAnchor.constraint(equalTo: fieldContainer.bottomAnchor),
name.widthAnchor.constraint(greaterThanOrEqualTo: value.widthAnchor, multiplier: 0.5),
name.widthAnchor.constraint(lessThanOrEqualTo: value.widthAnchor, multiplier: 2),
])
}
}
NSLayoutConstraint.activate(fieldConstraints)
}
}
private class ProfileFieldValueView: UIView {
weak var navigationDelegate: TuskerNavigationDelegate? {
didSet {
textView.navigationDelegate = navigationDelegate
}
}
private let account: AccountMO
private let field: Account.Field
private let textView = ContentTextView()
private var iconView: UIView?
init(field: Account.Field, account: AccountMO) {
self.account = account
self.field = field
super.init(frame: .zero)
textView.isSelectable = false
textView.backgroundColor = .clear
textView.defaultFont = .preferredFont(forTextStyle: .body)
textView.monospaceFont = UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular))
textView.adjustsFontForContentSizeCategory = true
textView.setTextFromHtml(field.value)
textView.setEmojis(account.emojis, identifier: account.id)
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
textView.translatesAutoresizingMaskIntoConstraints = false
addSubview(textView)
let textViewTrailingConstraint: NSLayoutConstraint
if field.verifiedAt != nil {
var config = UIButton.Configuration.plain()
config.image = UIImage(systemName: "checkmark")
config.baseForegroundColor = .systemGreen
let icon = UIButton(configuration: config)
self.iconView = icon
icon.translatesAutoresizingMaskIntoConstraints = false
icon.setContentHuggingPriority(.defaultHigh, for: .horizontal)
icon.addTarget(self, action: #selector(verifiedIconTapped), for: .touchUpInside)
icon.isPointerInteractionEnabled = true
icon.accessibilityLabel = "Verified link"
addSubview(icon)
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor, constant: -4)
NSLayoutConstraint.activate([
icon.centerYAnchor.constraint(equalTo: centerYAnchor),
icon.trailingAnchor.constraint(equalTo: trailingAnchor),
])
} else {
textViewTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor)
}
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: leadingAnchor),
textViewTrailingConstraint,
textView.topAnchor.constraint(equalTo: topAnchor),
textView.bottomAnchor.constraint(equalTo: bottomAnchor),
])
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func setTextAlignment(_ alignment: NSTextAlignment) {
textView.textAlignment = alignment
}
@objc private func verifiedIconTapped() {
guard let navigationDelegate else {
return
}
let view = ProfileFieldVerificationView(
acct: account.acct,
verifiedAt: field.verifiedAt!,
linkText: textView.text,
navigationDelegate: navigationDelegate
)
let host = UIHostingController(rootView: view)
let toPresent: UIViewController
if traitCollection.horizontalSizeClass == .compact || traitCollection.verticalSizeClass == .compact {
toPresent = UINavigationController(rootViewController: host)
toPresent.modalPresentationStyle = .pageSheet
let sheetPresentationController = toPresent.sheetPresentationController!
sheetPresentationController.detents = [
.medium()
]
} else {
host.modalPresentationStyle = .popover
let popoverPresentationController = host.popoverPresentationController!
popoverPresentationController.sourceView = iconView
host.preferredContentSize = host.sizeThatFits(in: CGSize(width: 400, height: CGFloat.infinity))
toPresent = host
}
navigationDelegate.present(toPresent, animated: true)
}
}
private struct ProfileFieldVerificationView: View {
let acct: String
let verifiedAt: Date
let linkText: String
let navigationDelegate: TuskerNavigationDelegate
var body: some View {
VStack(alignment: .leading, spacing: 8) {
firstLine
secondLine
}
.padding()
.navigationTitle(Text("Verified Link"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") {
navigationDelegate.dismiss(animated: true)
}
}
}
.environment(\.openURL, OpenURLAction(handler: { url in
// dismiss the sheet/popover first
navigationDelegate.dismiss(animated: true) {
navigationDelegate.selected(url: url)
}
return .handled
}))
}
private var firstLine: Text {
var attrStr: AttributedString = "This link has been verified by your instance, "
var instance = AttributedString(navigationDelegate.apiController!.instanceURL.host!)
instance.font = .body.bold()
attrStr += instance
attrStr += "."
return Text(attrStr)
}
private var secondLine: Text {
var attrStr: AttributedString = "The page at "
var linkStr: AttributedString
if linkText.count > 43 {
linkStr = AttributedString(linkText.prefix(40) + "")
} else {
linkStr = AttributedString(linkText)
}
linkStr.link = URL(string: linkText)
attrStr += linkStr
attrStr += " was confirmed to link back to "
var acctStr = AttributedString("@\(acct)")
acctStr.font = .body.bold()
attrStr += acctStr
attrStr += AttributedString(" as of \(verifiedAt.formatted(date: .abbreviated, time: .shortened)).")
return Text(attrStr)
}
}