140 lines
6.4 KiB
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)
|
|
}
|
|
}
|