Tusker/Tusker/Views/ContentLabel.swift

230 lines
9.6 KiB
Swift

//
// 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
}
}
}