diff --git a/Pachyderm/Sources/Pachyderm/Model/Account.swift b/Pachyderm/Sources/Pachyderm/Model/Account.swift index 49dd7f12..053e1a05 100644 --- a/Pachyderm/Sources/Pachyderm/Model/Account.swift +++ b/Pachyderm/Sources/Pachyderm/Model/Account.swift @@ -167,5 +167,12 @@ extension Account { public struct Field: Codable { public let name: String public let value: String + public let verifiedAt: Date? + + enum CodingKeys: String, CodingKey { + case name + case value + case verifiedAt = "verified_at" + } } } diff --git a/Tusker/Views/Profile Header/ProfileFieldsView.swift b/Tusker/Views/Profile Header/ProfileFieldsView.swift index fcfa72a7..555272e6 100644 --- a/Tusker/Views/Profile Header/ProfileFieldsView.swift +++ b/Tusker/Views/Profile Header/ProfileFieldsView.swift @@ -7,13 +7,15 @@ // import UIKit +import Pachyderm +import SwiftUI class ProfileFieldsView: UIView { weak var delegate: ProfileHeaderViewDelegate? private let stack = UIStackView() - private var fieldViews: [(EmojiLabel, ContentTextView)] = [] + private var fieldViews: [(EmojiLabel, ProfileFieldValueView)] = [] private var fieldConstraints: [NSLayoutConstraint] = [] private var isUsingSingleColumn: Bool = false @@ -80,16 +82,11 @@ class ProfileFieldsView: UIView { nameLabel.setEmojis(account.emojis, identifier: account.id) nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let valueTextView = ContentTextView() - valueTextView.isSelectable = false - valueTextView.defaultFont = .preferredFont(forTextStyle: .body) - valueTextView.adjustsFontForContentSizeCategory = true - valueTextView.setTextFromHtml(field.value) - valueTextView.setEmojis(account.emojis, identifier: account.id) - valueTextView.navigationDelegate = delegate - valueTextView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + let valueView = ProfileFieldValueView(field: field, account: account) + valueView.navigationDelegate = delegate + valueView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - fieldViews.append((nameLabel, valueTextView)) + fieldViews.append((nameLabel, valueView)) } configureFields() @@ -121,7 +118,7 @@ class ProfileFieldsView: UIView { } name.textAlignment = .natural stack.addArrangedSubview(name) - value.textAlignment = .natural + value.setTextAlignment(.natural) stack.addArrangedSubview(value) } } else { @@ -137,7 +134,7 @@ class ProfileFieldsView: UIView { name.textAlignment = .right name.translatesAutoresizingMaskIntoConstraints = false - value.textAlignment = .left + value.setTextAlignment(.left) value.translatesAutoresizingMaskIntoConstraints = false let fieldContainer = UIView() @@ -165,3 +162,159 @@ class ProfileFieldsView: UIView { } } + +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.defaultFont = .preferredFont(forTextStyle: .body) + 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) + } +}