// // ContentLabel.swift // Tusker // // Created by Shadowfacts on 10/1/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import SafariServices import Pachyderm import SwiftSoup class ContentLabel: LinkLabel { private static let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: []) var navigationDelegate: TuskerNavigationDelegate? // MARK: - Emojis func setEmojis(_ emojis: [Emoji]) { guard !emojis.isEmpty else { return } let group = DispatchGroup() let mutAttrString = NSMutableAttributedString(attributedString: self.attributedText!) let string = mutAttrString.string let matches = ContentLabel.emojiRegex.matches(in: string, options: [], range: NSRange(location: 0, length: mutAttrString.length)) for match in matches.reversed() { let shortcode = (string as NSString).substring(with: match.range(at: 1)) guard let emoji = emojis.first(where: { $0.shortcode == shortcode }) else { continue } group.enter() ImageCache.emojis.get(emoji.url) { (data) in guard let data = data, let image = UIImage(data: data) else { group.leave() return } DispatchQueue.main.async { let attachment = self.createEmojiTextAttachment(image: image, index: match.range.location) mutAttrString.replaceCharacters(in: match.range, with: NSAttributedString(attachment: attachment)) group.leave() } } } group.notify(queue: .main) { self.attributedText = mutAttrString self.setNeedsLayout() self.setNeedsDisplay() } } // Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m func createEmojiTextAttachment(image: UIImage, index: Int) -> NSTextAttachment { let font = self.font! let adjustedCapHeight = font.capHeight - 1 var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight) let defaultScale: CGFloat = 1.4 imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * defaultScale, height: imageSizeMatchingFontSize.height * defaultScale) let textColor = self.textColor! UIGraphicsBeginImageContextWithOptions(imageSizeMatchingFontSize, false, 0.0) textColor.set() image.draw(in: CGRect(origin: .zero, size: imageSizeMatchingFontSize)) let attachmentImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() let attachment = NSTextAttachment() attachment.image = attachmentImage return attachment } // MARK: - HTML Parsing func setTextFromHtml(_ html: String) { let doc = try! SwiftSoup.parse(html) let body = doc.body()! let (attributedText, links) = attributedTextForHTMLNode(body) let mutAttrString = NSMutableAttributedString(attributedString: attributedText) // only trailing whitespace can be trimmed here // when posting an attachment without any text, pleromafe includes U+200B ZERO WIDTH SPACE at the beginning // this would get trimmed and cause range out of bounds crashes mutAttrString.trimTrailingCharactersInSet(.whitespacesAndNewlines) self.links = [] let linkAttributes: [NSAttributedString.Key: Any] = [ .foregroundColor: UIColor.systemBlue, ] for (range, url) in links { mutAttrString.addAttributes(linkAttributes, range: range) self.links.append(Link(range: range, url: url)) } 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() for child in node.getChildNodes() { let (text, childLinks) = attributedTextForHTMLNode(child) for (range, url) in childLinks { 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) { links[attributed.fullRange] = url } case "p": attributed.append(NSAttributedString(string: "\n\n")) case "em", "i": let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font attributed.addAttribute(.font, value: currentFont.addingTraits(.traitItalic), range: attributed.fullRange) case "strong", "b": let currentFont: UIFont = attributed.attribute(.font, at: 0, effectiveRange: nil) as? UIFont ?? self.font attributed.addAttribute(.font, value: currentFont.addingTraits(.traitBold), range: attributed.fullRange) case "del": attributed.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: attributed.fullRange) case "code": attributed.addAttribute(.font, value: UIFont(name: "Menlo", size: font!.pointSize)!, range: attributed.fullRange) case "pre": attributed.addAttribute(.font, value: UIFont(name: "Menlo", size: font!.pointSize)!, range: attributed.fullRange) attributed.append(NSAttributedString(string: "\n\n")) case "ol", "ul": attributed.trimLeadingCharactersInSet(.whitespacesAndNewlines) attributed.append(NSAttributedString(string: "\n")) break case "li": let parentEl = node.parent()! let parentTag = parentEl.tagName() let bullet: NSAttributedString if parentTag == "ol" { let index = (try? node.elementSiblingIndex()) ?? 0 // we use the monospace digit font so that the periods of all the list items line up bullet = NSAttributedString(string: "\(index + 1).\t", attributes: [.font: UIFont.monospacedDigitSystemFont(ofSize: font!.pointSize, weight: .regular)]) } else if parentTag == "ul" { bullet = NSAttributedString(string: "\u{2022}\t") } else { bullet = NSAttributedString(string: "") } // inserting bullets at the beginning of the string shifts all the links down, so we adjust the link ranges for (range, url) in links { let newRange = NSRange(location: range.location + bullet.length - 1, length: range.length) links[newRange] = url links.removeValue(forKey: range) } attributed.insert(bullet, at: 0) attributed.append(NSAttributedString(string: "\n")) default: break } return (attributed, links) default: fatalError("Unexpected node type: \(type(of: node))") } } func getViewController(forLink url: URL, inRange range: NSRange) -> UIViewController { let text = (self.text! as NSString).substring(with: range) if let mention = getMention(for: url, text: text) { return ProfileTableViewController(accountID: mention.id) } else if let tag = getHashtag(for: url, text: text) { return TimelineTableViewController(for: .tag(hashtag: tag.name)) } else { return SFSafariViewController(url: url) } } func getViewController(forLinkAt point: CGPoint) -> UIViewController? { guard let link = getLink(atPoint: point) else { return nil } return getViewController(forLink: link.url, inRange: link.range) } // MARK: - Interaction override func linkTapped(_ link: LinkLabel.Link) { let text = (self.text! as NSString).substring(with: link.range) if let mention = getMention(for: link.url, text: text) { navigationDelegate?.selected(mention: mention) } else if let tag = getHashtag(for: link.url, text: text) { navigationDelegate?.selected(tag: tag) } else { navigationDelegate?.selected(url: link.url) } } override func linkLongPressed(_ link: LinkLabel.Link) { navigationDelegate?.showMoreOptions(forURL: link.url) } // MARK: - Navigation func getMention(for url: URL, text: String) -> Mention? { return nil } func getHashtag(for url: URL, text: String) -> Hashtag? { if text.starts(with: "#") { let tag = String(text.dropFirst()) return Hashtag(name: tag, url: url) } else { return nil } } }