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

View File

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

View File

@ -7,79 +7,52 @@
// //
import Foundation import Foundation
import os
// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]> class MultiThreadDictionary<Key: Hashable, Value> {
// to make the lock semantics more clear private let name: String
@available(iOS, obsoleted: 16.0) private var dict = [Key: Value]()
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> { private let queue: DispatchQueue
private let lock: any Lock<[Key: Value]>
init() { init(name: String) {
if #available(iOS 16.0, *) { self.name = name
self.lock = OSAllocatedUnfairLock(initialState: [:]) self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent)
} else {
self.lock = UnfairLock(initialState: [:])
}
} }
subscript(key: Key) -> Value? { subscript(key: Key) -> Value? {
get { get {
return lock.withLock { dict in var result: Value? = nil
dict[key] queue.sync {
result = dict[key]
} }
return result
} }
set(value) { set(value) {
lock.withLock { dict in queue.async(flags: .barrier) {
dict[key] = value 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. /// 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? { func removeValue(forKey key: Key) -> Value? {
return lock.withLock { dict in var value: Value? = nil
dict.removeValue(forKey: key) queue.sync(flags: .barrier) {
value = dict.removeValue(forKey: key)
} }
return value
} }
func contains(key: Key) -> Bool { func contains(key: Key) -> Bool {
return lock.withLock { dict in var value: Bool!
dict.keys.contains(key) queue.sync {
value = dict.keys.contains(key)
} }
} return value
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)
} }
} }

View File

@ -52,7 +52,7 @@ class FeaturedProfileCollectionViewCell: UICollectionViewCell {
displayNameLabel.updateForAccountDisplayName(account: account) displayNameLabel.updateForAccountDisplayName(account: account)
noteTextView.setTextFromHtml(account.note) noteTextView.setTextFromHtml(account.note)
noteTextView.setEmojis(account.emojis, identifier: account.id) noteTextView.setEmojis(account.emojis)
avatarImageView.image = nil avatarImageView.image = nil
if let avatar = account.avatar { 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) let matches = emojiRegex.matches(in: account.displayName, options: [], range: fullRange)
guard !matches.isEmpty else { return } guard !matches.isEmpty else { return }
let emojiImages = MultiThreadDictionary<String, Image>() let emojiImages = MultiThreadDictionary<String, Image>(name: "AcccountDisplayNameLabel Emoji Images")
let group = DispatchGroup() let group = DispatchGroup()

View File

@ -24,7 +24,6 @@ extension BaseEmojiLabel {
// blergh // blergh
precondition(Thread.isMainThread) precondition(Thread.isMainThread)
emojiIdentifier = identifier
emojiRequests.forEach { $0.cancel() } emojiRequests.forEach { $0.cancel() }
emojiRequests = [] emojiRequests = []
@ -39,7 +38,10 @@ extension BaseEmojiLabel {
return 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 var foundEmojis = false
let group = DispatchGroup() let group = DispatchGroup()
@ -69,8 +71,12 @@ extension BaseEmojiLabel {
group.leave() group.leave()
return return
} }
emojiImages[emoji.shortcode] = transformedImage // sync back to the main thread to add the dictionary
group.leave() // todo: using the main thread for this isn't great
DispatchQueue.main.async {
emojiImages[emoji.shortcode] = transformedImage
group.leave()
}
} }
if let request = request { if let request = request {
emojiRequests.append(request) emojiRequests.append(request)
@ -86,27 +92,21 @@ extension BaseEmojiLabel {
func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString { func buildStringWithEmojisReplaced(usePlaceholders: Bool) -> NSAttributedString {
let mutAttrString = NSMutableAttributedString(attributedString: attributedString) let mutAttrString = NSMutableAttributedString(attributedString: attributedString)
// lock once for the entire loop, rather than lock/unlocking for each iteration to do the lookup // replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
// OSAllocatedUnfairLock.withLock expects a @Sendable closure, so this warns about captures of non-sendable types (attribute dstrings, text checking results) for match in matches.reversed() {
// 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) let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
// so, just ignore the warnings let attachment: NSTextAttachment
emojiImages.withLock { emojiImages in
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis if let emojiImage = emojiImages[shortcode] {
for match in matches.reversed() { attachment = NSTextAttachment(emojiImage: emojiImage, in: self.emojiFont, with: self.emojiTextColor)
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1)) } else if usePlaceholders {
let attachment: NSTextAttachment attachment = NSTextAttachment(emojiPlaceholderIn: self.emojiFont)
} else {
if let emojiImage = emojiImages[shortcode] { continue
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)
} }
let attachmentStr = NSAttributedString(attachment: attachment)
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
} }
return mutAttrString return mutAttrString

View File

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

View File

@ -21,9 +21,13 @@ class EmojiLabel: UILabel, BaseEmojiLabel {
func setEmojis(_ emojis: [Emoji], identifier: String) { func setEmojis(_ emojis: [Emoji], identifier: String) {
guard emojis.count > 0, let attributedText = attributedText else { return } 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 } guard let self = self, self.emojiIdentifier == identifier else { return }
self.hasEmojis = didReplaceEmojis
self.attributedText = newAttributedText self.attributedText = newAttributedText
self.setNeedsLayout() self.setNeedsLayout()
self.setNeedsDisplay() self.setNeedsDisplay()

View File

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

View File

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

View File

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