Tusker/Tusker/Views/BaseEmojiLabel.swift

174 lines
8.2 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: [])
@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<ID: Hashable>(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<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.
// 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<ID: Hashable>(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)
}
}