// // ProfileFieldValueView.swift // Tusker // // Created by Shadowfacts on 5/6/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import SwiftUI import SafariServices class ProfileFieldValueView: UIView { weak var navigationDelegate: TuskerNavigationDelegate? private static let converter = HTMLConverter( font: .preferredFont(forTextStyle: .body), monospaceFont: UIFontMetrics.default.scaledFont(for: .monospacedSystemFont(ofSize: 17, weight: .regular)), color: .label, paragraphStyle: .default ) private let account: AccountMO private let field: Account.Field private var link: (String, URL)? private let label = EmojiLabel() private var iconView: UIView? private var currentTargetedPreview: UITargetedPreview? init(field: Account.Field, account: AccountMO) { self.account = account self.field = field super.init(frame: .zero) let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) var range = NSRange(location: 0, length: 0) if converted.length != 0, let url = converted.attribute(.link, at: 0, longestEffectiveRange: &range, in: converted.fullRange) as? URL { link = (converted.attributedSubstring(from: range).string, url) label.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(linkTapped))) label.addInteraction(UIContextMenuInteraction(delegate: self)) label.isUserInteractionEnabled = true converted.enumerateAttribute(.link, in: converted.fullRange) { value, range, stop in guard value != nil else { return } converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) // the .link attribute in a UILabel always makes the color blue >.> converted.removeAttribute(.link, range: range) } } label.numberOfLines = 0 label.font = .preferredFont(forTextStyle: .body) label.adjustsFontForContentSizeCategory = true label.attributedText = converted label.setEmojis(account.emojis, identifier: account.id) label.setContentCompressionResistancePriority(.required, for: .vertical) label.translatesAutoresizingMaskIntoConstraints = false addSubview(label) let labelTrailingConstraint: NSLayoutConstraint if field.verifiedAt != nil { var config = UIButton.Configuration.plain() config.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(scale: .medium) 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) labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor) NSLayoutConstraint.activate([ icon.centerYAnchor.constraint(equalTo: label.centerYAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), ]) } else { labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor) } NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: leadingAnchor), labelTrailingConstraint, label.topAnchor.constraint(equalTo: topAnchor), label.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func sizeThatFits(_ size: CGSize) -> CGSize { var size = label.sizeThatFits(size) if let iconView { size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width } return size } func setTextAlignment(_ alignment: NSTextAlignment) { label.textAlignment = alignment } func getHashtagOrURL() -> (Hashtag?, URL)? { guard let (text, url) = link else { return nil } if text.starts(with: "#") { return (Hashtag(name: String(text.dropFirst()), url: url), url) } else { return (nil, url) } } @objc private func linkTapped() { guard let (hashtag, url) = getHashtagOrURL() else { return } if let hashtag { navigationDelegate?.selected(tag: hashtag) } else { navigationDelegate?.selected(url: url) } } @objc private func verifiedIconTapped() { guard let navigationDelegate else { return } let view = ProfileFieldVerificationView( acct: account.acct, verifiedAt: field.verifiedAt!, linkText: label.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) } } extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider { var toastableViewController: ToastableViewController? { navigationDelegate } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { guard let (hashtag, url) = getHashtagOrURL(), let navigationDelegate else { return nil } if let hashtag { return UIContextMenuConfiguration { HashtagTimelineViewController(for: hashtag, mastodonController: navigationDelegate.apiController) } actionProvider: { _ in UIMenu(children: self.actionsForHashtag(hashtag, source: .view(self))) } } else { return UIContextMenuConfiguration { let vc = SFSafariViewController(url: url) vc.preferredControlTintColor = Preferences.shared.accentColor.color return vc } actionProvider: { _ in UIMenu(children: self.actionsForURL(url, source: .view(self))) } } } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, highlightPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { var rect = label.textRect(forBounds: label.bounds, limitedToNumberOfLines: 0) // the rect should be vertically centered, but textRect doesn't seem to take the label's vertical alignment into account rect.origin.x = 0 rect.origin.y = (bounds.height - rect.height) / 2 let parameters = UIPreviewParameters(textLineRects: [rect as NSValue]) let preview = UITargetedPreview(view: label, parameters: parameters) currentTargetedPreview = preview return preview } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configuration: UIContextMenuConfiguration, dismissalPreviewForItemWithIdentifier identifier: NSCopying) -> UITargetedPreview? { return currentTargetedPreview } func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: navigationDelegate!) } }