// // 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) } }