Compare commits

..

No commits in common. "8b78a5e7adc2731622bbf5f03212945ca42acd89" and "c2d1fe45d811c1d40dd7d4d8e74821a1b1bb9722" have entirely different histories.

11 changed files with 70 additions and 96 deletions

View File

@ -24,7 +24,7 @@ class ImageCache {
private let cache: ImageDataCache
private let desiredPixelSize: CGSize?
private var groups = MultiThreadDictionary<URL, RequestGroup>()
private var groups = MultiThreadDictionary<URL, RequestGroup>(name: "ImageCache request groups")
private var backgroundQueue = DispatchQueue(label: "ImageCache completion queue", qos: .default)
@ -96,7 +96,7 @@ class ImageCache {
if let data = data {
try? self.cache.set(url.absoluteString, data: data, image: image)
}
_ = self.groups.removeValue(forKey: url)
self.groups.removeValueWithoutReturning(forKey: url)
}
groups[url] = group
return group

View File

@ -20,15 +20,13 @@ class MastodonCachePersistentStore: NSPersistentContainer {
private(set) lazy var backgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
context.parent = self.viewContext
return context
}()
private(set) lazy var prefetchBackgroundContext: NSManagedObjectContext = {
let context = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
context.persistentStoreCoordinator = self.persistentStoreCoordinator
context.automaticallyMergesChangesFromParent = true
context.parent = self.viewContext
return context
}()
@ -53,8 +51,6 @@ class MastodonCachePersistentStore: NSPersistentContainer {
}
}
viewContext.automaticallyMergesChangesFromParent = true
NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: viewContext)
}

View File

@ -7,79 +7,52 @@
//
import Foundation
import os
// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
// to make the lock semantics more clear
@available(iOS, obsoleted: 16.0)
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
private let lock: any Lock<[Key: Value]>
class MultiThreadDictionary<Key: Hashable, Value> {
private let name: String
private var dict = [Key: Value]()
private let queue: DispatchQueue
init() {
if #available(iOS 16.0, *) {
self.lock = OSAllocatedUnfairLock(initialState: [:])
} else {
self.lock = UnfairLock(initialState: [:])
}
init(name: String) {
self.name = name
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent)
}
subscript(key: Key) -> Value? {
get {
return lock.withLock { dict in
dict[key]
var result: Value? = nil
queue.sync {
result = dict[key]
}
return result
}
set(value) {
lock.withLock { dict in
dict[key] = value
queue.async(flags: .barrier) {
self.dict[key] = value
}
}
}
func removeValueWithoutReturning(forKey key: Key) {
queue.async(flags: .barrier) {
self.dict.removeValue(forKey: key)
}
}
/// If the result of this function is unused, it is preferable to use `removeValueWithoutReturning` as it executes asynchronously and doesn't block the calling thread.
func removeValue(forKey key: Key) -> Value? {
return lock.withLock { dict in
dict.removeValue(forKey: key)
var value: Value? = nil
queue.sync(flags: .barrier) {
value = dict.removeValue(forKey: key)
}
return value
}
func contains(key: Key) -> Bool {
return lock.withLock { dict in
dict.keys.contains(key)
var value: Bool!
queue.sync {
value = dict.keys.contains(key)
}
}
func withLock<R>(_ body: @Sendable (inout [Key: Value]) throws -> R) rethrows -> R where R: Sendable {
return try lock.withLock(body)
}
}
// TODO: replace this only with OSAllocatedUnfairLock
@available(iOS, obsoleted: 16.0)
fileprivate protocol Lock<State> {
associatedtype State
func withLock<R>(_ body: @Sendable (inout State) throws -> R) rethrows -> R where R: Sendable
}
@available(iOS 16.0, *)
extension OSAllocatedUnfairLock: Lock {
}
// from http://www.russbishop.net/the-law
fileprivate class UnfairLock<State>: Lock {
private var lock: UnsafeMutablePointer<os_unfair_lock>
private var state: State
init(initialState: State) {
self.state = initialState
self.lock = .allocate(capacity: 1)
self.lock.initialize(to: os_unfair_lock())
}
deinit {
self.lock.deallocate()
}
func withLock<R>(_ body: (inout State) throws -> R) rethrows -> R where R: Sendable {
os_unfair_lock_lock(lock)
defer { os_unfair_lock_unlock(lock) }
return try body(&state)
return value
}
}

View File

@ -52,7 +52,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: account)
noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis, identifier: account.id)
noteTextView.setEmojis(account.emojis)
avatarImageView.image = nil
if let avatar = account.avatar {

View File

@ -36,7 +36,7 @@ struct AccountDisplayNameLabel<Account: AccountProtocol>: View {
let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>()
let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images")
let group = DispatchGroup()

View File

@ -24,7 +24,6 @@ extension BaseEmojiLabel {
// blergh
precondition(Thread.isMainThread)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
@ -39,7 +38,10 @@ extension BaseEmojiLabel {
return
}
let emojiImages = MultiThreadDictionary<String, UIImage>()
// 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()
@ -69,8 +71,12 @@ extension BaseEmojiLabel {
group.leave()
return
}
emojiImages[emoji.shortcode] = transformedImage
group.leave()
// 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)
@ -86,27 +92,21 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup
// 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
emojiImages.withLock { emojiImages in
// 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
// 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)
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

View File

@ -57,8 +57,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
}
// MARK: - Emojis
func setEmojis(_ emojis: [Emoji], identifier: String?) {
replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
func setEmojis(_ emojis: [Emoji]) {
replaceEmojis(in: attributedText!, emojis: emojis, identifier: emojiIdentifier) { attributedString, didReplaceEmojis in
guard didReplaceEmojis else {
return
}

View File

@ -21,9 +21,13 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return }
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
self.emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() }
emojiRequests = []
hasEmojis = true
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, _) in
guard let self = self, self.emojiIdentifier == identifier else { return }
self.hasEmojis = didReplaceEmojis
self.attributedText = newAttributedText
self.setNeedsLayout()
self.setNeedsDisplay()

View File

@ -110,7 +110,7 @@ class ProfileHeaderView: UIView {
noteTextView.navigationDelegate = delegate
noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis, identifier: account.id)
noteTextView.setEmojis(account.emojis)
// don't show relationship label for the user's own account
if accountID != mastodonController.account?.id {
@ -148,7 +148,7 @@ class ProfileHeaderView: UIView {
valueTextView.isSelectable = false
valueTextView.font = .systemFont(ofSize: 17)
valueTextView.setTextFromHtml(field.value)
valueTextView.setEmojis(account.emojis, identifier: account.id)
valueTextView.setEmojis(account.emojis)
valueTextView.textAlignment = .left
valueTextView.awakeFromNib()
valueTextView.navigationDelegate = delegate

View File

@ -16,8 +16,9 @@ class StatusContentTextView: ContentTextView {
func setTextFrom(status: StatusMO) {
statusID = status.id
emojiIdentifier = status.id
setTextFromHtml(status.content)
setEmojis(status.emojis, identifier: status.id)
setEmojis(status.emojis)
}
override func getMention(for url: URL, text: String) -> Mention? {

View File

@ -5,7 +5,7 @@
<key>poll votes count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@votes@</string>
<string>%2$#@votes@</string>
<key>votes</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>