forked from shadowfacts/Tusker
Shadowfacts
c489d018bd
# Conflicts: # Tusker/Caching/ImageCache.swift # Tusker/Extensions/PKDrawing+Render.swift # Tusker/MultiThreadDictionary.swift # Tusker/Views/BaseEmojiLabel.swift
174 lines
8.2 KiB
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)
|
|
}
|
|
}
|