Compare commits
4 Commits
c2d1fe45d8
...
8b78a5e7ad
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 8b78a5e7ad | |
Shadowfacts | 66c17006d1 | |
Shadowfacts | 8a911f238b | |
Shadowfacts | 77c44c323f |
|
@ -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>(name: "ImageCache request groups")
|
private var groups = MultiThreadDictionary<URL, RequestGroup>()
|
||||||
|
|
||||||
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.removeValueWithoutReturning(forKey: url)
|
_ = self.groups.removeValue(forKey: url)
|
||||||
}
|
}
|
||||||
groups[url] = group
|
groups[url] = group
|
||||||
return group
|
return group
|
||||||
|
|
|
@ -20,13 +20,15 @@ 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.parent = self.viewContext
|
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
||||||
|
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.parent = self.viewContext
|
context.persistentStoreCoordinator = self.persistentStoreCoordinator
|
||||||
|
context.automaticallyMergesChangesFromParent = true
|
||||||
return context
|
return context
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -51,6 +53,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,52 +7,79 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
class MultiThreadDictionary<Key: Hashable, Value> {
|
// once we target iOS 16, replace uses of this with OSAllocatedUnfairLock<[Key: Value]>
|
||||||
private let name: String
|
// to make the lock semantics more clear
|
||||||
private var dict = [Key: Value]()
|
@available(iOS, obsoleted: 16.0)
|
||||||
private let queue: DispatchQueue
|
class MultiThreadDictionary<Key: Hashable & Sendable, Value: Sendable> {
|
||||||
|
private let lock: any Lock<[Key: Value]>
|
||||||
|
|
||||||
init(name: String) {
|
init() {
|
||||||
self.name = name
|
if #available(iOS 16.0, *) {
|
||||||
self.queue = DispatchQueue(label: "MultiThreadDictionary (\(name)) Coordinator", attributes: .concurrent)
|
self.lock = OSAllocatedUnfairLock(initialState: [:])
|
||||||
|
} else {
|
||||||
|
self.lock = UnfairLock(initialState: [:])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
subscript(key: Key) -> Value? {
|
subscript(key: Key) -> Value? {
|
||||||
get {
|
get {
|
||||||
var result: Value? = nil
|
return lock.withLock { dict in
|
||||||
queue.sync {
|
dict[key]
|
||||||
result = dict[key]
|
|
||||||
}
|
}
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
set(value) {
|
set(value) {
|
||||||
queue.async(flags: .barrier) {
|
lock.withLock { dict in
|
||||||
self.dict[key] = value
|
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? {
|
||||||
var value: Value? = nil
|
return lock.withLock { dict in
|
||||||
queue.sync(flags: .barrier) {
|
dict.removeValue(forKey: key)
|
||||||
value = dict.removeValue(forKey: key)
|
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(key: Key) -> Bool {
|
func contains(key: Key) -> Bool {
|
||||||
var value: Bool!
|
return lock.withLock { dict in
|
||||||
queue.sync {
|
dict.keys.contains(key)
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
||||||
|
|
||||||
avatarImageView.image = nil
|
avatarImageView.image = nil
|
||||||
if let avatar = account.avatar {
|
if let avatar = account.avatar {
|
||||||
|
|
|
@ -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>(name: "AcccountDisplayNameLabel Emoji Images")
|
let emojiImages = MultiThreadDictionary<String, Image>()
|
||||||
|
|
||||||
let group = DispatchGroup()
|
let group = DispatchGroup()
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ extension BaseEmojiLabel {
|
||||||
// blergh
|
// blergh
|
||||||
precondition(Thread.isMainThread)
|
precondition(Thread.isMainThread)
|
||||||
|
|
||||||
|
emojiIdentifier = identifier
|
||||||
emojiRequests.forEach { $0.cancel() }
|
emojiRequests.forEach { $0.cancel() }
|
||||||
emojiRequests = []
|
emojiRequests = []
|
||||||
|
|
||||||
|
@ -38,10 +39,7 @@ extension BaseEmojiLabel {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// not using a MultiThreadDictionary so that cached images can be added immediately
|
let emojiImages = MultiThreadDictionary<String, UIImage>()
|
||||||
// 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()
|
||||||
|
@ -71,13 +69,9 @@ extension BaseEmojiLabel {
|
||||||
group.leave()
|
group.leave()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 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
|
emojiImages[emoji.shortcode] = transformedImage
|
||||||
group.leave()
|
group.leave()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if let request = request {
|
if let request = request {
|
||||||
emojiRequests.append(request)
|
emojiRequests.append(request)
|
||||||
}
|
}
|
||||||
|
@ -92,6 +86,11 @@ 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
|
||||||
|
// 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
|
// replaces the emojis starting from the end of the string as to not alter the indices of preceeding emojis
|
||||||
for match in matches.reversed() {
|
for match in matches.reversed() {
|
||||||
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
|
let shortcode = (attributedString.string as NSString).substring(with: match.range(at: 1))
|
||||||
|
@ -108,6 +107,7 @@ extension BaseEmojiLabel {
|
||||||
let attachmentStr = NSAttributedString(attachment: attachment)
|
let attachmentStr = NSAttributedString(attachment: attachment)
|
||||||
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
|
mutAttrString.replaceCharacters(in: match.range, with: attachmentStr)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return mutAttrString
|
return mutAttrString
|
||||||
}
|
}
|
||||||
|
|
|
@ -57,8 +57,8 @@ class ContentTextView: LinkTextView, BaseEmojiLabel {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Emojis
|
// MARK: - Emojis
|
||||||
func setEmojis(_ emojis: [Emoji]) {
|
func setEmojis(_ emojis: [Emoji], identifier: String?) {
|
||||||
replaceEmojis(in: attributedText!, emojis: emojis, identifier: emojiIdentifier) { attributedString, didReplaceEmojis in
|
replaceEmojis(in: attributedText!, emojis: emojis, identifier: identifier) { attributedString, didReplaceEmojis in
|
||||||
guard didReplaceEmojis else {
|
guard didReplaceEmojis else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,13 +21,9 @@ 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 }
|
||||||
|
|
||||||
self.emojiIdentifier = identifier
|
replaceEmojis(in: attributedText.string, emojis: emojis, identifier: identifier) { [weak self] (newAttributedText, didReplaceEmojis) in
|
||||||
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()
|
||||||
|
|
|
@ -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)
|
noteTextView.setEmojis(account.emojis, identifier: account.id)
|
||||||
|
|
||||||
// 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)
|
valueTextView.setEmojis(account.emojis, identifier: account.id)
|
||||||
valueTextView.textAlignment = .left
|
valueTextView.textAlignment = .left
|
||||||
valueTextView.awakeFromNib()
|
valueTextView.awakeFromNib()
|
||||||
valueTextView.navigationDelegate = delegate
|
valueTextView.navigationDelegate = delegate
|
||||||
|
|
|
@ -16,9 +16,8 @@ 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)
|
setEmojis(status.emojis, identifier: status.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func getMention(for url: URL, text: String) -> Mention? {
|
override func getMention(for url: URL, text: String) -> Mention? {
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<key>poll votes count</key>
|
<key>poll votes count</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringLocalizedFormatKey</key>
|
<key>NSStringLocalizedFormatKey</key>
|
||||||
<string>%2$#@votes@</string>
|
<string>%#@votes@</string>
|
||||||
<key>votes</key>
|
<key>votes</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSStringFormatSpecTypeKey</key>
|
<key>NSStringFormatSpecTypeKey</key>
|
||||||
|
|
Loading…
Reference in New Issue