// // 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: []) @MainActor protocol BaseEmojiLabel: AnyObject { var emojiIdentifier: AnyHashable? { 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: ID?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { // blergh precondition(Thread.isMainThread) let identifier = AnyHashable(identifier) 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 } // Based on https://github.com/ReticentJohn/Amaroq/blob/7c5b7088eb9fd1611dcb0f47d43bf8df093e142c/DireFloof/InlineImageHelpers.m let adjustedCapHeight = emojiFont.capHeight - 1 #if os(visionOS) let screenScale: CGFloat = 2 #else let screenScale = UIScreen.main.scale #endif @Sendable func emojiImageSize(_ image: UIImage) -> CGSize { var imageSizeMatchingFontSize = CGSize(width: image.size.width * (adjustedCapHeight / image.size.height), height: adjustedCapHeight) let scale = 1.4 * screenScale imageSizeMatchingFontSize = CGSize(width: imageSizeMatchingFontSize.width * scale, height: imageSizeMatchingFontSize.height * scale) return imageSizeMatchingFontSize } 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. // we generate the thumbnail on the main thread, because it's usually fast enough // and the delay caused by doing it asynchronously looks works. // todo: consider caching these somewhere? the cache needs to take into account both the url and the font size, which may vary across labels, so can't just use ImageCache if let thumbnail = image.preparingThumbnail(of: emojiImageSize(image)), let cgImage = thumbnail.cgImage { // the thumbnail API takes a pixel size and returns an image with scale 1, but we want the actual screen scale, so convert // see FB12187798 emojiImages[emoji.shortcode] = UIImage(cgImage: cgImage, scale: screenScale, orientation: .up) } } else { // otherwise, perform the network request group.enter() let request = ImageCache.emojis.getFromSource(URL(emoji.url)!) { (_, image) in guard let image else { group.leave() return } image.prepareThumbnail(of: emojiImageSize(image)) { thumbnail in guard let thumbnail = thumbnail?.cgImage, case let rescaled = UIImage(cgImage: thumbnail, scale: screenScale, orientation: .up), let transformedImage = ImageGrayscalifier.convertIfNecessary(url: URL(emoji.url)!, image: rescaled) 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 { return $0.mapValues { image in NSTextAttachment(image: image) } } 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: ID?, setAttributedString: @escaping (_ attributedString: NSAttributedString, _ didReplaceEmojis: Bool) -> Void) { replaceEmojis(in: NSAttributedString(string: string), emojis: emojis, identifier: identifier, setAttributedString: setAttributedString) } }