Fix link detection

This commit is contained in:
Shadowfacts 2018-08-26 14:49:22 -04:00
parent f28e73442b
commit 62bc57e169
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
4 changed files with 376 additions and 85 deletions

View File

@ -10,6 +10,7 @@
04DACE8A212CA6B7009840C4 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE89212CA6B7009840C4 /* Timeline.swift */; }; 04DACE8A212CA6B7009840C4 /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE89212CA6B7009840C4 /* Timeline.swift */; };
04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; };
04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */; }; 04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */; };
D64A0CD32132153900640E3B /* StatusContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64A0CD22132153900640E3B /* StatusContentLabel.swift */; };
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; }; D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AAC2128D88B005A6F37 /* LocalData.swift */; };
D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; }; D64D0AAF2128D954005A6F37 /* Onboarding.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */; };
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; }; D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
@ -65,6 +66,7 @@
04DACE89212CA6B7009840C4 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; }; 04DACE89212CA6B7009840C4 /* Timeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Timeline.swift; sourceTree = "<group>"; };
04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; }; 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = "<group>"; };
04DACE8D212CC7CC009840C4 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = "<group>"; }; 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = "<group>"; };
D64A0CD22132153900640E3B /* StatusContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentLabel.swift; sourceTree = "<group>"; };
D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; }; D64D0AAC2128D88B005A6F37 /* LocalData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalData.swift; sourceTree = "<group>"; };
D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = "<group>"; }; D64D0AAE2128D954005A6F37 /* Onboarding.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Onboarding.storyboard; sourceTree = "<group>"; };
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; }; D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
@ -119,6 +121,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */, D6BED173212667E900F02DA0 /* StatusTableViewCell.swift */,
D64A0CD22132153900640E3B /* StatusContentLabel.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -351,6 +354,7 @@
D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */, D64D0AAD2128D88B005A6F37 /* LocalData.swift in Sources */,
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */, D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */,
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */, D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */,
D64A0CD32132153900640E3B /* StatusContentLabel.swift in Sources */,
D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */, D6D4DDD0212518A000E1C4BB /* AppDelegate.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;

View File

@ -24,7 +24,7 @@
<rect key="frame" x="0.0" y="0.0" width="375" height="71.5"/> <rect key="frame" x="0.0" y="0.0" width="375" height="71.5"/>
<autoresizingMask key="autoresizingMask"/> <autoresizingMask key="autoresizingMask"/>
<subviews> <subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZFA-wR-9b4"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="252" verticalHuggingPriority="251" horizontalCompressionResistancePriority="751" verticalCompressionResistancePriority="751" text="Display name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ZFA-wR-9b4">
<rect key="frame" x="76" y="11" width="103" height="21"/> <rect key="frame" x="76" y="11" width="103" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/> <nil key="textColor"/>
@ -36,7 +36,7 @@
<color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/> <color key="textColor" white="0.33333333333333331" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<nil key="highlightedColor"/> <nil key="highlightedColor"/>
</label> </label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xX6-Gq-MFH"> <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" text="Content" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xX6-Gq-MFH" customClass="StatusContentLabel" customModule="Tusker" customModuleProvider="target">
<rect key="frame" x="76" y="40" width="283" height="21"/> <rect key="frame" x="76" y="40" width="283" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/> <fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/> <nil key="textColor"/>

View File

@ -0,0 +1,286 @@
//
// StatusContentLabel.swift
// Tusker
//
// Created by Shadowfacts on 8/25/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import SwiftSoup
class StatusContentLabel: UILabel {
override var text: String? {
didSet {
parseHTML()
}
}
override var attributedText: NSAttributedString? {
didSet {
updateTextStorage()
}
}
override var numberOfLines: Int {
didSet {
textContainer.maximumNumberOfLines = numberOfLines
}
}
override var lineBreakMode: NSLineBreakMode {
didSet {
textContainer.lineBreakMode = lineBreakMode
}
}
public var lineSpacing: CGFloat = 0
public var minimumLineHeight: CGFloat = 0
private var _customizing = true
private lazy var textStorage = NSTextStorage()
private lazy var layoutManager = NSLayoutManager()
private lazy var textContainer = NSTextContainer()
private var heightCorrection: CGFloat = 0
private var selectedElement: (range: NSRange, url: URL)?
private var links: [NSRange: URL] = [:]
override init(frame: CGRect) {
super.init(frame: frame)
_customizing = false
setupLabel()
}
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
_customizing = false
setupLabel()
}
override func awakeFromNib() {
super.awakeFromNib()
updateTextStorage()
}
override func drawText(in rect: CGRect) {
let range = NSRange(location: 0, length: textStorage.length)
textContainer.size = rect.size
let newOrigin = textOrigin(in: rect)
layoutManager.drawBackground(forGlyphRange: range, at: newOrigin)
layoutManager.drawGlyphs(forGlyphRange: range, at: newOrigin)
}
override var intrinsicContentSize: CGSize {
let superSize = super.intrinsicContentSize
textContainer.size = CGSize(width: superSize.width, height: .greatestFiniteMagnitude)
let size = layoutManager.usedRect(for: textContainer)
return CGSize(width: ceil(size.width), height: ceil(size.height))
}
private func element(at location: CGPoint) -> (range: NSRange, url: URL)? {
guard textStorage.length > 0 else { return nil }
var correctLocation = location
correctLocation.y -= heightCorrection
let boundingRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: 0, length: textStorage.length), in: textContainer)
guard boundingRect.contains(correctLocation) else {
return nil
}
let index = layoutManager.glyphIndex(for: correctLocation, in: textContainer)
for (range, url) in links {
if index >= range.location && index <= range.location + range.length {
return (range, url)
}
}
return nil
}
private func updateAttributesWhenSelected(_ isSelected: Bool) {
guard let selectedElement = selectedElement else { return }
var attributes = textStorage.attributes(at: 0, effectiveRange: nil)
attributes[.foregroundColor] = isSelected ? nil : UIColor.blue
textStorage.addAttributes(attributes, range: selectedElement.range)
setNeedsDisplay()
}
private func onTouch(_ touch: UITouch) -> Bool {
let location = touch.location(in: self)
var avoidSuperCall = false
switch touch.phase {
case .began, .moved:
if let element = element(at: location) {
if element.range.location != selectedElement?.range.location || element.range.length != selectedElement?.range.length {
updateAttributesWhenSelected(false)
selectedElement = element
updateAttributesWhenSelected(true)
}
avoidSuperCall = true
} else {
updateAttributesWhenSelected(false)
selectedElement = nil
}
case .ended:
guard let selectedElement = selectedElement else { return avoidSuperCall }
print("tapped \(selectedElement)")
let when = DispatchTime.now() + Double(Int64(0.25 * Double(NSEC_PER_SEC))) / Double(NSEC_PER_SEC)
DispatchQueue.main.asyncAfter(deadline: when) {
self.updateAttributesWhenSelected(false)
self.selectedElement = nil
}
case .cancelled:
updateAttributesWhenSelected(false)
selectedElement = nil
case .stationary:
break
}
return avoidSuperCall
}
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesBegan(touches, with: event)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesMoved(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesCancelled(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let touch = touches.first else { return }
if onTouch(touch) { return }
super.touchesEnded(touches, with: event)
}
private func setupLabel() {
textStorage.addLayoutManager(layoutManager)
layoutManager.addTextContainer(textContainer)
textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = lineBreakMode
textContainer.maximumNumberOfLines = numberOfLines
isUserInteractionEnabled = true
}
private func parseHTML() {
if _customizing { return }
guard let text = text else { return }
let doc = try! SwiftSoup.parse(text)
let body = doc.body()!
let (attributedText, links) = attributedTextForHTMLNode(body)
self.links = links
let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
mutAttrString.addAttribute(.font, value: font, range: NSRange(location: 0, length: mutAttrString.length))
self.attributedText = mutAttrString
}
private func attributedTextForHTMLNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) {
switch node {
case let node as TextNode:
return (NSAttributedString(string: node.text()), [:])
case let node as Element:
var links = [NSRange: URL]()
let attributed = NSMutableAttributedString()
node.getChildNodes().forEach { child in
let (text, childLinks) = attributedTextForHTMLNode(child)
childLinks.forEach { range, url in
let newRange = NSRange(location: range.location + attributed.length, length: range.length)
links[newRange] = url
}
attributed.append(text)
}
switch node.tagName() {
case "br":
attributed.append(NSAttributedString(string: "\n"))
case "a":
if let link = try? node.attr("href"),
let url = URL(string: link) {
let linkRange = NSRange(location: 0, length: attributed.length)
let linkAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.blue
]
attributed.setAttributes(linkAttributes, range: linkRange)
links[linkRange] = url
}
default:
break
}
return (attributed, links)
default:
fatalError("Unexpected node type: \(type(of: node))")
}
}
private func updateTextStorage() {
guard !_customizing else { return }
guard let attributedText = attributedText,
attributedText.length > 0 else {
links = [:]
textStorage.setAttributedString(NSAttributedString())
setNeedsDisplay()
return
}
// is this necessary?
let mutAttrString = addLineBreak(attributedText)
// let mutAttrString = NSMutableAttributedString(attributedString: attributedText)
textStorage.setAttributedString(mutAttrString)
_customizing = true
text = attributedText.string
_customizing = false
setNeedsDisplay()
}
private func addLineBreak(_ attrString: NSAttributedString) -> NSMutableAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attrString)
var range = NSRange(location: 0, length: 0)
var attributes = mutAttrString.attributes(at: 0, effectiveRange: &range)
let paragraphStyle = attributes[.paragraphStyle] as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byWordWrapping
paragraphStyle.alignment = textAlignment
paragraphStyle.lineSpacing = lineSpacing
paragraphStyle.minimumLineHeight = minimumLineHeight > 0 ? minimumLineHeight : self.font.pointSize * 1.14
attributes[.paragraphStyle] = paragraphStyle
mutAttrString.setAttributes(attributes, range: range)
return mutAttrString
}
private func textOrigin(in rect: CGRect) -> CGPoint {
let usedRect = layoutManager.usedRect(for: textContainer)
heightCorrection = (rect.height - usedRect.height) / 2
let glyphOriginY = heightCorrection > 0 ? rect.origin.y + heightCorrection : rect.origin.y
return CGPoint(x: rect.origin.x, y: glyphOriginY)
}
}

View File

@ -14,7 +14,7 @@ class StatusTableViewCell: UITableViewCell {
@IBOutlet weak var displayNameLabel: UILabel! @IBOutlet weak var displayNameLabel: UILabel!
@IBOutlet weak var usernameLabel: UILabel! @IBOutlet weak var usernameLabel: UILabel!
@IBOutlet weak var contentLabel: UILabel! @IBOutlet weak var contentLabel: StatusContentLabel!
@IBOutlet weak var avatarImageView: UIImageView! @IBOutlet weak var avatarImageView: UIImageView!
var status: Status! var status: Status!
@ -49,92 +49,93 @@ class StatusTableViewCell: UITableViewCell {
} }
} }
contentLabel.text = status.content
let doc = try! SwiftSoup.parse(status.content) // let doc = try! SwiftSoup.parse(status.content)
let body = doc.body()! // let body = doc.body()!
// print("---") //// print("---")
// print(status.content) //// print(status.content)
// print("---") //// print("---")
//
let (text, links) = attributedTextForNode(body) // let (text, links) = attributedTextForNode(body)
self.links = links // self.links = links
contentLabel.attributedText = text // contentLabel.attributedText = text
contentLabel.isUserInteractionEnabled = true // contentLabel.isUserInteractionEnabled = true
contentLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTapOnContentLabel(_:)))) // contentLabel.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleTapOnContentLabel(_:))))
//
// https://stackoverflow.com/a/28519273/4731558 // // https://stackoverflow.com/a/28519273/4731558
layoutManager = NSLayoutManager() // layoutManager = NSLayoutManager()
textContainer = NSTextContainer(size: .zero) // textContainer = NSTextContainer(size: .zero)
textStorage = NSTextStorage(attributedString: text) // textStorage = NSTextStorage(attributedString: text)
//
layoutManager.addTextContainer(textContainer) // layoutManager.addTextContainer(textContainer)
textStorage.addLayoutManager(layoutManager) // textStorage.addLayoutManager(layoutManager)
//
textContainer.lineFragmentPadding = 0 // textContainer.lineFragmentPadding = 0
textContainer.lineBreakMode = contentLabel.lineBreakMode // textContainer.lineBreakMode = contentLabel.lineBreakMode
textContainer.maximumNumberOfLines = contentLabel.numberOfLines // textContainer.maximumNumberOfLines = contentLabel.numberOfLines
} }
func attributedTextForNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) { // func attributedTextForNode(_ node: Node) -> (NSAttributedString, [NSRange: URL]) {
switch node { // switch node {
case let node as TextNode: // case let node as TextNode:
return (NSAttributedString(string: node.text()), [:]) // return (NSAttributedString(string: node.text()), [:])
case let node as Element: // case let node as Element:
var links = [NSRange: URL]() // var links = [NSRange: URL]()
let attributed = NSMutableAttributedString() // let attributed = NSMutableAttributedString()
node.getChildNodes().forEach { child in // node.getChildNodes().forEach { child in
let (text, childLinks) = attributedTextForNode(child) // let (text, childLinks) = attributedTextForNode(child)
childLinks.forEach { range, url in // childLinks.forEach { range, url in
let newRange = NSRange(location: range.location + attributed.length, length: range.length) // let newRange = NSRange(location: range.location + attributed.length, length: range.length)
links[newRange] = url // links[newRange] = url
} // }
attributed.append(text) // attributed.append(text)
} // }
//
switch node.tagName() { // switch node.tagName() {
case "br": // case "br":
attributed.append(NSAttributedString(string: "\n")) // attributed.append(NSAttributedString(string: "\n"))
case "a": // case "a":
if let link = try? node.attr("href"), // if let link = try? node.attr("href"),
let url = URL(string: link) { // let url = URL(string: link) {
let linkRange = NSRange(location: 0, length: attributed.length) // let linkRange = NSRange(location: 0, length: attributed.length)
let linkAttributes: [NSAttributedString.Key: Any] = [ // let linkAttributes: [NSAttributedString.Key: Any] = [
.foregroundColor: UIColor.blue // .foregroundColor: UIColor.blue
] // ]
attributed.setAttributes(linkAttributes, range: linkRange) // attributed.setAttributes(linkAttributes, range: linkRange)
//
links[linkRange] = url // links[linkRange] = url
} // }
default: // default:
break // break
} // }
//
return (attributed, links) // return (attributed, links)
default: // default:
fatalError("Unexpected node type: \(type(of: node))") // fatalError("Unexpected node type: \(type(of: node))")
} // }
} // }
@objc func handleTapOnContentLabel(_ tapGesture: UITapGestureRecognizer) { // @objc func handleTapOnContentLabel(_ tapGesture: UITapGestureRecognizer) {
guard let view = tapGesture.view else { fatalError() } // guard let view = tapGesture.view else { fatalError() }
//
let locationOfTouchInLabel = tapGesture.location(in: view) // let locationOfTouchInLabel = tapGesture.location(in: view)
let labelSize = view.bounds.size // let labelSize = view.bounds.size
let textBoundingBox = layoutManager.usedRect(for: textContainer) // let textBoundingBox = layoutManager.usedRect(for: textContainer)
let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x, // let textContainerOffset = CGPoint(x: (labelSize.width - textBoundingBox.size.width) * 0.5 - textBoundingBox.origin.x,
y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y) // y: (labelSize.height - textBoundingBox.size.height) * 0.5 - textBoundingBox.origin.y)
let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x, // let locationOfTouchInTextContainer = CGPoint(x: locationOfTouchInLabel.x - textContainerOffset.x,
y: locationOfTouchInLabel.y - textContainerOffset.y) // y: locationOfTouchInLabel.y - textContainerOffset.y)
let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil) // let indexOfCharacter = layoutManager.characterIndex(for: locationOfTouchInTextContainer, in: textContainer, fractionOfDistanceBetweenInsertionPoints: nil)
//
let link = links.first { (range, _) -> Bool in // let link = links.first { (range, _) -> Bool in
range.contains(indexOfCharacter) // range.contains(indexOfCharacter)
} // }
//
if let (_, url) = link { // if let (_, url) = link {
print("Open URL: \(url)") // print("Open URL: \(url)")
} // }
} // }
override func prepareForReuse() { override func prepareForReuse() {
if let url = avatarURL { if let url = avatarURL {