Tusker/Tusker/Views/BaseEmojiLabel.swift

140 lines
6.4 KiB
Swift

//
// BaseEmojiLabel.swift
// Tusker
//
// Created by Shadowfacts on 10/18/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import WebURLFoundationExtras
private let emojiRegex = try! NSRegularExpression(pattern: ":(\\w+):", options: [])
protocol BaseEmojiLabel: AnyObject {
var emojiIdentifier: String? { get set }
var emojiRequests: [ImageCache.Request] { get set }
var emojiFont: UIFont { get }
var emojiTextColor: UIColor { get }
}
extension BaseEmojiLabel {
func replaceEmojis(in attributedString: NSAttributedString, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
// blergh
precondition(Thread.isMainThread)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
guard !emojis.isEmpty else {
setAttributedString(attributedString, false)
return
}
let matches = emojiRegex.matches(in: attributedString.string, options: [], range: NSRange(location: 0, length: attributedString.length))
guard !matches.isEmpty else {
setAttributedString(attributedString, false)
return
}
let emojiImages = MultiThreadDictionary<String, UIImage>()
var foundEmojis = false
let group = DispatchGroup()
for emoji in emojis {
// only make requests for emojis that are present in the text to avoid making unnecessary network requests
guard matches.contains(where: { (match) -> Bool in
let matchShortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
return emoji.shortcode == matchShortcode
}) else {
continue
}
foundEmojis = true
if let image = ImageCache.emojis.get(URL(emoji.url)!)?.image {
// if the image is cached, add it immediately
emojiImages[emoji.shortcode] = image
} else {
// otherwise, perform the network request
group.enter()
// todo: ImageCache.emojis.get here will re-check the memory and disk caches, there should be another method to force-refetch
let request = ImageCache.emojis.get(URL(emoji.url)!) { (_, image) in
guard let image = image,
let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: image) else {
group.leave()
return
}
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
if let request = request {
emojiRequests.append(request)
}
}
}
guard foundEmojis else {
setAttributedString(attributedString, false)
return
}
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
// OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results)
// even though the closures is invoked on the same thread that withLock is called, so it's unclear why it needs to be @Sendable (FB11494878)
// so, just ignore the warnings
emojiImages.withLock { emojiImages in
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
for match in matches.reversed() {
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
let attachment: NSTextAttachment
if let emojiImage = emojiImages[shortcode] {
attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
} else if usePlaceholders {
attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
continue
}
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
}
}
return mutAttrString
}
if emojiRequests.isEmpty {
// waiting on the group when there are only cached emojis results in a bad first layout,
// because it would still wait until the next runloop iteration
// but, since there were no requests, we can generate the correct string immediately
let replacedString = buildStringWithEmojisReplaced(usePlaceholders: false)
setAttributedString(replacedString, true)
} else {
// if we did find emojis and there are pending network requests,
// set an attributed strings with placeholders for non-loaded emojis to avoid layout shift
setAttributedString(buildStringWithEmojisReplaced(usePlaceholders: true), true)
group.notify(queue: .main) { [weak self] in
// if e.g. the account changes before all emojis are loaded, don't bother trying to set them
guard let self = self, self.emojiIdentifier == identifier else { return }
let replacedString = buildStringWithEmojisReplaced(usePlaceholders: false)
setAttributedString(replacedString, true)
}
}
}
func replaceEmojis(in string: String, emojis: [Emoji], identifier: String?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) {
replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, setAttributedString: setAttributedString)
}
}