// // 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() 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) // 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 let emojiAttachments = emojiImages.withLock { $0.mapValues { image in NSTextAttachment(emojiImage: image, in: self.emojiFont, with: self.emojiTextColor) } } let placeholder = usePlaceholders ? NSTextAttachment(emojiPlaceholderIn: self.emojiFont) : nil // 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 emoji = emojiAttachments[shortcode] { attachment = emoji } else if usePlaceholders { attachment = placeholder! } 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) } }