diff --git a/Tusker/Views/Profile Header/ProfileFieldValueView.swift b/Tusker/Views/Profile Header/ProfileFieldValueView.swift index cfac804b..beeba329 100644 --- a/Tusker/Views/Profile Header/ProfileFieldValueView.swift +++ b/Tusker/Views/Profile Header/ProfileFieldValueView.swift @@ -12,11 +12,7 @@ import SwiftUI import SafariServices class ProfileFieldValueView: UIView { - weak var navigationDelegate: TuskerNavigationDelegate? { - didSet { - textView.navigationDelegate = navigationDelegate - } - } + weak var navigationDelegate: TuskerNavigationDelegate? private static let converter = HTMLConverter( font: .preferredFont(forTextStyle: .body), @@ -28,8 +24,9 @@ class ProfileFieldValueView: UIView { private let account: AccountMO private let field: Account.Field + private var link: (String, URL)? - private let textView = ContentTextView() + private let label = EmojiLabel() private var iconView: UIView? private var currentTargetedPreview: UITargetedPreview? @@ -42,28 +39,34 @@ class ProfileFieldValueView: UIView { let converted = NSMutableAttributedString(attributedString: ProfileFieldValueView.converter.convert(field.value)) - #if os(visionOS) - textView.linkTextAttributes = [ - .foregroundColor: UIColor.link - ] - #else - textView.linkTextAttributes = [ - .foregroundColor: UIColor.tintColor - ] - #endif - textView.backgroundColor = nil - textView.isScrollEnabled = false - textView.isSelectable = false - textView.isEditable = false - textView.font = .preferredFont(forTextStyle: .body) - updateTextContainerInset() - textView.adjustsFontForContentSizeCategory = true - textView.attributedText = converted - textView.setEmojis(account.emojis, identifier: account.id) - textView.isUserInteractionEnabled = true - textView.setContentCompressionResistancePriority(.required, for: .vertical) - textView.translatesAutoresizingMaskIntoConstraints = false - addSubview(textView) + 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 } + #if os(visionOS) + converted.addAttribute(.foregroundColor, value: UIColor.link, range: range) + #else + converted.addAttribute(.foregroundColor, value: UIColor.tintColor, range: range) + #endif + // 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 @@ -80,20 +83,20 @@ class ProfileFieldValueView: UIView { icon.isPointerInteractionEnabled = true icon.accessibilityLabel = "Verified link" addSubview(icon) - labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: icon.leadingAnchor) + labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: icon.leadingAnchor) NSLayoutConstraint.activate([ - icon.centerYAnchor.constraint(equalTo: textView.centerYAnchor), + icon.centerYAnchor.constraint(equalTo: label.centerYAnchor), icon.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor), ]) } else { - labelTrailingConstraint = textView.trailingAnchor.constraint(equalTo: trailingAnchor) + labelTrailingConstraint = label.trailingAnchor.constraint(equalTo: trailingAnchor) } NSLayoutConstraint.activate([ - textView.leadingAnchor.constraint(equalTo: leadingAnchor), + label.leadingAnchor.constraint(equalTo: leadingAnchor), labelTrailingConstraint, - textView.topAnchor.constraint(equalTo: topAnchor), - textView.bottomAnchor.constraint(equalTo: bottomAnchor), + label.topAnchor.constraint(equalTo: topAnchor), + label.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } @@ -102,36 +105,37 @@ class ProfileFieldValueView: UIView { } override func sizeThatFits(_ size: CGSize) -> CGSize { - var size = textView.sizeThatFits(size) + var size = label.sizeThatFits(size) if let iconView { size.width += iconView.sizeThatFits(UIView.layoutFittingCompressedSize).width } return size } - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory { - updateTextContainerInset() - } - } - - private func updateTextContainerInset() { - // blergh - switch traitCollection.preferredContentSizeCategory { - case .extraSmall: - textView.textContainerInset = UIEdgeInsets(top: 4, left: 0, bottom: 0, right: 0) - case .small: - textView.textContainerInset = UIEdgeInsets(top: 3, left: 0, bottom: 0, right: 0) - case .medium, .large: - textView.textContainerInset = UIEdgeInsets(top: 2, left: 0, bottom: 0, right: 0) - default: - textView.textContainerInset = .zero - } - } - func setTextAlignment(_ alignment: NSTextAlignment) { - textView.textAlignment = alignment + 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() { @@ -141,7 +145,7 @@ class ProfileFieldValueView: UIView { let view = ProfileFieldVerificationView( acct: account.acct, verifiedAt: field.verifiedAt!, - linkText: textView.text ?? "", + linkText: label.text ?? "", navigationDelegate: navigationDelegate ) let host = UIHostingController(rootView: view) @@ -165,3 +169,49 @@ class ProfileFieldValueView: UIView { navigationDelegate.present(toPresent, animated: true) } } + +extension ProfileFieldValueView: UIContextMenuInteractionDelegate, MenuActionProvider { + 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) + #if !os(visionOS) + vc.preferredControlTintColor = Preferences.shared.accentColor.color + #endif + 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!) + } +}