diff --git a/.gitmodules b/.gitmodules index 3d032561..d3a62aad 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "SwiftSoup"] path = SwiftSoup url = git://github.com/scinfu/SwiftSoup.git +[submodule "Cache"] + path = Cache + url = git@github.com:hyperoslo/Cache.git diff --git a/Cache b/Cache new file mode 160000 index 00000000..8c42c575 --- /dev/null +++ b/Cache @@ -0,0 +1 @@ +Subproject commit 8c42c575cf28b2ff0e780c9728721e9a8891c92e diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index b24f4575..70a8e710 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -12,8 +12,10 @@ 04496BD0216252E5001F1B23 /* TTTAttributedLabel.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 04496BC8216252E5001F1B23 /* TTTAttributedLabel.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 04496BD52162530A001F1B23 /* TTTAttributedLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = 04496BD42162530A001F1B23 /* TTTAttributedLabel.m */; }; 04496BD721625361001F1B23 /* ContentLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04496BD621625361001F1B23 /* ContentLabel.swift */; }; + 0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0461A38F2163CBAE00C0A807 /* Cache.framework */; }; + 0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0461A38F2163CBAE00C0A807 /* Cache.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 04DACE8C212CB14B009840C4 /* MainTabBarViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */; }; - 04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */; }; + 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04DACE8D212CC7CC009840C4 /* ImageCache.swift */; }; 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04ED00B021481ED800567C53 /* SteppedProgressView.swift */; }; D6028B9B2150811100F223B9 /* MastodonCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6028B9A2150811100F223B9 /* MastodonCache.swift */; }; D61099B42144B0CC00432DC2 /* Pachyderm.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D61099AB2144B0CC00432DC2 /* Pachyderm.framework */; }; @@ -178,6 +180,7 @@ 04496BD0216252E5001F1B23 /* TTTAttributedLabel.framework in Embed Frameworks */, D61099C12144B0CC00432DC2 /* Pachyderm.framework in Embed Frameworks */, D6BED170212663DA00F02DA0 /* SwiftSoup.framework in Embed Frameworks */, + 0461A3912163CBAE00C0A807 /* Cache.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -190,8 +193,9 @@ 04496BCB216252E5001F1B23 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 04496BD42162530A001F1B23 /* TTTAttributedLabel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = TTTAttributedLabel.m; sourceTree = ""; }; 04496BD621625361001F1B23 /* ContentLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentLabel.swift; sourceTree = ""; }; + 0461A38F2163CBAE00C0A807 /* Cache.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Cache.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 04DACE8B212CB14B009840C4 /* MainTabBarViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabBarViewController.swift; sourceTree = ""; }; - 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvatarCache.swift; sourceTree = ""; }; + 04DACE8D212CC7CC009840C4 /* ImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCache.swift; sourceTree = ""; }; 04ED00B021481ED800567C53 /* SteppedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SteppedProgressView.swift; sourceTree = ""; }; D6028B9A2150811100F223B9 /* MastodonCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonCache.swift; sourceTree = ""; }; D61099AB2144B0CC00432DC2 /* Pachyderm.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pachyderm.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -337,6 +341,7 @@ 04496BCF216252E5001F1B23 /* TTTAttributedLabel.framework in Frameworks */, D61099C02144B0CC00432DC2 /* Pachyderm.framework in Frameworks */, D65A37F321472F300087646E /* SwiftSoup.framework in Frameworks */, + 0461A3902163CBAE00C0A807 /* Cache.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -667,6 +672,7 @@ D6D4DDC3212518A000E1C4BB = { isa = PBXGroup; children = ( + 0461A38F2163CBAE00C0A807 /* Cache.framework */, D6BED16E212663DA00F02DA0 /* SwiftSoup.framework */, D61099AC2144B0CC00432DC2 /* Pachyderm */, D61099B92144B0CC00432DC2 /* PachydermTests */, @@ -697,7 +703,7 @@ children = ( D6D4DDCF212518A000E1C4BB /* AppDelegate.swift */, D64D0AAC2128D88B005A6F37 /* LocalData.swift */, - 04DACE8D212CC7CC009840C4 /* AvatarCache.swift */, + 04DACE8D212CC7CC009840C4 /* ImageCache.swift */, D6028B9A2150811100F223B9 /* MastodonCache.swift */, D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */, D6757A7A2157E00100721E32 /* XCallbackURL */, @@ -1065,7 +1071,7 @@ D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, - 04DACE8E212CC7CC009840C4 /* AvatarCache.swift in Sources */, + 04DACE8E212CC7CC009840C4 /* ImageCache.swift in Sources */, D6333B772138D94E00CE884A /* ComposeMediaView.swift in Sources */, 04ED00B121481ED800567C53 /* SteppedProgressView.swift in Sources */, D663626421360D2300C9CBA2 /* AvatarStyle.swift in Sources */, diff --git a/Tusker.xcworkspace/contents.xcworkspacedata b/Tusker.xcworkspace/contents.xcworkspacedata index bb8fa0df..6f52af94 100644 --- a/Tusker.xcworkspace/contents.xcworkspacedata +++ b/Tusker.xcworkspace/contents.xcworkspacedata @@ -4,6 +4,9 @@ + + diff --git a/Tusker/AvatarCache.swift b/Tusker/AvatarCache.swift deleted file mode 100644 index bc7ca328..00000000 --- a/Tusker/AvatarCache.swift +++ /dev/null @@ -1,61 +0,0 @@ -// -// ImageCache.swift -// Tusker -// -// Created by Anil Korde on 8/21/18. -// Copyright © 2018 Shadowfacts. All rights reserved. -// - -import UIKit - -class AvatarCache { - - static let shared = AvatarCache() - - let cache = NSCache() - - var requests = [URL: URLSessionDataTask]() - var requestCallbacks = [URL: [(UIImage?) -> Void]]() - - - - private init() { - } - - func get(_ url: URL, completion: @escaping (UIImage?) -> Void) { - let key = url.absoluteString as NSString - if let image = cache.object(forKey: key) { - completion(image) - } else if var callbacks = requestCallbacks[url] { - callbacks.append(completion) - requestCallbacks[url] = callbacks - } else { - requestCallbacks[url] = [completion] - let task = URLSession.shared.dataTask(with: url) { data, response, error in - guard error == nil, let data = data, let image = UIImage(data: data) else { - let callbacks = self.requestCallbacks.removeValue(forKey: url) - callbacks?.forEach({ callback in - // todo: default avatar for failed requests - callback(nil) - }) - return - } - - let callbacks = self.requestCallbacks.removeValue(forKey: url) - callbacks?.forEach({ callback in - callback(image) - }) - self.cache.setObject(image, forKey: key) - } - task.resume() - requests[url] = task - } - } - - func cancel(_ url: URL) { - requests[url]?.cancel() - requests.removeValue(forKey: url) - requestCallbacks.removeValue(forKey: url) - } - -} diff --git a/Tusker/ImageCache.swift b/Tusker/ImageCache.swift new file mode 100644 index 00000000..fecb6ea5 --- /dev/null +++ b/Tusker/ImageCache.swift @@ -0,0 +1,85 @@ +// +// ImageCache.swift +// Tusker +// +// Created by Shadowfacts on 8/21/18. +// Copyright © 2018 Shadowfacts. All rights reserved. +// + +import UIKit +import Cache + +class ImageCache { + + static let avatars = ImageCache(name: "Avatars") + + let storage: Storage + + var requests = [URL: Request]() + + init(name: String, diskExpiry: Expiry = .seconds(60 * 60 * 24), memoryExpiry: Expiry = .seconds(60 * 60)) { + self.storage = try! Storage( + diskConfig: DiskConfig(name: name, expiry: diskExpiry), + memoryConfig: MemoryConfig(expiry: memoryExpiry), + transformer: TransformerFactory.forImage()) + } + + func get(_ url: URL, completion: ((UIImage?) -> Void)?) { + let key = url.absoluteString + if (try? storage.existsObject(forKey: key)) ?? false, + let image = try? storage.object(forKey: key) { + completion?(image) + } else { + if let completion = completion, let request = requests[url] { + request.callbacks.append(completion) + } else { + let request = Request(url: url, completion: completion) + requests[url] = request + request.run { (image) in + try? self.storage.setObject(image, forKey: key) + } + } + } + } + + func cancel(_ url: URL) { + requests[url]?.cancel() + } + + class Request { + let url: URL + var task: URLSessionDataTask? + var callbacks: [(UIImage?) -> Void] + + init(url: URL, completion: ((UIImage?) -> Void)?) { + if let completion = completion { + self.callbacks = [completion] + } else { + self.callbacks = [] + } + self.url = url + } + + func run(cache: @escaping (UIImage) -> Void) { + task = URLSession.shared.dataTask(with: url, completionHandler: { (data, response, error) in + guard error == nil, let data = data, let image = UIImage(data: data) else { + self.complete(with: nil) + return + } + cache(image) + self.complete(with: image) + }) + task!.resume() + } + + func cancel() { + task?.cancel() + complete(with: nil) + } + + func complete(with image: UIImage?) { + callbacks.forEach { $0(image) } + } + } + +} diff --git a/Tusker/Screens/Compose/ComposeViewController.swift b/Tusker/Screens/Compose/ComposeViewController.swift index 0a8a5b06..287c72f4 100644 --- a/Tusker/Screens/Compose/ComposeViewController.swift +++ b/Tusker/Screens/Compose/ComposeViewController.swift @@ -92,7 +92,7 @@ class ComposeViewController: UIViewController { inReplyToAvatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: inReplyToAvatarImageView) inReplyToAvatarImageView.layer.masksToBounds = true inReplyToAvatarImageView.image = nil - AvatarCache.shared.get(inReplyTo.account.avatar) { image in + ImageCache.avatars.get(inReplyTo.account.avatar) { (image) in DispatchQueue.main.async { self.inReplyToAvatarImageView.image = image } diff --git a/Tusker/Views/Notifications/ActionNotificationTableViewCell.swift b/Tusker/Views/Notifications/ActionNotificationTableViewCell.swift index 458bb0ae..b00fdd2a 100644 --- a/Tusker/Views/Notifications/ActionNotificationTableViewCell.swift +++ b/Tusker/Views/Notifications/ActionNotificationTableViewCell.swift @@ -75,7 +75,7 @@ class ActionNotificationTableViewCell: UITableViewCell, PreferencesAdaptive { usernameLabel.text = "@\(status.account.acct)" opAvatarImageView.image = nil opAvatarURL = status.account.avatar - AvatarCache.shared.get(status.account.avatar) { image in + ImageCache.avatars.get(status.account.avatar) { (image) in DispatchQueue.main.async { self.opAvatarImageView.image = image self.opAvatarURL = nil @@ -83,7 +83,7 @@ class ActionNotificationTableViewCell: UITableViewCell, PreferencesAdaptive { } actionAvatarImageView.image = nil actionAvatarURL = notification.account.avatar - AvatarCache.shared.get(notification.account.avatar) { image in + ImageCache.avatars.get(notification.account.avatar) { (image) in DispatchQueue.main.async { self.actionAvatarImageView.image = image self.actionAvatarURL = nil @@ -158,10 +158,10 @@ class ActionNotificationTableViewCell: UITableViewCell, PreferencesAdaptive { override func prepareForReuse() { if let url = opAvatarURL { - AvatarCache.shared.cancel(url) + ImageCache.avatars.cancel(url) } if let url = actionAvatarURL { - AvatarCache.shared.cancel(url) + ImageCache.avatars.cancel(url) } updateTimestampWorkItem?.cancel() updateTimestampWorkItem = nil diff --git a/Tusker/Views/Notifications/FollowNotificationTableViewCell.swift b/Tusker/Views/Notifications/FollowNotificationTableViewCell.swift index 493bbb40..726f102b 100644 --- a/Tusker/Views/Notifications/FollowNotificationTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowNotificationTableViewCell.swift @@ -49,7 +49,7 @@ class FollowNotificationTableViewCell: UITableViewCell, PreferencesAdaptive { usernameLabel.text = "@\(account.acct)" avatarImageView.image = nil avatarURL = account.avatar - AvatarCache.shared.get(account.avatar) { image in + ImageCache.avatars.get(account.avatar) { (image) in DispatchQueue.main.async { self.avatarImageView.image = image self.avatarURL = nil @@ -81,7 +81,7 @@ class FollowNotificationTableViewCell: UITableViewCell, PreferencesAdaptive { override func prepareForReuse() { if let url = avatarURL { - AvatarCache.shared.cancel(url) + ImageCache.avatars.cancel(url) } updateTimestampWorkItem?.cancel() updateTimestampWorkItem = nil diff --git a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift index 386bcb51..a59c2c72 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderTableViewCell.swift @@ -59,7 +59,7 @@ class ProfileHeaderTableViewCell: UITableViewCell, PreferencesAdaptive { avatarImageView.image = nil avatarURL = account.avatar - AvatarCache.shared.get(account.avatar) { image in + ImageCache.avatars.get(account.avatar) { (image) in DispatchQueue.main.async { self.avatarImageView.image = image self.avatarURL = nil @@ -89,7 +89,7 @@ class ProfileHeaderTableViewCell: UITableViewCell, PreferencesAdaptive { override func prepareForReuse() { if let url = avatarURL { - AvatarCache.shared.cancel(url) + ImageCache.avatars.cancel(url) } } diff --git a/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift b/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift index 06db6f9c..7f07666a 100644 --- a/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift +++ b/Tusker/Views/Status/ConversationMainStatusTableViewCell.swift @@ -77,7 +77,7 @@ class ConversationMainStatusTableViewCell: UITableViewCell, PreferencesAdaptive usernameLabel.text = "@\(account.acct)" avatarImageView.image = nil avatarURL = account.avatar - AvatarCache.shared.get(account.avatar) { image in + ImageCache.avatars.get(account.avatar) { (image) in DispatchQueue.main.async { self.avatarImageView.image = image self.avatarURL = nil @@ -151,7 +151,7 @@ class ConversationMainStatusTableViewCell: UITableViewCell, PreferencesAdaptive override func prepareForReuse() { if let url = avatarURL { - AvatarCache.shared.cancel(url) + ImageCache.avatars.cancel(url) } updateTimestampWorkItem?.cancel() updateTimestampWorkItem = nil diff --git a/Tusker/Views/Status/StatusTableViewCell.swift b/Tusker/Views/Status/StatusTableViewCell.swift index 0a401336..db5b5efe 100644 --- a/Tusker/Views/Status/StatusTableViewCell.swift +++ b/Tusker/Views/Status/StatusTableViewCell.swift @@ -92,7 +92,7 @@ class StatusTableViewCell: UITableViewCell, PreferencesAdaptive { usernameLabel.text = "@\(account.acct)" avatarImageView.image = nil avatarURL = account.avatar - AvatarCache.shared.get(account.avatar) { image in + ImageCache.avatars.get(account.avatar) { (image) in DispatchQueue.main.async { self.avatarImageView.image = image self.avatarURL = nil @@ -166,7 +166,7 @@ class StatusTableViewCell: UITableViewCell, PreferencesAdaptive { override func prepareForReuse() { if let url = avatarURL { - AvatarCache.shared.cancel(url) + ImageCache.avatars.cancel(url) } updateTimestampWorkItem?.cancel() updateTimestampWorkItem = nil