From eb4e6e32f7114f43034949dc61af4979474c0581 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 1 Nov 2020 13:59:58 -0500 Subject: [PATCH] Add Grayscale Images preference --- Pachyderm/Client.swift | 3 +- Tusker.xcodeproj/project.pbxproj | 4 + Tusker/ImageGrayscalifier.swift | 59 +++++++++ Tusker/Preferences/Preferences.swift | 6 +- .../Large Image/LargeImageContentView.swift | 32 ++++- .../LargeImageViewController.swift | 18 ++- .../LoadingLargeImageViewController.swift | 28 +++-- .../Preferences/WellnessPrefsView.swift | 27 ++-- .../Profile/MyProfileViewController.swift | 17 ++- .../Account Cell/AccountTableViewCell.swift | 35 ++++-- Tusker/Views/Attachments/AttachmentView.swift | 118 +++++++++++++----- .../Attachments/GifvAttachmentView.swift | 36 +++++- Tusker/Views/BaseEmojiLabel.swift | 12 +- Tusker/Views/ContentTextView.swift | 12 +- ...ActionNotificationGroupTableViewCell.swift | 65 ++++++++-- ...FollowNotificationGroupTableViewCell.swift | 63 +++++++++- ...llowRequestNotificationTableViewCell.swift | 31 +++-- .../Profile Header/ProfileHeaderView.swift | 62 ++++++--- .../Status/BaseStatusTableViewCell.swift | 41 ++++-- Tusker/Views/Status/StatusCardView.swift | 55 +++++--- .../Status/TimelineStatusTableViewCell.swift | 4 +- 21 files changed, 592 insertions(+), 136 deletions(-) create mode 100644 Tusker/ImageGrayscalifier.swift diff --git a/Pachyderm/Client.swift b/Pachyderm/Client.swift index 7595dc54..4f7ab784 100644 --- a/Pachyderm/Client.swift +++ b/Pachyderm/Client.swift @@ -76,6 +76,7 @@ public class Client { return } guard let result = try? Client.decoder.decode(Result.self, from: data) else { + print(request) completion(.failure(.invalidModel)) return } @@ -90,7 +91,7 @@ public class Client { func createURLRequest(request: Request) -> URLRequest? { guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: true) else { return nil } components.path = request.path - components.queryItems = request.queryParameters.queryItems + components.queryItems = request.queryParameters.isEmpty ? nil : request.queryParameters.queryItems guard let url = components.url else { return nil } var urlRequest = URLRequest(url: url, timeoutInterval: timeoutInterval) urlRequest.httpMethod = request.method.name diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index f5ede7f8..45c2b068 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -231,6 +231,7 @@ D6B053AB23BD2F1400A066FA /* AssetCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */; }; D6B053AC23BD2F1400A066FA /* AssetCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */; }; D6B053AE23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */; }; + D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */; }; D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */; }; D6B8DB342182A59300424AF7 /* UIAlertController+Visibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */; }; D6BC874521961F73006163F1 /* Gifu.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D6BC874421961F73006163F1 /* Gifu.framework */; }; @@ -569,6 +570,7 @@ D6B053A923BD2F1400A066FA /* AssetCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetCollectionViewCell.swift; sourceTree = ""; }; D6B053AA23BD2F1400A066FA /* AssetCollectionViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = AssetCollectionViewCell.xib; sourceTree = ""; }; D6B053AD23BD322B00A066FA /* AssetPickerSheetContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetPickerSheetContainerViewController.swift; sourceTree = ""; }; + D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageGrayscalifier.swift; sourceTree = ""; }; D6B4A4FE2506B81A000C81C1 /* AccountDisplayNameLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDisplayNameLabel.swift; sourceTree = ""; }; D6B8DB332182A59300424AF7 /* UIAlertController+Visibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIAlertController+Visibility.swift"; sourceTree = ""; }; D6BC874421961F73006163F1 /* Gifu.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Gifu.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1385,6 +1387,7 @@ D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */, D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */, D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */, + D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */, D67B506B250B28FF00FAECFB /* Vendor */, D6F1F84E2193B9BE00F5FE67 /* Caching */, D6757A7A2157E00100721E32 /* XCallbackURL */, @@ -1842,6 +1845,7 @@ D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */, D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */, D62D2424217ABF3F005076CC /* NSUserActivity+Extensions.swift in Sources */, + D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, D6E426812532814100C02E1C /* MaybeLazyStack.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D6A3BC852321F6C100FD64D5 /* AccountListTableViewController.swift in Sources */, diff --git a/Tusker/ImageGrayscalifier.swift b/Tusker/ImageGrayscalifier.swift new file mode 100644 index 00000000..872ef7b5 --- /dev/null +++ b/Tusker/ImageGrayscalifier.swift @@ -0,0 +1,59 @@ +// +// ImageGrayscalifier.swift +// Tusker +// +// Created by Shadowfacts on 10/29/20. +// Copyright © 2020 Shadowfacts. All rights reserved. +// + +import UIKit + +struct ImageGrayscalifier { + static let queue = DispatchQueue(label: "ImageGrayscalifier", qos: .default) + + private static let context = CIContext() + private static let cache = NSCache() + + static func convert(url: URL?, data: Data) -> UIImage? { + if let url = url, + let cached = cache.object(forKey: url as NSURL) { + return cached + } + + guard let source = CIImage(data: data) else { + return nil + } + return doConvert(source, url: url) + } + + static func convert(url: URL?, cgImage: CGImage) -> UIImage? { + if let url = url, + let cached = cache.object(forKey: url as NSURL) { + return cached + } + + return doConvert(CIImage(cgImage: cgImage), url: url) + } + + private static func doConvert(_ source: CIImage, url: URL?) -> UIImage? { + guard let filter = CIFilter(name: "CIColorMonochrome") else { + return nil + } + + filter.setValue(source, forKey: "inputImage") + filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor") + filter.setValue(1.0, forKey: "inputIntensity") + + guard let output = filter.outputImage, + let cgImage = context.createCGImage(output, from: output.extent) else { + return nil + } + let image = UIImage(cgImage: cgImage) + + if let url = url { + cache.setObject(image, forKey: url as NSURL) + } + + return image + } +} diff --git a/Tusker/Preferences/Preferences.swift b/Tusker/Preferences/Preferences.swift index ffda4e3a..bf5c924c 100644 --- a/Tusker/Preferences/Preferences.swift +++ b/Tusker/Preferences/Preferences.swift @@ -64,6 +64,7 @@ class Preferences: Codable, ObservableObject { self.showFavoriteAndReblogCounts = try container.decode(Bool.self, forKey: .showFavoriteAndReblogCounts) self.defaultNotificationsMode = try container.decode(NotificationsMode.self, forKey: .defaultNotificationsType) + self.grayscaleImages = try container.decode(Bool.self, forKey: .grayscaleImages) self.silentActions = try container.decode([String: Permission].self, forKey: .silentActions) self.statusContentType = try container.decode(StatusContentType.self, forKey: .statusContentType) @@ -95,6 +96,7 @@ class Preferences: Codable, ObservableObject { try container.encode(showFavoriteAndReblogCounts, forKey: .showFavoriteAndReblogCounts) try container.encode(defaultNotificationsMode, forKey: .defaultNotificationsType) + try container.encode(grayscaleImages, forKey: .grayscaleImages) try container.encode(silentActions, forKey: .silentActions) try container.encode(statusContentType, forKey: .statusContentType) @@ -128,12 +130,13 @@ class Preferences: Codable, ObservableObject { // MARK: Digital Wellness @Published var showFavoriteAndReblogCounts = true @Published var defaultNotificationsMode = NotificationsMode.allNotifications + @Published var grayscaleImages = false // MARK: Advanced @Published var silentActions: [String: Permission] = [:] @Published var statusContentType: StatusContentType = .plain - enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey { case theme case avatarStyle case hideCustomEmojiInUsernames @@ -157,6 +160,7 @@ class Preferences: Codable, ObservableObject { case showFavoriteAndReblogCounts case defaultNotificationsType + case grayscaleImages case silentActions case statusContentType diff --git a/Tusker/Screens/Large Image/LargeImageContentView.swift b/Tusker/Screens/Large Image/LargeImageContentView.swift index 35ab86ab..e15b9eb4 100644 --- a/Tusker/Screens/Large Image/LargeImageContentView.swift +++ b/Tusker/Screens/Large Image/LargeImageContentView.swift @@ -11,10 +11,11 @@ import Gifu import Pachyderm import AVFoundation -protocol LargeImageContentView { +protocol LargeImageContentView: UIView { var animationImage: UIImage? { get } var animationGifData: Data? { get } var activityItemsForSharing: [Any] { get } + func grayscaleStateChanged() } class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentView { @@ -29,6 +30,14 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV [image!] } + private var sourceData: Data? + + convenience init(sourceData data: Data, isGif: Bool) { + self.init(image: UIImage(data: data)!, gifData: isGif ? data : nil) + + self.sourceData = data + } + init(image: UIImage, gifData: Data?) { self.animationGifData = gifData @@ -50,6 +59,23 @@ class LargeImageImageContentView: UIImageView, GIFAnimatable, LargeImageContentV updateImageIfNeeded() } + + func grayscaleStateChanged() { + guard let data = sourceData else { + return + } + + let image: UIImage? + if Preferences.shared.grayscaleImages { + image = ImageGrayscalifier.convert(url: nil, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + self.image = image + } + } } class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { @@ -85,4 +111,8 @@ class LargeImageGifvContentView: GifvAttachmentView, LargeImageContentView { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + func grayscaleStateChanged() { + // no-op, GifvAttachmentView observes the grayscale state itself + } } diff --git a/Tusker/Screens/Large Image/LargeImageViewController.swift b/Tusker/Screens/Large Image/LargeImageViewController.swift index d77e4c3a..0e0530dc 100644 --- a/Tusker/Screens/Large Image/LargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LargeImageViewController.swift @@ -10,8 +10,6 @@ import UIKit class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeImageAnimatableViewController { - typealias ContentView = UIView & LargeImageContentView - weak var animationSourceView: UIImageView? var largeImageController: LargeImageViewController? { self } var animationImage: UIImage? { contentView.animationImage } @@ -31,7 +29,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma @IBOutlet weak var bottomControlsView: UIView! @IBOutlet weak var descriptionLabel: UILabel! - var contentView: ContentView { + var contentView: LargeImageContentView { didSet { oldValue.removeFromSuperview() setupContentView() @@ -50,7 +48,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma } var shrinkGestureEnabled = true - var prevZoomScale: CGFloat? + private var prevZoomScale: CGFloat? + private var isGrayscale = false override var prefersStatusBarHidden: Bool { return true @@ -63,7 +62,7 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma return !controlsVisible } - init(contentView: ContentView, description: String?, sourceView: UIImageView?) { + init(contentView: LargeImageContentView, description: String?, sourceView: UIImageView?) { self.imageDescription = description self.animationSourceView = sourceView @@ -103,6 +102,8 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma let doubleTap = UITapGestureRecognizer(target: self, action: #selector(scrollViewDoubleTapped(_:))) doubleTap.numberOfTapsRequired = 2 view.addGestureRecognizer(doubleTap) + + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } private func setupContentView() { @@ -147,6 +148,13 @@ class LargeImageViewController: UIViewController, UIScrollViewDelegate, LargeIma closeButtonTrailingConstraint.constant = offset } } + + @objc private func preferencesChanged() { + if isGrayscale != Preferences.shared.grayscaleImages { + isGrayscale = Preferences.shared.grayscaleImages + contentView.grayscaleStateChanged() + } + } func setControlsVisible(_ controlsVisible: Bool, animated: Bool) { self.controlsVisible = controlsVisible diff --git a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift index 878fc624..18a390f8 100644 --- a/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift +++ b/Tusker/Screens/Large Image/LoadingLargeImageViewController.swift @@ -86,7 +86,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie view.backgroundColor = .black if let data = cache.get(url) { - createLargeImage(data: data) + createLargeImage(data: data, url: url) } else { createPreview() @@ -97,7 +97,7 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie self.imageRequest = nil DispatchQueue.main.async { self.loadingVC?.removeViewAndController() - self.createLargeImage(data: data!) + self.createLargeImage(data: data!, url: self.url) } } } @@ -115,12 +115,21 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie } } - private func createLargeImage(data: Data) { + private func createLargeImage(data: Data, url: URL) { guard !loaded else { return } loaded = true - guard let image = UIImage(data: data) else { return } - let gifData = url.pathExtension == "gif" ? data : nil - createLargeImage(image: image, gifData: gifData) + + let image: UIImage? + if Preferences.shared.grayscaleImages { + image = ImageGrayscalifier.convert(url: url, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + let gifData = url.pathExtension == "gif" ? data : nil + createLargeImage(image: image, gifData: gifData) + } } private func createLargeImage(image: UIImage, gifData: Data?) { @@ -138,8 +147,13 @@ class LoadingLargeImageViewController: UIViewController, LargeImageAnimatableVie private func createPreview() { guard !self.loaded, - let image = animationSourceView?.image else { return } + var image = animationSourceView?.image else { return } + if Preferences.shared.grayscaleImages, + let source = image.cgImage, + let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: source) { + image = grayscale + } self.createLargeImage(image: image, gifData: nil) } diff --git a/Tusker/Screens/Preferences/WellnessPrefsView.swift b/Tusker/Screens/Preferences/WellnessPrefsView.swift index 393c34a0..0c6e1636 100644 --- a/Tusker/Screens/Preferences/WellnessPrefsView.swift +++ b/Tusker/Screens/Preferences/WellnessPrefsView.swift @@ -9,31 +9,28 @@ import SwiftUI struct WellnessPrefsView: View { - @ObservedObject var preferences = Preferences.shared + @ObservedObject private var preferences = Preferences.shared var body: some View { List { - showFavAndReblogCountSection - notificationsModeSection + showFavAndReblogCount + notificationsMode + grayscaleImages } .insetOrGroupedListStyle() .navigationBarTitle(Text("Digital Wellness")) } - var showFavAndReblogCountSection: some View { - Section(footer: showFavAndReblogCountFooter) { + private var showFavAndReblogCount: some View { + Section(footer: Text("Control whether total favorite and reblog counts are shown for the main post in conversations.")) { Toggle(isOn: $preferences.showFavoriteAndReblogCounts) { Text("Show Favorite and Reblog Counts") } } } - var showFavAndReblogCountFooter: some View { - Text("Control whether total favorite and reblog counts are shown for the main post in conversations.") - } - - var notificationsModeSection: some View { - Section(footer: notificationsModeFooter) { + private var notificationsMode: some View { + Section(footer: Text("Choose which kinds of notifications will be shown by default in the Notifications tab.")) { Picker(selection: $preferences.defaultNotificationsMode, label: Text("Default Notifications Mode")) { ForEach(NotificationsMode.allCases, id: \.self) { type in Text(type.displayName).tag(type) @@ -42,8 +39,12 @@ struct WellnessPrefsView: View { } } - var notificationsModeFooter: some View { - Text("Choose which kinds of notifications will be shown by default in the Notifications tab.") + private var grayscaleImages: some View { + Section(footer: Text("Show attachments, avatars, headers, and custom emoji in black and white.")) { + Toggle(isOn: $preferences.grayscaleImages) { + Text("Grayscale Images") + } + } } } diff --git a/Tusker/Screens/Profile/MyProfileViewController.swift b/Tusker/Screens/Profile/MyProfileViewController.swift index e384cddf..5860e12e 100644 --- a/Tusker/Screens/Profile/MyProfileViewController.swift +++ b/Tusker/Screens/Profile/MyProfileViewController.swift @@ -40,8 +40,21 @@ class MyProfileViewController: ProfileViewController { } private func setAvatarTabBarImage(account: Account) { - _ = ImageCache.avatars.get(account.avatar, completion: { [weak self] (data) in - guard let self = self, let data = data, let image = UIImage(data: data) else { return } + let avatarURL = account.avatar + _ = ImageCache.avatars.get(avatarURL, completion: { [weak self] (data) in + guard let self = self, let data = data else { return } + + let maybeGrayscale: UIImage? + if Preferences.shared.grayscaleImages { + maybeGrayscale = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + maybeGrayscale = UIImage(data: data) + } + + guard let image = maybeGrayscale else { + return + } + DispatchQueue.main.async { let size = CGSize(width: 30, height: 30) let rect = CGRect(origin: .zero, size: size) diff --git a/Tusker/Views/Account Cell/AccountTableViewCell.swift b/Tusker/Views/Account Cell/AccountTableViewCell.swift index c8e1d331..6df6c309 100644 --- a/Tusker/Views/Account Cell/AccountTableViewCell.swift +++ b/Tusker/Views/Account Cell/AccountTableViewCell.swift @@ -21,7 +21,8 @@ class AccountTableViewCell: UITableViewCell { var accountID: String! - var avatarRequest: ImageCache.Request? + private var avatarRequest: ImageCache.Request? + private var isGrayscale = false override func awakeFromNib() { super.awakeFromNib() @@ -38,6 +39,10 @@ class AccountTableViewCell: UITableViewCell { fatalError("Missing cached account \(accountID!)") } displayNameLabel.updateForAccountDisplayName(account: account) + + if isGrayscale != Preferences.shared.grayscaleImages { + updateGrayscaleableUI(account: account) + } } func updateUI(accountID: String) { @@ -46,21 +51,37 @@ class AccountTableViewCell: UITableViewCell { fatalError("Missing cached account \(accountID)") } - avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in + usernameLabel.text = "@\(account.acct)" + + updateGrayscaleableUI(account: account) + updateUIForPrefrences() + } + + private func updateGrayscaleableUI(account: AccountMO) { + isGrayscale = Preferences.shared.grayscaleImages + + let accountID = self.accountID + + let avatarURL = account.avatar + avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in guard let self = self, let data = data, self.accountID == accountID else { return } self.avatarRequest = nil + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + DispatchQueue.main.async { - self.avatarImageView.image = UIImage(data: data) + self.avatarImageView.image = image } } - usernameLabel.text = "@\(account.acct)" - let doc = try! SwiftSoup.parse(account.note) noteLabel.text = try! doc.text() noteLabel.setEmojis(account.emojis, identifier: account.id) - - updateUIForPrefrences() } override func prepareForReuse() { diff --git a/Tusker/Views/Attachments/AttachmentView.swift b/Tusker/Views/Attachments/AttachmentView.swift index b5b11c04..7d6b46a9 100644 --- a/Tusker/Views/Attachments/AttachmentView.swift +++ b/Tusker/Views/Attachments/AttachmentView.swift @@ -28,11 +28,21 @@ class AttachmentView: UIImageView, GIFAnimatable { var expectedSize: CGSize! private var attachmentRequest: ImageCache.Request? + private var source: Source? - var gifData: Data? + var gifData: Data? { + switch source { + case let .gifData(_, data): + return data + default: + return nil + } + } private var autoplayGifs: Bool { Preferences.shared.automaticallyPlayGifs && !ProcessInfo.processInfo.isLowPowerModeEnabled } + + private var isGrayscale = false public lazy var animator: Animator? = Animator(withDelegate: self) @@ -55,19 +65,29 @@ class AttachmentView: UIImageView, GIFAnimatable { commonInit() } - func commonInit() { + private func commonInit() { contentMode = .scaleAspectFill layer.masksToBounds = true isUserInteractionEnabled = true addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(imagePressed))) - NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .preferencesChanged, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(gifPlaybackModeChanged), name: .NSProcessInfoPowerStateDidChange, object: nil) addInteraction(UIContextMenuInteraction(delegate: self)) } - @objc func gifPlaybackModeChanged() { + @objc private func preferencesChanged() { + gifPlaybackModeChanged() + + if isGrayscale != Preferences.shared.grayscaleImages { + ImageGrayscalifier.queue.async { + self.displayImage() + } + } + } + + @objc private func gifPlaybackModeChanged() { // NSProcessInfoPowerStateDidChange is sometimes fired on a background thread DispatchQueue.main.async { if self.attachment.kind == .image, @@ -106,13 +126,19 @@ class AttachmentView: UIImageView, GIFAnimatable { } else { size = self.expectedSize } - if let preview = UIImage(blurHash: hash, size: size) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - if self.image == nil { - self.image = preview - } - } + + guard var preview = UIImage(blurHash: hash, size: size) else { + return + } + + if Preferences.shared.grayscaleImages, + let grayscale = ImageGrayscalifier.convert(url: nil, cgImage: preview.cgImage!) { + preview = grayscale + } + + DispatchQueue.main.async { [weak self] in + guard let self = self, self.image == nil else { return } + self.image = preview } } } @@ -132,35 +158,34 @@ class AttachmentView: UIImageView, GIFAnimatable { } func loadImage() { - attachmentRequest = ImageCache.attachments.get(attachment.url) { [weak self] (data) in + let attachmentURL = attachment.url + attachmentRequest = ImageCache.attachments.get(attachmentURL) { [weak self] (data) in guard let self = self, let data = data else { return } self.attachmentRequest = nil - DispatchQueue.main.async { - if self.attachment.url.pathExtension == "gif" { - self.gifData = data - if self.autoplayGifs { - self.animate(withGIFData: data) - } else { - self.image = UIImage(data: data) - } + if self.attachment.url.pathExtension == "gif" { + self.source = .gifData(attachmentURL, data) + if self.autoplayGifs { + self.animate(withGIFData: data) } else { - self.image = UIImage(data: data) + self.displayImage() } + } else { + self.source = .imageData(attachmentURL, data) + self.displayImage() } } } func loadVideo() { let attachmentURL = self.attachment.url + // todo: use a single dispatch queue DispatchQueue.global(qos: .userInitiated).async { let asset = AVURLAsset(url: attachmentURL) let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } - DispatchQueue.main.async { [weak self] in - guard let self = self, self.attachment.url == attachmentURL else { return } - self.image = UIImage(cgImage: image) - } + self.source = .cgImage(attachmentURL, image) + self.displayImage() } let playImageView = UIImageView(image: UIImage(systemName: "play.circle.fill")) @@ -202,10 +227,8 @@ class AttachmentView: UIImageView, GIFAnimatable { let generator = AVAssetImageGenerator(asset: asset) generator.appliesPreferredTrackTransform = true guard let image = try? generator.copyCGImage(at: .zero, actualTime: nil) else { return } - DispatchQueue.main.async { [weak self] in - guard let self = self, self.attachment.url == attachmentURL else { return } - self.image = UIImage(cgImage: image) - } + self.source = .cgImage(attachmentURL, image) + self.displayImage() } let gifvView = GifvAttachmentView(asset: asset, gravity: .resizeAspectFill) @@ -223,6 +246,35 @@ class AttachmentView: UIImageView, GIFAnimatable { ]) } + private func displayImage() { + isGrayscale = Preferences.shared.grayscaleImages + + let image: UIImage? + + switch source { + case nil: + image = nil + + case let .imageData(url, data), let .gifData(url, data): + if isGrayscale { + image = ImageGrayscalifier.convert(url: url, data: data) + } else { + image = UIImage(data: data) + } + + case let .cgImage(url, cgImage): + if isGrayscale { + image = ImageGrayscalifier.convert(url: url, cgImage: cgImage) + } else { + image = UIImage(cgImage: cgImage) + } + } + + DispatchQueue.main.async { + self.image = image + } + } + override func display(_ layer: CALayer) { super.display(layer) @@ -242,6 +294,14 @@ class AttachmentView: UIImageView, GIFAnimatable { } +fileprivate extension AttachmentView { + enum Source { + case imageData(URL, Data) + case gifData(URL, Data) + case cgImage(URL, CGImage) + } +} + extension AttachmentView: UIContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil, previewProvider: { () -> UIViewController? in diff --git a/Tusker/Views/Attachments/GifvAttachmentView.swift b/Tusker/Views/Attachments/GifvAttachmentView.swift index 6ab2eec8..73c9bb57 100644 --- a/Tusker/Views/Attachments/GifvAttachmentView.swift +++ b/Tusker/Views/Attachments/GifvAttachmentView.swift @@ -19,12 +19,16 @@ class GifvAttachmentView: UIView { layer as! AVPlayerLayer } - let item: AVPlayerItem + private var asset: AVAsset + private(set) var item: AVPlayerItem let player: AVPlayer + private var isGrayscale = false init(asset: AVAsset, gravity: AVLayerVideoGravity) { - item = AVPlayerItem(asset: asset) + self.asset = asset + item = GifvAttachmentView.createItem(asset: asset) player = AVPlayer(playerItem: item) + isGrayscale = Preferences.shared.grayscaleImages super.init(frame: .zero) @@ -33,13 +37,39 @@ class GifvAttachmentView: UIView { player.isMuted = true NotificationCenter.default.addObserver(self, selector: #selector(restartItem), name: .AVPlayerItemDidPlayToEndTime, object: item) + NotificationCenter.default.addObserver(self, selector: #selector(preferencesChanged), name: .preferencesChanged, object: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - @objc func restartItem() { + private static func createItem(asset: AVAsset) -> AVPlayerItem { + let item = AVPlayerItem(asset: asset) + if Preferences.shared.grayscaleImages { + item.videoComposition = AVVideoComposition(asset: asset, applyingCIFiltersWithHandler: { (request) in + let filter = CIFilter(name: "CIColorMonochrome")! + + filter.setValue(request.sourceImage, forKey: "inputImage") + filter.setValue(CIColor(red: 0.85, green: 0.85, blue: 0.85), forKey: "inputColor") + filter.setValue(1.0, forKey: "inputIntensity") + + request.finish(with: filter.outputImage!, context: nil) + }) + } + return item + } + + @objc private func preferencesChanged() { + if isGrayscale != Preferences.shared.grayscaleImages { + isGrayscale = Preferences.shared.grayscaleImages + item = GifvAttachmentView.createItem(asset: asset) + player.replaceCurrentItem(with: item) + player.play() + } + } + + @objc private func restartItem() { item.seek(to: .zero) { (success) in guard success else { return } self.player.play() diff --git a/Tusker/Views/BaseEmojiLabel.swift b/Tusker/Views/BaseEmojiLabel.swift index c214f023..44510bbf 100644 --- a/Tusker/Views/BaseEmojiLabel.swift +++ b/Tusker/Views/BaseEmojiLabel.swift @@ -45,10 +45,18 @@ extension BaseEmojiLabel { group.enter() let request = ImageCache.emojis.get(emoji.url) { (data) in defer { group.leave() } - guard let data = data, let image = UIImage(data: data) else { + guard let data = data else { return } - emojiImages[emoji.shortcode] = image + let image: UIImage? + if Preferences.shared.grayscaleImages { + image = ImageGrayscalifier.convert(url: emoji.url, data: data) + } else { + image = UIImage(data: data) + } + if let image = image { + emojiImages[emoji.shortcode] = image + } } if let request = request { emojiRequests.append(request) diff --git a/Tusker/Views/ContentTextView.swift b/Tusker/Views/ContentTextView.swift index d5ce9ec2..f568e405 100644 --- a/Tusker/Views/ContentTextView.swift +++ b/Tusker/Views/ContentTextView.swift @@ -59,10 +59,18 @@ class ContentTextView: LinkTextView { group.enter() _ = ImageCache.emojis.get(emoji.url) { (data) in defer { group.leave() } - guard let data = data, let image = UIImage(data: data) else { + guard let data = data else { return } - emojiImages[emoji.shortcode] = image + let image: UIImage? + if Preferences.shared.grayscaleImages { + image = ImageGrayscalifier.convert(url: emoji.url, data: data) + } else { + image = UIImage(data: data) + } + if let image = image { + emojiImages[emoji.shortcode] = image + } } } diff --git a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift index dc6b30d2..25ea06a0 100644 --- a/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/ActionNotificationGroupTableViewCell.swift @@ -25,8 +25,9 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { var group: NotificationGroup! var statusID: String! - var avatarRequests = [String: ImageCache.Request]() - var updateTimestampWorkItem: DispatchWorkItem? + private var avatarRequests = [String: ImageCache.Request]() + private var updateTimestampWorkItem: DispatchWorkItem? + private var isGrayscale = false deinit { updateTimestampWorkItem?.cancel() @@ -44,6 +45,10 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { for case let imageView as UIImageView in actionAvatarStackView.arrangedSubviews { imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) } + + if isGrayscale != Preferences.shared.grayscaleImages { + updateGrayscaleableUI() + } } func updateUI(group: NotificationGroup) { @@ -67,8 +72,10 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { fatalError() } + isGrayscale = Preferences.shared.grayscaleImages + let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } - + actionAvatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } var imageViews = [UIImageView]() for account in people { @@ -76,11 +83,22 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { imageView.translatesAutoresizingMaskIntoConstraints = false imageView.layer.masksToBounds = true imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 - avatarRequests[account.id] = ImageCache.avatars.get(account.avatar) { [weak self] (data) in + let avatarURL = account.avatar + avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in guard let self = self, let data = data, self.group.id == group.id else { return } - DispatchQueue.main.async { - self.avatarRequests.removeValue(forKey: account.id) - imageView.image = UIImage(data: data) + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + DispatchQueue.main.async { + self.avatarRequests.removeValue(forKey: account.id) + imageView.image = image + } } } actionAvatarStackView.addArrangedSubview(imageView) @@ -104,7 +122,38 @@ class ActionNotificationGroupTableViewCell: UITableViewCell { statusContentLabel.text = try! doc.text() } - func updateTimestamp() { + private func updateGrayscaleableUI() { + let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } + let groupID = group.id + + for (index, account) in people.enumerated() { + guard actionAvatarStackView.arrangedSubviews.count > index, + let imageView = actionAvatarStackView.arrangedSubviews[index] as? UIImageView else { + continue + } + + let avatarURL = account.avatar + avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in + guard let self = self, let data = data, self.group.id == groupID else { return } + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + DispatchQueue.main.async { + self.avatarRequests.removeValue(forKey: account.id) + imageView.image = image + } + } + } + } + } + + private func updateTimestamp() { guard let notification = group.notifications.first else { fatalError("Missing cached notification") } diff --git a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift index e41d5c1a..6f089702 100644 --- a/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowNotificationGroupTableViewCell.swift @@ -20,8 +20,9 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { var group: NotificationGroup! - var avatarRequests = [String: ImageCache.Request]() - var updateTimestampWorkItem: DispatchWorkItem? + private var avatarRequests = [String: ImageCache.Request]() + private var updateTimestampWorkItem: DispatchWorkItem? + private var isGrayscale = false deinit { updateTimestampWorkItem?.cancel() @@ -39,6 +40,10 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { for case let imageView as UIImageView in avatarStackView.arrangedSubviews { imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: imageView) } + + if isGrayscale != Preferences.shared.grayscaleImages { + updateGrayscaleableUI() + } } func updateUI(group: NotificationGroup) { @@ -51,17 +56,30 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { }, identifier: group.id) updateTimestamp() + isGrayscale = Preferences.shared.grayscaleImages + avatarStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } for account in people { let imageView = UIImageView() imageView.translatesAutoresizingMaskIntoConstraints = false imageView.layer.masksToBounds = true imageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 - avatarRequests[account.id] = ImageCache.avatars.get(account.avatar) { [weak self] (data) in + let avatarURL = account.avatar + avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in guard let self = self, let data = data, self.group.id == group.id else { return } - DispatchQueue.main.async { - self.avatarRequests.removeValue(forKey: account.id) - imageView.image = UIImage(data: data) + + let image: UIImage? + if Preferences.shared.grayscaleImages { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + DispatchQueue.main.async { + self.avatarRequests.removeValue(forKey: account.id) + imageView.image = image + } } } avatarStackView.addArrangedSubview(imageView) @@ -72,6 +90,39 @@ class FollowNotificationGroupTableViewCell: UITableViewCell { } } + private func updateGrayscaleableUI() { + isGrayscale = Preferences.shared.grayscaleImages + + let people = group.notifications.compactMap { mastodonController.persistentContainer.account(for: $0.account.id) } + let groupID = group.id + + for (index, account) in people.enumerated() { + guard avatarStackView.arrangedSubviews.count > index, + let imageView = avatarStackView.arrangedSubviews[index] as? UIImageView else { + continue + } + + let avatarURL = account.avatar + avatarRequests[account.id] = ImageCache.avatars.get(avatarURL) { [weak self] (data) in + guard let self = self, let data = data, self.group.id == groupID else { return } + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + DispatchQueue.main.async { + self.avatarRequests.removeValue(forKey: account.id) + imageView.image = image + } + } + } + } + } + func updateActionLabel(names: [NSAttributedString]) -> NSAttributedString { // todo: figure out how to localize this let str = NSMutableAttributedString(string: "Followed by ") diff --git a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift index 1438a361..d9d28433 100644 --- a/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift +++ b/Tusker/Views/Notifications/FollowRequestNotificationTableViewCell.swift @@ -25,8 +25,9 @@ class FollowRequestNotificationTableViewCell: UITableViewCell { var notification: Pachyderm.Notification? var account: Account! - var avatarRequest: ImageCache.Request? - var updateTimestampWorkItem: DispatchWorkItem? + private var avatarRequest: ImageCache.Request? + private var updateTimestampWorkItem: DispatchWorkItem? + private var isGrayscale = false deinit { updateTimestampWorkItem?.cancel() @@ -43,6 +44,11 @@ class FollowRequestNotificationTableViewCell: UITableViewCell { @objc func updateUIForPreferences() { avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadiusFraction * 30 + + if isGrayscale != Preferences.shared.grayscaleImages, + let account = self.account { + updateUI(account: account) + } } func updateUI(notification: Pachyderm.Notification) { @@ -61,16 +67,27 @@ class FollowRequestNotificationTableViewCell: UITableViewCell { actionLabel.text = "Request to follow from \(account.displayName)" actionLabel.setEmojis(account.emojis, identifier: account.id) } - avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in - guard let self = self, self.account == account, let data = data, let image = UIImage(data: data) else { return } + let avatarURL = account.avatar + avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in + guard let self = self, self.account == account, let data = data else { return } self.avatarRequest = nil - DispatchQueue.main.async { - self.avatarImageView.image = image + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + if let image = image { + DispatchQueue.main.async { + self.avatarImageView.image = image + } } } } - func updateTimestamp() { + private func updateTimestamp() { guard let notification = notification else { return } timestampLabel.text = notification.createdAt.timeAgoString() diff --git a/Tusker/Views/Profile Header/ProfileHeaderView.swift b/Tusker/Views/Profile Header/ProfileHeaderView.swift index a20b8c79..ab4c20ca 100644 --- a/Tusker/Views/Profile Header/ProfileHeaderView.swift +++ b/Tusker/Views/Profile Header/ProfileHeaderView.swift @@ -48,6 +48,8 @@ class ProfileHeaderView: UIView { private var avatarRequest: ImageCache.Request? private var headerRequest: ImageCache.Request? + private var isGrayscale = false + private var cancellables = [AnyCancellable]() deinit { @@ -106,22 +108,7 @@ class ProfileHeaderView: UIView { usernameLabel.text = "@\(account.acct)" - avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in - guard let self = self, let data = data, self.accountID == accountID else { return } - self.avatarRequest = nil - DispatchQueue.main.async { - self.avatarImageView.image = UIImage(data: data) - } - } - if let header = account.header { - headerRequest = ImageCache.headers.get(header) { [weak self] (data) in - guard let self = self, let data = data, self.accountID == accountID else { return } - self.headerRequest = nil - DispatchQueue.main.async { - self.headerImageView.image = UIImage(data: data) - } - } - } + updateImages(account: account) if #available(iOS 14.0, *) { moreButton.menu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: actionsForProfile(accountID: accountID, sourceView: moreButton)) @@ -193,6 +180,49 @@ class ProfileHeaderView: UIView { avatarContainerView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarContainerView) avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) displayNameLabel.updateForAccountDisplayName(account: account) + + if isGrayscale != Preferences.shared.grayscaleImages { + updateImages(account: account) + } + } + + private func updateImages(account: AccountMO) { + isGrayscale = Preferences.shared.grayscaleImages + + let accountID = account.id + let avatarURL = account.avatar + avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in + guard let self = self, let data = data, self.accountID == accountID else { return } + self.avatarRequest = nil + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + DispatchQueue.main.async { + self.avatarImageView.image = image + } + } + if let header = account.header { + headerRequest = ImageCache.headers.get(header) { [weak self] (data) in + guard let self = self, let data = data, self.accountID == accountID else { return } + self.headerRequest = nil + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: header, data: data) + } else { + image = UIImage(data: data) + } + + DispatchQueue.main.async { + self.headerImageView.image = image + } + } + } } // MARK: Interaction diff --git a/Tusker/Views/Status/BaseStatusTableViewCell.swift b/Tusker/Views/Status/BaseStatusTableViewCell.swift index a607028b..e0c39774 100644 --- a/Tusker/Views/Status/BaseStatusTableViewCell.swift +++ b/Tusker/Views/Status/BaseStatusTableViewCell.swift @@ -74,6 +74,8 @@ class BaseStatusTableViewCell: UITableViewCell { private var currentPictureInPictureVideoStatusID: String? + private var isGrayscale = false + override func awakeFromNib() { super.awakeFromNib() @@ -142,6 +144,7 @@ class BaseStatusTableViewCell: UITableViewCell { let account = status.account self.accountID = account.id updateUI(account: account) + updateGrayscaleableUI(account: account, status: status) updateUIForPreferences(account: account, status: status) cardView.card = status.card @@ -154,8 +157,6 @@ class BaseStatusTableViewCell: UITableViewCell { updateStatusState(status: status) - contentTextView.setTextFrom(status: status) - contentWarningLabel.text = status.spoilerText contentWarningLabel.isHidden = status.spoilerText.isEmpty if !contentWarningLabel.isHidden { @@ -215,12 +216,6 @@ class BaseStatusTableViewCell: UITableViewCell { func updateUI(account: AccountMO) { usernameLabel.text = "@\(account.acct)" avatarImageView.image = nil - avatarRequest = ImageCache.avatars.get(account.avatar) { [weak self] (data) in - DispatchQueue.main.async { - guard let self = self, let data = data, self.accountID == account.id else { return } - self.avatarImageView.image = UIImage(data: data) - } - } } @objc private func preferencesChanged() { @@ -232,10 +227,13 @@ class BaseStatusTableViewCell: UITableViewCell { func updateUIForPreferences(account: AccountMO, status: StatusMO) { avatarImageView.layer.cornerRadius = Preferences.shared.avatarStyle.cornerRadius(for: avatarImageView) - displayNameLabel.updateForAccountDisplayName(account: account) attachmentsView.contentHidden = Preferences.shared.blurAllMedia || (mastodonController.persistentContainer.status(for: statusID)?.sensitive ?? false) updateStatusIconsForPreferences(status) + + if isGrayscale != Preferences.shared.grayscaleImages { + updateGrayscaleableUI(account: account, status: status) + } } func updateStatusIconsForPreferences(_ status: StatusMO) { @@ -253,6 +251,31 @@ class BaseStatusTableViewCell: UITableViewCell { reblogButton.setImage(reblogButtonImage, for: .normal) } + func updateGrayscaleableUI(account: AccountMO, status: StatusMO) { + isGrayscale = Preferences.shared.grayscaleImages + + let avatarURL = account.avatar + let accountID = account.id + avatarRequest = ImageCache.avatars.get(avatarURL) { [weak self] (data) in + guard let self = self, let data = data, self.accountID == accountID else { return } + + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: avatarURL, data: data) + } else { + image = UIImage(data: data) + } + + DispatchQueue.main.async { + self.avatarImageView.image = image + } + } + + contentTextView.setTextFrom(status: status) + + displayNameLabel.updateForAccountDisplayName(account: account) + } + override func prepareForReuse() { super.prepareForReuse() diff --git a/Tusker/Views/Status/StatusCardView.swift b/Tusker/Views/Status/StatusCardView.swift index ce817957..c60be175 100644 --- a/Tusker/Views/Status/StatusCardView.swift +++ b/Tusker/Views/Status/StatusCardView.swift @@ -26,6 +26,7 @@ class StatusCardView: UIView { private let inactiveBackgroundColor = UIColor.secondarySystemBackground private var imageRequest: ImageCache.Request? + private var isGrayscale = false private var titleLabel: UILabel! private var descriptionLabel: UILabel! @@ -108,26 +109,15 @@ class StatusCardView: UIView { placeholderImageView.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), placeholderImageView.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), ]) + + NotificationCenter.default.addObserver(self, selector: #selector(updateUIForPreferences), name: .preferencesChanged, object: nil) } private func updateUI(card: Card) { self.imageView.image = nil - if let image = card.image { - placeholderImageView.isHidden = true - - imageRequest = ImageCache.attachments.get(image, completion: { (data) in - guard let data = data, let image = UIImage(data: data) else { return } - DispatchQueue.main.async { - self.imageView.image = image - } - }) - if imageRequest != nil { - loadBlurHash() - } - } else { - placeholderImageView.isHidden = false - } + updateGrayscaleableUI(card: card) + updateUIForPreferences() let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) titleLabel.text = title @@ -138,6 +128,41 @@ class StatusCardView: UIView { descriptionLabel.isHidden = description.isEmpty } + @objc private func updateUIForPreferences() { + if isGrayscale != Preferences.shared.grayscaleImages, + let card = card { + updateGrayscaleableUI(card: card) + } + } + + private func updateGrayscaleableUI(card: Card) { + isGrayscale = Preferences.shared.grayscaleImages + + if let imageURL = card.image { + placeholderImageView.isHidden = true + + imageRequest = ImageCache.attachments.get(imageURL, completion: { (data) in + guard let data = data else { return } + let image: UIImage? + if self.isGrayscale { + image = ImageGrayscalifier.convert(url: imageURL, data: data) + } else { + image = UIImage(data: data) + } + if let image = image { + DispatchQueue.main.async { + self.imageView.image = image + } + } + }) + if imageRequest != nil { + loadBlurHash() + } + } else { + placeholderImageView.isHidden = false + } + } + private func loadBlurHash() { guard let card = card, let hash = card.blurhash else { return } diff --git a/Tusker/Views/Status/TimelineStatusTableViewCell.swift b/Tusker/Views/Status/TimelineStatusTableViewCell.swift index 1964d3b0..239dd1b7 100644 --- a/Tusker/Views/Status/TimelineStatusTableViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusTableViewCell.swift @@ -94,8 +94,8 @@ class TimelineStatusTableViewCell: BaseStatusTableViewCell { pinImageView.isHidden = !pinned } - override func updateUIForPreferences(account: AccountMO, status: StatusMO) { - super.updateUIForPreferences(account: account, status: status) + override func updateGrayscaleableUI(account: AccountMO, status: StatusMO) { + super.updateGrayscaleableUI(account: account, status: status) if let rebloggerID = rebloggerID, let reblogger = mastodonController.persistentContainer.account(for: rebloggerID) {