// // BaseEmojiLabel.swift // Tusker // // Created by Shadowfacts on 10/18/20. // Copyright © 2020 Shadowfacts. All rights reserved. // import UIKit import Pachyderm 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) 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 } // not using a MultiThreadDictionary so that cached images can be added immediately // without jumping through various queues so that we can use them immediately // in building either the final string or the string with placeholders var emojiImages: [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(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(emoji.url) { (_, image) in guard let image = image, let transformedImage = ImageGrayscalifier.convertIfNecessary(url: emoji.url, image: image) else { group.leave() return } // sync back to the main thread to add the dictionary // todo: using the main thread for this isn't great DispatchQueue.main.async { 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) // 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) } }